Docs

Email, as an API you already know

An address is a resource. Mail arriving is an HTTP request. Everything below works with a free account.

Authentication

Every request carries an API key as a bearer token. Keys are account-wide, server-side only, and never valid in a browser — a key in client JavaScript is a key anyone who opens devtools now owns.

curl https://pidgeon.ai/api/v1/identities \
  -H "Authorization: Bearer $PIDGEON_API_KEY"

Create an address

An address your software owns. `application` and `workflow` addresses have no inbox — mail arrives, an event fires, and there is nothing to read.

curl https://pidgeon.ai/api/v1/identities \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "domain_id": "your-domain-id",
    "local_part": "receipts",
    "type": "application"
  }'

Receive mail

Register an endpoint and we POST every event you subscribe to. The response to this call carries the signing secret, once.

curl https://pidgeon.ai/api/v1/webhooks \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/pidgeon",
    "event_types": ["message.received"]
  }'

The payload we send

Ids and metadata, never a body. Two reasons: an event is delivered to a server we do not control, and it is kept in a log nothing deletes from. Exchange the id for the content when you need it.

POST https://api.example.com/pidgeon
X-Pidgeon-Signature: v1=9f86d081884c7d659a2feaa0c55ad015…
X-Pidgeon-Timestamp: 1767225600
X-Pidgeon-Event-Id: 7f9c2e0a-…
X-Pidgeon-Event-Type: message.received
X-Pidgeon-Delivery-Id: 3c1d5b8e-…

{
  "id": "7f9c2e0a-…",
  "type": "message.received",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": {
    "message_id": "1b2c3d4e-…",
    "thread_id": "5f6a7b8c-…",
    "identity": "hello@yourdomain.com",
    "from": "customer@example.com",
    "to": ["hello@yourdomain.com"],
    "subject": "Invoice 4021",
    "delivered_to": "hello@yourdomain.com",
    "matched_by": "identity",
    "spam": false,
    "attachments": [
      { "id": "…", "filename": "invoice.pdf", "content_type": "application/pdf", "size": 184320 }
    ]
  }
}

Verify the signature

Do this before you act on anything. Without it, your endpoint is an unauthenticated POST that says mail arrived — and acting on that is how you get software any stranger can drive. We sign the timestamp along with the body, so a captured request cannot be replayed forever.

# Verification happens in your code, not on the command line.
# Switch to TypeScript or Python for a working implementation.

Read a message

The other half of the contract. The event gave you an id; this gives you the content, authorised and scoped to your account.

curl https://pidgeon.ai/api/v1/messages/MESSAGE_ID \
  -H "Authorization: Bearer $PIDGEON_API_KEY"

Send a reply

`from` has to be an address you own, and `in_reply_to` keeps the conversation together. Sends go through the same abuse checks and the same daily cap as the web composer.

curl https://pidgeon.ai/api/v1/messages \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Re: Invoice 4021",
    "text": "Received, thank you.",
    "in_reply_to": "MESSAGE_ID"
  }'

Hand a conversation to a person

When your software is out of its depth, set the conversation’s owner to a human. It appears in the inbox, and `thread.updated` tells whatever was driving that it no longer is.

curl -X PATCH https://pidgeon.ai/api/v1/threads/THREAD_ID \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "owner_type": "human", "status": "human_review" }'

Download an attachment

A short-lived signed URL, not the bytes. Fetch it when you need it rather than storing it — it expires, which is the point.

curl https://pidgeon.ai/api/v1/attachments/ATTACHMENT_ID \
  -H "Authorization: Bearer $PIDGEON_API_KEY"

Errors and limits

A failure is always `{ error: { code, message } }`, with an HTTP status that means the same thing. Every response carries `X-RateLimit-Remaining` and `X-RateLimit-Reset`, so a client can pace itself rather than discover the limit by hitting it.

400  validation      the request body or query is wrong
401  unauthenticated missing or invalid API key
402  entitlement     your plan does not include that
403  forbidden       the account is on hold, or the send was blocked
404  not_found       no such resource, or it is not yours
409  conflict        that address already exists
429  rate_limited    slow down; see X-RateLimit-Reset
500  internal        our fault — retry

Signed in, this same page fills every example with your own address and domain id, so the first thing you paste runs. Open it with your account.

You own the domain. Take the inbox.