# Pigeons - complete documentation > Open source email infrastructure for developers and AI agents. > https://pigeons.dev ============================================================ Quick Start (quick-start) ============================================================ --- title: Quick Start description: Get from zero to sending email in under 2 minutes. --- ## Start the server ```bash docker run -d \ -p 3891:3891 \ -e PIGEONS_AWS_ACCESS_KEY_ID=your-key \ -e PIGEONS_AWS_SECRET_ACCESS_KEY=your-secret \ -e PIGEONS_AWS_REGION=us-east-1 \ -e PIGEONS_FROM_EMAIL=noreply@yourdomain.com \ ghcr.io/pigeons-dev/pigeons ``` On first boot, Pigeons prints your API key to stdout. Grab it from the logs: ```bash docker logs $(docker ps -q --filter ancestor=ghcr.io/pigeons-dev/pigeons) 2>&1 | grep "Your API key" ``` The key starts with `pgn_`. Save it somewhere safe - it won't be shown again. ## Send your first email ```bash curl -X POST http://localhost:3891/v1/emails \ -H "Content-Type: application/json" \ -H "x-api-key: pgn_your_key_here" \ -d '{ "to": "recipient@example.com", "subject": "Hello from Pigeons", "html": "
Here is your update.
', }) ``` ### Schedule an email ```typescript const email = await pigeons.emails.send({ to: 'alice@example.com', subject: 'Reminder', text: 'Your trial expires tomorrow.', scheduledAt: '2024-06-01T09:00:00Z', }) ``` ### Get email status ```typescript const email = await pigeons.emails.get('msg_abc123...') console.log(email.status) // "delivered" ``` ### List emails ```typescript const page = await pigeons.emails.list({ limit: 10, status: 'delivered' }) for (const email of page.data) { console.log(email.id, email.subject, email.status) } if (page.has_more) { const next = await pigeons.emails.list({ cursor: page.next_cursor! }) } ``` ### Cancel a queued email ```typescript const cancelled = await pigeons.emails.cancel('msg_abc123...') ``` ### Batch send ```typescript const results = await pigeons.emails.batch([ { to: 'alice@example.com', subject: 'Hi Alice', text: 'Hello!' }, { to: 'bob@example.com', subject: 'Hi Bob', text: 'Hello!' }, ]) ``` ### Idempotent sends ```typescript const email = await pigeons.emails.send( { to: 'alice@example.com', subject: 'Order #123', html: '...' }, { idempotencyKey: 'order-123-confirmation' }, ) ``` --- ## Contacts ### Create a contact ```typescript const contact = await pigeons.contacts.create({ email: 'alice@example.com', firstName: 'Alice', lastName: 'Smith', tags: ['newsletter', 'beta'], properties: { plan: 'pro', signupSource: 'website' }, }) ``` ### Get a contact ```typescript // By ID const contact = await pigeons.contacts.get('ctc_abc123...') // By email const contact = await pigeons.contacts.get('alice@example.com') ``` ### Update a contact ```typescript const updated = await pigeons.contacts.update('ctc_abc123...', { tags: ['newsletter', 'beta', 'vip'], properties: { plan: 'enterprise' }, }) ``` ### List contacts ```typescript const page = await pigeons.contacts.list({ tag: 'newsletter', limit: 50 }) ``` ### Delete a contact ```typescript await pigeons.contacts.delete('ctc_abc123...') ``` ### Batch create ```typescript const result = await pigeons.contacts.batchCreate([ { email: 'alice@example.com', firstName: 'Alice', tags: ['import'] }, { email: 'bob@example.com', firstName: 'Bob', tags: ['import'] }, ]) console.log(result.created) // 2 console.log(result.skipped) // 0 ``` ### Unsubscribe / Resubscribe ```typescript // Global unsubscribe await pigeons.contacts.unsubscribe('ctc_abc123...') // Per-topic unsubscribe await pigeons.contacts.unsubscribe('ctc_abc123...', { topicId: 'top_xyz...' }) // Resubscribe await pigeons.contacts.resubscribe('ctc_abc123...') ``` --- ## Templates ### Create a template ```typescript const template = await pigeons.templates.create({ slug: 'welcome-email', name: 'Welcome Email', subject: 'Welcome, {{name}}!', body: 'Hi {{name}},
', sampleData: { name: 'Alice', company: 'Acme' }, }) ``` ### Get / Update / Delete ```typescript const tpl = await pigeons.templates.get('welcome-email') // by slug or ID const updated = await pigeons.templates.update('welcome-email', { subject: 'New subject' }) await pigeons.templates.delete('welcome-email') ``` ### Preview a template ```typescript const preview = await pigeons.templates.preview('welcome-email', { name: 'Alice', company: 'Acme', }) console.log(preview.subject) // "Welcome, Alice!" console.log(preview.html) // rendered HTML ``` --- ## Campaigns ### Create and send a campaign ```typescript const campaign = await pigeons.campaigns.create({ name: 'Product Launch', templateId: 'product-launch', templateData: { launchDate: '2024-06-01' }, audienceFilter: { tags: { contains: 'newsletter' } }, }) // Send immediately await pigeons.campaigns.send(campaign.id) // Or schedule await pigeons.campaigns.send(campaign.id, { scheduledAt: '2024-06-01T09:00:00Z', }) ``` ### Campaign lifecycle ```typescript await pigeons.campaigns.pause(campaign.id) await pigeons.campaigns.resume(campaign.id) await pigeons.campaigns.cancel(campaign.id) ``` ### Monitor stats ```typescript const campaign = await pigeons.campaigns.get('cmp_abc123...') console.log(campaign.stats) // { total: 500, sent: 450, delivered: 440, opened: 120, bounced: 5, complained: 1, failed: 4 } ``` --- ## Sequences ### Create a sequence ```typescript const sequence = await pigeons.sequences.create({ name: 'Onboarding', steps: [ { type: 'email', templateId: 'welcome-email', subject: 'Welcome!' }, { type: 'delay', delayDuration: '1d' }, { type: 'email', templateId: 'getting-started', subject: 'Getting Started' }, { type: 'delay', delayDuration: '3d' }, { type: 'email', templateId: 'tips', subject: 'Pro Tips' }, ], }) ``` ### Activate and enroll ```typescript await pigeons.sequences.activate(sequence.id) const enrollment = await pigeons.sequences.enroll(sequence.id, 'ctc_abc123...') console.log(enrollment.status) // "active" ``` ### Monitor enrollments ```typescript const page = await pigeons.sequences.enrollments(sequence.id, { status: 'active' }) ``` --- ## Inboxes ### Create an inbox ```typescript const inbox = await pigeons.inboxes.create({ name: 'Support', emailAddress: 'support@yourdomain.com', webhookUrl: 'https://your-app.com/webhooks/email', }) ``` ### Inbox operations ```typescript await pigeons.inboxes.pause(inbox.id) await pigeons.inboxes.resume(inbox.id) await pigeons.inboxes.setCatchAll(inbox.id) await pigeons.inboxes.delete(inbox.id) ``` ### Read messages ```typescript const messages = await pigeons.inboxes.messages.list(inbox.id, { direction: 'inbound', limit: 10, }) const message = await pigeons.inboxes.messages.get(inbox.id, 'imsg_abc123...') ``` ### Send from an inbox ```typescript const sent = await pigeons.inboxes.messages.create(inbox.id, { to: 'customer@example.com', subject: 'Re: Your ticket', html: 'Thanks for reaching out.
', inReplyTo: 'imsg_previous...', // maintains thread }) ``` ### Threads ```typescript const threads = await pigeons.inboxes.threads.list(inbox.id, { status: 'open' }) const thread = await pigeons.inboxes.threads.get(inbox.id, 'thr_abc123...') await pigeons.inboxes.threads.update(inbox.id, 'thr_abc123...', { status: 'closed', }) ``` --- ## Webhooks ```typescript const webhook = await pigeons.webhooks.create({ url: 'https://your-app.com/webhooks/pigeons', events: ['email.delivered', 'email.bounced', 'inbox.message.received'], }) // The secret is only returned on creation console.log(webhook.secret) // "whsec_..." ``` --- ## Domains ```typescript const domain = await pigeons.domains.add({ domain: 'example.com' }) console.log(domain.records) // DNS records to add // Re-check verification const verified = await pigeons.domains.verify(domain.id) console.log(verified.verificationStatus) // "verified" ``` --- ## Error Handling All errors extend `PigeonsError`: ```typescript import { PigeonsError, NotFoundError, AuthError, RateLimitError } from '@pigeons/pigeons-ts' try { await pigeons.emails.get('msg_nonexistent') } catch (err) { if (err instanceof NotFoundError) { console.log(err.message) // "Email 'msg_nonexistent' not found" console.log(err.statusCode) // 404 console.log(err.code) // "not_found" console.log(err.type) // "not_found" } } ``` Error classes: | Class | Status | When | |---|---|---| | `PigeonsError` | varies | Base class for all errors | | `ValidationError` | 400 | Invalid request parameters | | `AuthError` | 401 | Missing or invalid API key | | `NotFoundError` | 404 | Resource not found | | `RateLimitError` | 429 | Rate limit exceeded | ============================================================ Python SDK (python-sdk) ============================================================ --- title: Python SDK description: Send emails, manage contacts, and build automations with the Python SDK. --- ## Install ```bash pip install pigeons-py ``` ## Initialize ```python from pigeons import Pigeons pigeons = Pigeons( base_url="https://pigeons.yourdomain.com", api_key="pgn_your_key_here", ) ``` Both options can be set via environment variables instead: | Option | Env var | Description | |---|---|---| | `base_url` | `PIGEONS_URL` | Pigeons server URL (no default) | | `api_key` | `PIGEONS_API_KEY` | API key starting with `pgn_` | Constructor values take precedence over environment variables. --- ## Emails ### Send an email ```python email = pigeons.emails.send( to="alice@example.com", subject="Welcome!", html="Here is your update.
", ) ``` ### Schedule an email ```python email = pigeons.emails.send( to="alice@example.com", subject="Reminder", text="Your trial expires tomorrow.", scheduled_at="2024-06-01T09:00:00Z", ) ``` ### Get email status ```python email = pigeons.emails.get("msg_abc123...") print(email.status) # "delivered" ``` ### List emails ```python page = pigeons.emails.list(limit=10, status="delivered") for email in page.data: print(email.id, email.subject, email.status) if page.has_more: next_page = pigeons.emails.list(cursor=page.next_cursor) ``` ### Cancel a queued email ```python cancelled = pigeons.emails.cancel("msg_abc123...") ``` ### Batch send ```python results = pigeons.emails.batch([ {"to": "alice@example.com", "subject": "Hi Alice", "text": "Hello!"}, {"to": "bob@example.com", "subject": "Hi Bob", "text": "Hello!"}, ]) ``` ### Idempotent sends ```python email = pigeons.emails.send( to="alice@example.com", subject="Order #123", html="...", idempotency_key="order-123-confirmation", ) ``` --- ## Contacts ### Create a contact ```python contact = pigeons.contacts.create( email="alice@example.com", first_name="Alice", last_name="Smith", tags=["newsletter", "beta"], properties={"plan": "pro", "signup_source": "website"}, ) ``` ### Get a contact ```python # By ID contact = pigeons.contacts.get("ctc_abc123...") # By email contact = pigeons.contacts.get("alice@example.com") ``` ### Update a contact ```python updated = pigeons.contacts.update("ctc_abc123...", tags=["newsletter", "vip"]) ``` ### List contacts ```python page = pigeons.contacts.list(tag="newsletter", limit=50) ``` ### Delete a contact ```python pigeons.contacts.delete("ctc_abc123...") ``` ### Batch create ```python result = pigeons.contacts.batch_create([ {"email": "alice@example.com", "first_name": "Alice", "tags": ["import"]}, {"email": "bob@example.com", "first_name": "Bob", "tags": ["import"]}, ]) print(result.created) # 2 print(result.skipped) # 0 ``` ### Unsubscribe / Resubscribe ```python # Global unsubscribe pigeons.contacts.unsubscribe("ctc_abc123...") # Per-topic unsubscribe pigeons.contacts.unsubscribe("ctc_abc123...", topic_id="top_xyz...") # Resubscribe pigeons.contacts.resubscribe("ctc_abc123...") ``` --- ## Templates ### Create a template ```python template = pigeons.templates.create( slug="welcome-email", name="Welcome Email", subject="Welcome, {{name}}!", body="Hi {{name}},
", sample_data={"name": "Alice", "company": "Acme"}, ) ``` ### Get / Update / Delete ```python tpl = pigeons.templates.get("welcome-email") # by slug or ID updated = pigeons.templates.update("welcome-email", subject="New subject") pigeons.templates.delete("welcome-email") ``` ### Preview a template ```python preview = pigeons.templates.preview("welcome-email", data={"name": "Alice", "company": "Acme"}) print(preview.subject) # "Welcome, Alice!" print(preview.html) # rendered HTML ``` --- ## Campaigns ### Create and send a campaign ```python campaign = pigeons.campaigns.create( name="Product Launch", template_id="product-launch", template_data={"launch_date": "2024-06-01"}, audience_filter={"tags": {"contains": "newsletter"}}, ) # Send immediately pigeons.campaigns.send(campaign.id) # Or schedule pigeons.campaigns.send(campaign.id, scheduled_at="2024-06-01T09:00:00Z") ``` ### Campaign lifecycle ```python pigeons.campaigns.pause(campaign.id) pigeons.campaigns.resume(campaign.id) pigeons.campaigns.cancel(campaign.id) ``` ### Monitor stats ```python campaign = pigeons.campaigns.get("cmp_abc123...") print(campaign.stats) # {"total": 500, "sent": 450, "delivered": 440, "opened": 120, ...} ``` --- ## Sequences ### Create a sequence ```python sequence = pigeons.sequences.create( name="Onboarding", steps=[ {"type": "email", "template_id": "welcome-email", "subject": "Welcome!"}, {"type": "delay", "delay_duration": "1d"}, {"type": "email", "template_id": "getting-started", "subject": "Getting Started"}, {"type": "delay", "delay_duration": "3d"}, {"type": "email", "template_id": "tips", "subject": "Pro Tips"}, ], ) ``` ### Activate and enroll ```python pigeons.sequences.activate(sequence.id) enrollment = pigeons.sequences.enroll(sequence.id, "ctc_abc123...") print(enrollment.status) # "active" ``` ### Monitor enrollments ```python page = pigeons.sequences.enrollments(sequence.id, status="active") ``` --- ## Inboxes ### Create an inbox ```python inbox = pigeons.inboxes.create( name="Support", email_address="support@yourdomain.com", webhook_url="https://your-app.com/webhooks/email", ) ``` ### Inbox operations ```python pigeons.inboxes.pause(inbox.id) pigeons.inboxes.resume(inbox.id) pigeons.inboxes.set_catch_all(inbox.id) pigeons.inboxes.delete(inbox.id) ``` ### Read messages ```python messages = pigeons.inboxes.messages.list(inbox.id, direction="inbound", limit=10) message = pigeons.inboxes.messages.get(inbox.id, "imsg_abc123...") ``` ### Send from an inbox ```python sent = pigeons.inboxes.messages.create( inbox.id, to="customer@example.com", subject="Re: Your ticket", html="Thanks for reaching out.
", in_reply_to="imsg_previous...", # maintains thread ) ``` ### Threads ```python threads = pigeons.inboxes.threads.list(inbox.id, status="open") thread = pigeons.inboxes.threads.get(inbox.id, "thr_abc123...") pigeons.inboxes.threads.update(inbox.id, "thr_abc123...", status="closed") ``` --- ## Webhooks ```python webhook = pigeons.webhooks.create( url="https://your-app.com/webhooks/pigeons", events=["email.delivered", "email.bounced", "inbox.message.received"], ) # The secret is only returned on creation print(webhook.secret) # "whsec_..." ``` --- ## Domains ```python domain = pigeons.domains.add(domain="example.com") print(domain.records) # DNS records to add # Re-check verification verified = pigeons.domains.verify(domain.id) print(verified.verification_status) # "verified" ``` --- ## Error Handling All errors extend `PigeonsError`: ```python from pigeons import PigeonsError, NotFoundError, AuthError, RateLimitError try: pigeons.emails.get("msg_nonexistent") except NotFoundError as e: print(e.message) # "Email 'msg_nonexistent' not found" print(e.status_code) # 404 print(e.code) # "not_found" except PigeonsError as e: print(f"API error: {e.message}") ``` Error classes: | Class | Status | When | |---|---|---| | `PigeonsError` | varies | Base class for all errors | | `ValidationError` | 400 | Invalid request parameters | | `AuthError` | 401 | Missing or invalid API key | | `NotFoundError` | 404 | Resource not found | | `RateLimitError` | 429 | Rate limit exceeded | ============================================================ CLI (cli) ============================================================ --- title: CLI description: Manage your Pigeons instance from the command line. --- ## Install ```bash # Run directly npx @pigeons/cliHi {{name}}, thanks for signing up.
", "sampleData": {"name": "Alice", "company": "Acme"} }' ``` ```typescript const template = await pigeons.templates.create({ slug: 'welcome-email', name: 'Welcome Email', subject: 'Welcome, {{name}}!', body: 'Hi {{name}}, thanks for signing up.
', sampleData: { name: 'Alice', company: 'Acme' }, }) ``` ### Slug rules - Lowercase letters, numbers, and hyphens only - Must be unique across all templates - Used as a stable identifier in API calls (alternative to `tpl_` IDs) ## Variables Use `{{variableName}}` in both the subject and body. Variables are replaced with values from the `data` object at send time. ```htmlHi {{name}},
Your account is ready. Your plan: {{plan}}.
``` Sent with: ```json { "data": { "name": "Alice", "company": "Acme", "plan": "Pro" } } ``` Produces: ```htmlHi Alice,
Your account is ready. Your plan: Pro.
``` ## Sample Data The `sampleData` field stores default variable values for previewing the template without passing data: ```json { "sampleData": { "name": "Alice", "company": "Acme", "plan": "Pro" } } ``` This data is used by the preview endpoint and is never sent to real recipients. ## Preview a Template Render a template without sending: ```bash curl -X POST http://localhost:3891/v1/templates/welcome-email/preview \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"data": {"name": "Bob", "company": "Widgets Inc"}}' ``` If no `data` is provided, the template's `sampleData` is used. Response: ```json { "subject": "Welcome, Bob!", "html": "Hi {{name}}
', }) console.log(updated.version) // 2 ``` Updating only `name` or `sampleData` does not increment the version. ## Delete a Template ```bash curl -X DELETE http://localhost:3891/v1/templates/welcome-email \ -H "x-api-key: pgn_..." ``` Deleting a template does not affect emails already sent using it. ============================================================ Inboxes (inboxes) ============================================================ --- title: Inboxes description: Receive email into Pigeons with SES inbound, threading, and webhook delivery. --- ## Overview Pigeons inboxes let you receive email at real addresses and access messages via API. The inbound pipeline: 1. Someone sends email to `support@yourdomain.com` 2. SES receipt rule saves the raw MIME to S3 and notifies via SNS 3. Pigeons receives the SNS notification, fetches the email from S3, parses it 4. The message is routed to the matching inbox, threaded, and stored 5. Webhooks fire with the `inbox.message.received` event ## Prerequisites Inbound email requires: - An AWS region that supports SES inbound (`us-east-1`, `us-west-2`, or `eu-west-1`) - An S3 bucket for raw MIME storage - An SNS topic for inbound notifications - An SES receipt rule routing your domain to S3 + SNS - An MX record pointing your domain to SES inbound The easiest way to set all this up is the CLI wizard: ```bash pigeons setup aws --domain yourdomain.com ``` ## Manual SES Setup ### 1. S3 bucket Create a bucket (e.g. `pigeons-inbound-yourdomain-com`) with a policy allowing SES to write objects: ```json { "Version": "2012-10-17", "Statement": [{ "Sid": "AllowSESPut", "Effect": "Allow", "Principal": {"Service": "ses.amazonaws.com"}, "Action": "s3:PutObject", "Resource": "arn:aws:s3:::pigeons-inbound-yourdomain-com/*", "Condition": {"StringEquals": {"AWS:SourceAccount": "123456789012"}} }] } ``` Set these environment variables: ``` PIGEONS_INBOUND_S3_BUCKET=pigeons-inbound-yourdomain-com PIGEONS_INBOUND_S3_KEY_PREFIX=inbound/ ``` ### 2. SNS topic Create an SNS topic (e.g. `pigeons-inbound-yourdomain-com`) and subscribe it to your Pigeons server: ```bash aws sns subscribe \ --topic-arn arn:aws:sns:us-east-1:123456789:pigeons-inbound-yourdomain-com \ --protocol https \ --endpoint https://pigeons.yourdomain.com/v1/internal/ses/inbound ``` ### 3. SES receipt rule Create a receipt rule set and rule: ```bash # Create rule set (one-time) aws ses create-receipt-rule-set --rule-set-name pigeons-inbound aws ses set-active-receipt-rule-set --rule-set-name pigeons-inbound # Create rule for your domain aws ses create-receipt-rule \ --rule-set-name pigeons-inbound \ --rule '{ "Name": "pigeons-yourdomain-com", "Enabled": true, "Recipients": ["yourdomain.com"], "Actions": [ {"S3Action": {"BucketName": "pigeons-inbound-yourdomain-com", "ObjectKeyPrefix": "inbound/"}}, {"SNSAction": {"TopicArn": "arn:aws:sns:us-east-1:123456789:pigeons-inbound-yourdomain-com", "Encoding": "UTF-8"}} ], "ScanEnabled": true }' ``` ### 4. MX record Point your domain to SES inbound: ``` MX yourdomain.com → 10 inbound-smtp.us-east-1.amazonaws.com ``` ## Create an Inbox ```bash curl -X POST http://localhost:3891/v1/inboxes \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Support", "emailAddress": "support@yourdomain.com", "description": "Customer support inbox", "webhookUrl": "https://your-app.com/webhooks/email" }' ``` ```typescript const inbox = await pigeons.inboxes.create({ name: 'Support', emailAddress: 'support@yourdomain.com', webhookUrl: 'https://your-app.com/webhooks/email', }) ``` The `emailAddress` must belong to a domain with inbound capabilities configured. ## Webhook Delivery When a message arrives, Pigeons fires a webhook to the inbox's `webhookUrl` (if set) and to any globally registered webhooks subscribed to `inbox.message.received`. Webhook payload: ```json { "event": "inbox.message.received", "data": { "inbox_id": "inb_abc123...", "message_id": "imsg_xyz789...", "thread_id": "thr_def456...", "from": "customer@example.com", "to": ["support@yourdomain.com"], "subject": "Help with my order", "text": "I need help with order #123...", "direction": "inbound" } } ``` ## Threading Pigeons automatically threads messages using email headers: 1. **In-Reply-To** / **References** headers - standard email threading 2. **Subject matching** - messages with the same subject and participants are grouped Each thread tracks: | Field | Description | |---|---| | `id` | Thread ID (`thr_...`) | | `subject` | Thread subject | | `lastMessageAt` | Timestamp of the most recent message | | `messageCount` | Number of messages in the thread | | `participants` | Array of `{ email, name? }` | | `status` | `open`, `closed`, or `snoozed` | ### List threads ```bash curl "http://localhost:3891/v1/inboxes/inb_abc123.../threads?status=open" \ -H "x-api-key: pgn_..." ``` ### Get messages in a thread ```bash curl "http://localhost:3891/v1/inboxes/inb_abc123.../messages?thread_id=thr_def456..." \ -H "x-api-key: pgn_..." ``` ### Update thread status ```bash curl -X PATCH http://localhost:3891/v1/inboxes/inb_abc123.../threads/thr_def456... \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"status": "closed"}' ``` ## Send from an Inbox Reply to a message or start a new conversation: ```bash curl -X POST http://localhost:3891/v1/inboxes/inb_abc123.../messages \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "to": "customer@example.com", "subject": "Re: Help with my order", "html": "Thanks for reaching out. Let me look into order #123.
", "inReplyTo": "imsg_previous..." }' ``` The `inReplyTo` field maintains threading. When set, the outbound message is added to the same thread as the referenced message. ## Reply Extraction Inbound messages include both the full content and extracted content: | Field | Description | |---|---| | `html` | Full HTML body | | `text` | Full plain text body | | `extractedHtml` | Just the new reply (quoted text stripped) | | `extractedText` | Just the new reply as plain text | Use the `extracted*` fields to show only the new content without quoted reply chains. ## Catch-All Inbox Designate one inbox as the catch-all to receive messages that don't match any other inbox: ```bash curl -X POST http://localhost:3891/v1/inboxes/inb_abc123.../catch-all \ -H "x-api-key: pgn_..." ``` Only one inbox can be catch-all at a time. Setting a new catch-all removes the flag from the previous one. When an inbound email arrives for an address that doesn't match any inbox's `emailAddress`, it routes to the catch-all inbox. If no catch-all is configured, unmatched messages are discarded. ## Pause and Resume ```bash # Pause - stops accepting new messages curl -X POST http://localhost:3891/v1/inboxes/inb_abc123.../pause -H "x-api-key: pgn_..." # Resume curl -X POST http://localhost:3891/v1/inboxes/inb_abc123.../resume -H "x-api-key: pgn_..." ``` Paused inboxes still receive email at the SES level, but Pigeons does not store or process the messages. ## Message Attachments Inbound messages with attachments include an `attachments` array: ```json { "attachments": [ { "filename": "invoice.pdf", "contentType": "application/pdf", "size": 45231, "s3Key": "inbound/attachment/..." } ] } ``` The `s3Key` can be used to download the attachment from the inbound S3 bucket. ============================================================ Campaigns & Sequences (campaigns) ============================================================ --- title: Campaigns & Sequences description: Send bulk campaigns to targeted audiences and build multi-step automated sequences. --- ## Campaigns A campaign sends one email to a set of contacts, filtered by tags and properties. ### Lifecycle ``` draft → sending → sent ↓ ↑ paused draft → scheduled → sending → sent ↓ cancelled ``` | Status | Meaning | |---|---| | `draft` | Campaign is being configured. Can be edited. | | `scheduled` | Waiting for `scheduledAt` time. Can be cancelled. | | `sending` | Actively sending to recipients. Can be paused or cancelled. | | `paused` | Sending paused. Can be resumed. | | `sent` | All emails have been processed. | | `cancelled` | Campaign was cancelled before completing. | ### Create a campaign ```bash curl -X POST http://localhost:3891/v1/campaigns \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "name": "October Newsletter", "templateId": "monthly-newsletter", "templateData": {"month": "October", "highlight": "New API features"}, "audienceFilter": {"tags": {"contains": "newsletter"}}, "fromName": "Acme Updates", "fromEmail": "newsletter@yourdomain.com" }' ``` ```typescript const campaign = await pigeons.campaigns.create({ name: 'October Newsletter', templateId: 'monthly-newsletter', templateData: { month: 'October', highlight: 'New API features' }, audienceFilter: { tags: { contains: 'newsletter' } }, fromName: 'Acme Updates', fromEmail: 'newsletter@yourdomain.com', }) ``` ### Audience Filters The `audienceFilter` object determines which contacts receive the campaign. Contacts where `unsubscribed`, `bounced`, or `complained` is true are always excluded. #### Filter by tags ```json { "audienceFilter": { "tags": { "contains": "newsletter" } } } ``` | Operator | Description | |---|---| | `contains` | Contact must have this exact tag | | `containsAny` | Contact must have at least one of these tags | | `containsAll` | Contact must have all of these tags | ```json { "tags": { "containsAll": ["newsletter", "active"], "containsAny": ["pro", "enterprise"] } } ``` #### Filter by properties ```json { "audienceFilter": { "properties": { "plan": "pro", "region": "us" } } } ``` Properties match on exact equality. All specified properties must match. #### Combined filters ```json { "audienceFilter": { "tags": {"contains": "newsletter"}, "properties": {"plan": "pro"} } } ``` #### No filter Omit `audienceFilter` to target all eligible contacts (those not unsubscribed, bounced, or complained). ### Send a campaign ```bash # Send immediately curl -X POST http://localhost:3891/v1/campaigns/cmp_abc123.../send \ -H "x-api-key: pgn_..." # Schedule for later curl -X POST http://localhost:3891/v1/campaigns/cmp_abc123.../send \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"scheduledAt": "2024-06-01T09:00:00Z"}' ``` The campaign must have either a `templateId` or a `subject`. When sending, Pigeons: 1. Queries all contacts matching the audience filter 2. Excludes unsubscribed, bounced, and complained contacts 3. Creates a job to send to each eligible contact 4. Updates the `stats.total` field with the audience size If no eligible contacts match, the send fails with a validation error. ### Pause / Resume / Cancel ```bash # Pause (only while sending) curl -X POST http://localhost:3891/v1/campaigns/cmp_abc123.../pause \ -H "x-api-key: pgn_..." # Resume (only while paused) curl -X POST http://localhost:3891/v1/campaigns/cmp_abc123.../resume \ -H "x-api-key: pgn_..." # Cancel (while sending or scheduled) curl -X POST http://localhost:3891/v1/campaigns/cmp_abc123.../cancel \ -H "x-api-key: pgn_..." ``` ### Campaign stats ```typescript const campaign = await pigeons.campaigns.get('cmp_abc123...') console.log(campaign.stats) ``` ```json { "total": 500, "sent": 450, "delivered": 440, "opened": 120, "bounced": 5, "complained": 1, "failed": 4 } ``` Stats update in real time as SES delivery events arrive. The `opened` count requires [open tracking](/docs/open-tracking) to be enabled. ### Topic-scoped campaigns Link a campaign to a subscription topic so contacts who unsubscribed from that specific topic are excluded: ```typescript const campaign = await pigeons.campaigns.create({ name: 'Marketing Update', topicId: 'top_marketing...', templateId: 'marketing-update', audienceFilter: { tags: { contains: 'active' } }, }) ``` --- ## Sequences A sequence is a multi-step automation that sends a series of emails to individual contacts over time. ### Create a sequence ```bash curl -X POST http://localhost:3891/v1/sequences \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Onboarding", "steps": [ {"type": "email", "templateId": "welcome-email", "subject": "Welcome!"}, {"type": "delay", "delayDuration": "1d"}, {"type": "email", "templateId": "getting-started", "subject": "Getting Started"}, {"type": "delay", "delayDuration": "3d"}, {"type": "email", "templateId": "tips", "subject": "Pro Tips"} ] }' ``` ### Step types | Type | Fields | Description | |---|---|---| | `email` | `templateId`, `subject`, `templateData`, `topicId` | Sends an email using a template | | `delay` | `delayDuration` | Waits before proceeding. Format: `30m`, `12h`, `1d`, `7d` | | `condition` | `condition` | Evaluates a condition before continuing | ### Sequence lifecycle | Status | Meaning | |---|---| | `draft` | Being configured. Can't enroll contacts yet. | | `active` | Running. Contacts can be enrolled. | | `paused` | Temporarily stopped. Active enrollments are frozen. | | `archived` | Permanently disabled. | ### Activate a sequence A sequence must have at least one step before it can be activated: ```bash curl -X POST http://localhost:3891/v1/sequences/seq_abc123.../activate \ -H "x-api-key: pgn_..." ``` ### Enroll a contact ```bash curl -X POST http://localhost:3891/v1/sequences/seq_abc123.../enroll \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"contactId": "ctc_def456..."}' ``` ```typescript const enrollment = await pigeons.sequences.enroll('seq_abc123...', 'ctc_def456...') console.log(enrollment.status) // "active" ``` The sequence must be `active`. Enrollment creates a job to process the first step. ### Enrollment statuses | Status | Meaning | |---|---| | `active` | Currently progressing through steps | | `completed` | Finished all steps | | `exited` | Removed early (e.g. unsubscribed) | | `failed` | A step failed | ### Monitor enrollments ```bash curl "http://localhost:3891/v1/sequences/seq_abc123.../enrollments?status=active" \ -H "x-api-key: pgn_..." ``` ### Update a sequence Pause the sequence first, then update: ```bash curl -X POST http://localhost:3891/v1/sequences/seq_abc123.../pause \ -H "x-api-key: pgn_..." curl -X PATCH http://localhost:3891/v1/sequences/seq_abc123... \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"steps": [...]}' ``` When steps are provided in an update, the entire step list is replaced. ### Sequence stats ```json { "stats": { "enrolled": 150, "completed": 80, "exited": 10, "active": 60 } } ``` Per-step stats track `sent`, `delivered`, `opened`, `bounced`, and `complained` for each email step. ============================================================ Webhooks (webhooks) ============================================================ --- title: Webhooks description: Subscribe to email events and inbound messages with HTTP webhooks. --- ## Overview Webhooks deliver real-time notifications when events happen in your Pigeons instance. When an event fires, Pigeons sends a POST request to each registered webhook that subscribes to that event type. ## Event Types | Event | Fires when | |---|---| | `email.sent` | An email is accepted by SES | | `email.delivered` | SES confirms delivery to the recipient | | `email.bounced` | SES reports a bounce | | `email.complained` | SES reports a spam complaint | | `email.opened` | A recipient opens an email (requires open tracking) | | `email.failed` | An email fails to send | | `inbox.message.received` | An inbound email arrives at an inbox | | `inbox.message.sent` | An outbound email is sent from an inbox | | `contact.created` | A new contact is created | | `contact.updated` | A contact is updated | | `contact.unsubscribed` | A contact unsubscribes | | `campaign.sent` | A campaign email is sent | | `campaign.completed` | A campaign finishes sending to all recipients | | `sequence.step.sent` | A sequence step email is sent | ## Create a Webhook ```bash curl -X POST http://localhost:3891/v1/webhooks \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/pigeons", "events": ["email.delivered", "email.bounced", "inbox.message.received"] }' ``` ```typescript const webhook = await pigeons.webhooks.create({ url: 'https://your-app.com/webhooks/pigeons', events: ['email.delivered', 'email.bounced', 'inbox.message.received'], }) console.log(webhook.secret) // "whsec_..." - only returned on creation ``` A signing secret is auto-generated if you don't provide one (minimum 16 characters). The secret is only returned in the create response. Store it securely. ## Payload Format Every webhook delivery is a POST request with a JSON body: ```json { "event": "email.delivered", "data": { "email_id": "msg_abc123...", "delivered_at": "2024-01-15T10:30:05.000Z" } } ``` ### Headers | Header | Description | |---|---| | `Content-Type` | `application/json` | | `X-Pigeons-Signature` | HMAC-SHA256 signature of the request body | | `X-Pigeons-Event` | Event type (e.g. `email.delivered`) | | `X-Pigeons-Delivery` | Unique delivery ID (`wdl_...`) | ### Example payloads **email.delivered:** ```json { "event": "email.delivered", "data": { "email_id": "msg_abc123...", "delivered_at": "2024-01-15T10:30:05.000Z" } } ``` **email.bounced:** ```json { "event": "email.bounced", "data": { "email_id": "msg_abc123...", "bounce_type": "Permanent", "bounced_at": "2024-01-15T10:30:05.000Z" } } ``` **inbox.message.received:** ```json { "event": "inbox.message.received", "data": { "inbox_id": "inb_abc123...", "message_id": "imsg_xyz789...", "thread_id": "thr_def456...", "from": "customer@example.com", "to": ["support@yourdomain.com"], "subject": "Help with my order", "text": "I need help with order #123...", "direction": "inbound" } } ``` **email.opened:** ```json { "event": "email.opened", "data": { "email_id": "msg_abc123...", "opened_at": "2024-01-15T14:22:00.000Z" } } ``` ## Signature Verification Every delivery is signed with your webhook's secret using HMAC-SHA256. Verify signatures to ensure requests come from your Pigeons instance. ### Node.js ```typescript import { createHmac } from 'crypto' function verifySignature(body: string, signature: string, secret: string): boolean { const expected = createHmac('sha256', secret).update(body).digest('hex') return expected === signature } // In your webhook handler: app.post('/webhooks/pigeons', (req, res) => { const signature = req.headers['x-pigeons-signature'] as string const rawBody = JSON.stringify(req.body) if (!verifySignature(rawBody, signature, 'whsec_your_secret')) { return res.status(401).send('Invalid signature') } // Process the event const { event, data } = req.body console.log(`Received ${event}:`, data) res.status(200).send('ok') }) ``` ### Python ```python import hmac import hashlib def verify_signature(body: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature) ``` ## Retry Policy Failed deliveries are retried with exponential backoff: | Attempt | Delay | |---|---| | 1 | Immediate | | 2 | 1 minute | | 3 | 5 minutes | | 4 (final) | 15 minutes | A delivery is considered failed if: - The endpoint returns a non-2xx status code - The request times out (10 second timeout) - The connection fails After 3 retry attempts, the delivery is abandoned. ### Auto-disable After 10 consecutive failures across any deliveries, the webhook is automatically disabled (status set to `disabled`). Re-enable it by updating the webhook: ```bash curl -X PATCH http://localhost:3891/v1/webhooks/whk_abc123... \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"status": "active"}' ``` ## Manage Webhooks ### List ```bash curl http://localhost:3891/v1/webhooks -H "x-api-key: pgn_..." ``` The list response does not include secrets. ### Update ```bash curl -X PATCH http://localhost:3891/v1/webhooks/whk_abc123... \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{"events": ["email.delivered", "email.bounced", "email.complained"]}' ``` ### Delete ```bash curl -X DELETE http://localhost:3891/v1/webhooks/whk_abc123... \ -H "x-api-key: pgn_..." ``` ## SSRF Protection Webhook URLs are validated before every delivery to prevent SSRF attacks. URLs pointing to private IP ranges, localhost, or link-local addresses are rejected. ============================================================ Open Tracking (open-tracking) ============================================================ --- title: Open Tracking description: Track email opens with a transparent tracking pixel. --- ## How It Works When open tracking is enabled, Pigeons injects a 1x1 transparent GIF pixel into the HTML body of outgoing emails, just before the closing `` tag: ```html ``` When the recipient's email client loads the image, Pigeons records the open. The pixel endpoint returns the image immediately and processes the open asynchronously, so tracking never delays the response. ## Enabling Open Tracking Open tracking is **off by default**. Two controls determine whether an email gets a tracking pixel: ### Global: `PIGEONS_OPEN_TRACKING` Set the environment variable to enable tracking for all emails: ```bash PIGEONS_OPEN_TRACKING=true PIGEONS_BASE_URL=https://pigeons.yourdomain.com # required for pixel URLs ``` Both variables are required. Without `PIGEONS_BASE_URL`, pixels cannot be injected because there is no URL to point them to. ### Per-email: `trackOpens` Override the global setting for individual emails: ```bash curl -X POST http://localhost:3891/v1/emails \ -H "x-api-key: pgn_..." \ -H "Content-Type: application/json" \ -d '{ "to": "alice@example.com", "subject": "Your invoice", "html": "