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.