API Reference

Overview

Base path: /v1

All authenticated endpoints require the x-api-key header. API keys start with pgn_.

curl http://localhost:3891/v1/emails \
  -H "x-api-key: pgn_your_key_here"

Pagination

List endpoints use cursor-based pagination:

{
  "data": [...],
  "has_more": true,
  "next_cursor": "2024-01-15T10:30:00.000Z"
}

Pass ?cursor=<next_cursor>&limit=25 to get the next page. Maximum limit is 100, default is 25.

Idempotency

Mutating endpoints accept an Idempotency-Key header. If the same key is sent again within 24 hours, the original response is returned without re-executing the request.

curl -X POST http://localhost:3891/v1/emails \
  -H "x-api-key: pgn_..." \
  -H "Idempotency-Key: my-unique-key-123" \
  -H "Content-Type: application/json" \
  -d '{"to": "user@example.com", "subject": "Hello", "text": "Hi"}'

Error format

All errors return a consistent JSON shape:

{
  "error": {
    "type": "validation_error",
    "message": "Subject is required",
    "code": "validation_error"
  }
}
StatusTypeMeaning
400validation_errorInvalid request body or parameters
401authentication_errorMissing or invalid API key
404not_foundResource does not exist
409conflictResource already exists (e.g. duplicate email)
429rate_limit_errorToo many requests
500internal_errorUnexpected server error

Emails

Send an email

POST /v1/emails

Send directly with HTML/text, or render a template by slug or ID.

Request body:

FieldTypeRequiredDescription
tostring | string[]YesRecipient address(es)
fromstringNoSender address (defaults to PIGEONS_FROM_EMAIL)
subjectstringConditionalRequired unless using a template
htmlstringConditionalHTML body (required if no text or templateId)
textstringConditionalPlain text body
templateIdstringNoTemplate ID (tpl_...) or slug
dataobjectNoTemplate variables
ccstring[]NoCC recipients
bccstring[]NoBCC recipients
tagsstring[]NoTags for filtering
headersobjectNoCustom email headers
scheduledAtstringNoISO 8601 timestamp for scheduled send
trackOpensbooleanNoOverride global open tracking for this email

Response (201):

{
  "id": "msg_abc123...",
  "status": "queued",
  "from": "noreply@yourdomain.com",
  "to": ["recipient@example.com"],
  "subject": "Hello"
}

Get an email

GET /v1/emails/:id

Returns the full email object including delivery status, open tracking data, and timestamps.

Response (200):

{
  "id": "msg_abc123...",
  "from": "noreply@yourdomain.com",
  "to": ["recipient@example.com"],
  "subject": "Hello",
  "html": "<h1>Hello</h1>",
  "status": "delivered",
  "sesMessageId": "...",
  "trackOpens": false,
  "openCount": 0,
  "firstOpenedAt": null,
  "lastOpenedAt": null,
  "tags": null,
  "scheduledAt": null,
  "createdAt": "2024-01-15T10:30:00.000Z",
  "updatedAt": "2024-01-15T10:30:05.000Z"
}

List emails

GET /v1/emails

Query parameters:

ParamTypeDescription
limitnumberMax results (default 25, max 100)
cursorstringPagination cursor
statusstringFilter by status: queued, sending, sent, delivered, bounced, complained, failed

Cancel a queued email

POST /v1/emails/:id/cancel

Only emails with status queued can be cancelled. Sets status to failed.

Batch send

POST /v1/emails/batch

Send up to 100 emails in a single request. Request body is an array of send email objects.

Response (201): Array of email objects, one per item.


Domains

Add a domain

POST /v1/domains

Request body:

FieldTypeRequiredDescription
domainstringYesDomain name (e.g. example.com)
capabilitiesobjectNo{ sending: true, receiving: false }

Response (201): Domain object with DKIM tokens and required DNS records.

List domains

GET /v1/domains

Response (200):

{
  "data": [
    {
      "id": "dom_abc123...",
      "domain": "example.com",
      "verificationStatus": "verified",
      "dkimTokens": ["token1", "token2", "token3"],
      "spfVerified": true,
      "dmarcVerified": true,
      "region": "us-east-1",
      "capabilities": { "sending": true, "receiving": false },
      "records": [...]
    }
  ]
}

Get domain

GET /v1/domains/:id

Verify a domain

GET /v1/domains/:id/verify

Re-checks DNS records and updates verification status.

Get domain stats

GET /v1/domains/:id/stats

Returns delivery statistics and warmup guidance for the domain.

Delete a domain

DELETE /v1/domains/:id

Contacts

Create a contact

POST /v1/contacts

Request body:

FieldTypeRequiredDescription
emailstringYesEmail address (must be unique)
firstNamestringNoFirst name
lastNamestringNoLast name
tagsstring[]NoTags for segmentation
propertiesobjectNoCustom key-value data

Response (201): Full contact object.

Get a contact

GET /v1/contacts/:idOrEmail

Accepts either a contact ID (ctc_...) or email address.

List contacts

GET /v1/contacts

Query parameters:

ParamTypeDescription
limitnumberMax results (default 25, max 100)
cursorstringPagination cursor
tagstringFilter by tag

Update a contact

PATCH /v1/contacts/:idOrEmail

Request body:

FieldTypeDescription
firstNamestringFirst name
lastNamestringLast name
tagsstring[]Replace tags
propertiesobjectReplace custom properties

Delete a contact

DELETE /v1/contacts/:idOrEmail

Batch create contacts

POST /v1/contacts/batch

Create up to 1000 contacts. Existing emails are silently skipped.

Request body: Array of contact creation objects.

Response (201):

{
  "created": 8,
  "skipped": 2,
  "contacts": [{ "id": "ctc_...", "email": "..." }, ...]
}

Unsubscribe a contact

POST /v1/contacts/:idOrEmail/unsubscribe

Global unsubscribe, or pass { "topicId": "top_..." } for per-topic unsubscribe.

Resubscribe a contact

POST /v1/contacts/:idOrEmail/resubscribe

Same body format as unsubscribe.


Templates

Create a template

POST /v1/templates

Request body:

FieldTypeRequiredDescription
slugstringYesURL-safe identifier (lowercase alphanumeric with hyphens)
namestringYesDisplay name
subjectstringYesSubject line (supports {{variables}})
bodystringYesHTML body (supports {{variables}})
sampleDataobjectNoSample data for previews

Get a template

GET /v1/templates/:idOrSlug

Accepts template ID (tpl_...) or slug.

List templates

GET /v1/templates

Update a template

PATCH /v1/templates/:idOrSlug

Updating body or subject increments the template version.

Delete a template

DELETE /v1/templates/:idOrSlug

Preview a template

POST /v1/templates/:idOrSlug/preview

Request body:

{ "data": { "name": "Alice", "company": "Acme" } }

Response (200):

{
  "subject": "Welcome, Alice!",
  "html": "<h1>Welcome to Acme</h1>...",
  "text": "Welcome to Acme..."
}

Campaigns

Create a campaign

POST /v1/campaigns

Creates a campaign in draft status.

Request body:

FieldTypeRequiredDescription
namestringYesCampaign name
subjectstringNoEmail subject (or use template)
topicIdstringNoSubscription topic ID for unsubscribe filtering
templateIdstringNoTemplate ID or slug
templateDataobjectNoTemplate variables
fromNamestringNoSender display name
fromEmailstringNoSender email address
audienceFilterobjectNoFilter to select recipients (see Audience Filters)
scheduledAtstringNoISO 8601 timestamp for scheduled send

List campaigns

GET /v1/campaigns

Query parameters: limit, cursor, status (draft, scheduled, sending, paused, sent, cancelled).

Get a campaign

GET /v1/campaigns/:id

Returns the campaign with its stats object:

{
  "stats": {
    "total": 500,
    "sent": 450,
    "delivered": 440,
    "opened": 120,
    "bounced": 5,
    "complained": 1,
    "failed": 4
  }
}

Update a campaign

PATCH /v1/campaigns/:id

Only draft campaigns can be updated.

Send a campaign

POST /v1/campaigns/:id/send

Transitions from draft or scheduled to sending. Filters contacts by audience filter, excluding unsubscribed, bounced, and complained contacts.

Optional body: { "scheduledAt": "2024-06-01T09:00:00Z" } to schedule instead of sending immediately.

Pause a campaign

POST /v1/campaigns/:id/pause

Only sending campaigns can be paused.

Resume a campaign

POST /v1/campaigns/:id/resume

Only paused campaigns can be resumed.

Cancel a campaign

POST /v1/campaigns/:id/cancel

Only sending or scheduled campaigns can be cancelled.

Audience Filters

The audienceFilter object supports:

{
  "tags": {
    "contains": "newsletter",
    "containsAny": ["vip", "early-adopter"],
    "containsAll": ["active", "verified"]
  },
  "properties": {
    "plan": "pro",
    "region": "us"
  }
}

Sequences

Create a sequence

POST /v1/sequences

Request body:

FieldTypeRequiredDescription
namestringYesSequence name
triggerobjectNo{ event: "contact.created", filter: {...} }
stepsarrayNoArray of step objects

Step types:

  • email - send an email using templateId and optional subject, templateData, topicId
  • delay - wait for a duration (e.g. "1d", "12h", "30m") via delayDuration
  • condition - evaluate a condition before continuing

List sequences

GET /v1/sequences

Query parameters: limit, cursor, status (draft, active, paused, archived).

Get a sequence

GET /v1/sequences/:id

Returns the sequence with all steps and stats.

Update a sequence

PATCH /v1/sequences/:id

Active sequences must be paused before updating. Steps are replaced wholesale when provided.

Activate a sequence

POST /v1/sequences/:id/activate

Sequence must have at least one step.

Pause a sequence

POST /v1/sequences/:id/pause

Enroll a contact

POST /v1/sequences/:id/enroll

Request body:

{ "contactId": "ctc_abc123..." }

Sequence must be active. Returns the enrollment object.

List enrollments

GET /v1/sequences/:id/enrollments

Query parameters: limit, cursor, status (active, completed, exited, failed).


Inboxes

Create an inbox

POST /v1/inboxes

Request body:

FieldTypeRequiredDescription
namestringYesInbox name
emailAddressstringYesEmail address for this inbox
descriptionstringNoDescription
webhookUrlstringNoURL to receive inbound message webhooks
metadataobjectNoArbitrary metadata
labelsstring[]NoLabels for categorization

List inboxes

GET /v1/inboxes

Query parameters: limit, cursor, status (active, paused, archived), label.

Get an inbox

GET /v1/inboxes/:id

Update an inbox

PATCH /v1/inboxes/:id

Delete an inbox

DELETE /v1/inboxes/:id

Pause an inbox

POST /v1/inboxes/:id/pause

Resume an inbox

POST /v1/inboxes/:id/resume

Set catch-all

POST /v1/inboxes/:id/catch-all

Designates this inbox as the catch-all for unmatched inbound emails. Only one inbox can be catch-all at a time.

List messages

GET /v1/inboxes/:id/messages

Query parameters: limit, cursor, direction (inbound, outbound), thread_id, label.

Get a message

GET /v1/inboxes/:id/messages/:messageId

Send a message

POST /v1/inboxes/:id/messages

Request body:

FieldTypeRequiredDescription
tostring | string[]YesRecipient address(es)
ccstring[]NoCC recipients
subjectstringNoSubject line
htmlstringConditionalHTML body
textstringConditionalPlain text body
inReplyTostringNoMessage ID to reply to (maintains threading)
labelsstring[]NoLabels

List threads

GET /v1/inboxes/:id/threads

Query parameters: limit, cursor, status (open, closed, snoozed).

Get a thread

GET /v1/inboxes/:id/threads/:threadId

Update a thread

PATCH /v1/inboxes/:id/threads/:threadId

Request body:

FieldTypeDescription
statusstringopen, closed, or snoozed
metadataobjectArbitrary metadata

Webhooks

Create a webhook

POST /v1/webhooks

Request body:

FieldTypeRequiredDescription
urlstringYesEndpoint URL
eventsstring[]YesEvent types to subscribe to
secretstringNoSigning secret (min 16 chars). Auto-generated if omitted

The response includes the secret field. It is not returned on subsequent GET requests.

List webhooks

GET /v1/webhooks

Get a webhook

GET /v1/webhooks/:id

Update a webhook

PATCH /v1/webhooks/:id

Request body:

FieldTypeDescription
urlstringNew endpoint URL
eventsstring[]New event list
statusstringactive or disabled

Delete a webhook

DELETE /v1/webhooks/:id

API Keys

Create an API key

POST /v1/api-keys

Request body:

FieldTypeRequiredDescription
namestringYesKey name for identification
scopesstring[]NoRestrict to specific scopes (omit for full access)
expiresAtstringNoISO 8601 expiration timestamp

Available scopes: emails, domains, api_keys, templates, contacts, inboxes, webhooks, campaigns, sequences, topics, stats, or * for all.

Response (201): Includes the full API key in the key field. This is the only time the key is returned.

List API keys

GET /v1/api-keys

Returns all keys with prefix (first characters) but not the full key.

Delete an API key

DELETE /v1/api-keys/:id

Unauthenticated Endpoints

These endpoints sit outside auth middleware:

MethodPathDescription
GET/healthHealth check - returns { "status": "ok" }
POST/v1/internal/ses/eventsSES delivery/bounce/complaint events via SNS
POST/v1/internal/ses/inboundSES inbound email notifications via SNS
GET/v1/t/:emailIdOpen tracking pixel (1x1 transparent GIF)