Search the docs
Find a page, a section, or an endpoint.
Overview
Mail arriving as a signed POST, instead of a poll.
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://yourapp.com/webhooks/pidgeon",
"event_types": ["message.received", "message.delivered"]
}'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.
{
"id": "0044ed22-cbcb-4455-a945-06f9699dfca0",
"type": "message.received",
"created_at": "2026-09-14T09:41:02.118Z",
"data": {
"message_id": "1a70b25d-bc53-4a94-a51a-10fd88ebd19a",
"thread_id": "463ec73a-699f-4ea8-a8fa-bb736ae1601e",
"identity": "hello@yourdomain.com",
"from": "billing@supplier.com",
"to": ["hello@yourdomain.com"],
"subject": "Invoice 4417",
"delivered_to": "hello@yourdomain.com",
"matched_by": "address",
"spam": false,
"from_self": false,
"firewall": { "verdict": "clean", "signals": [] },
"attachments": [
{
"id": "0b1d4e8c-6a4f-4a1e-9b3e-2d0f1c7a5b94",
"filename": "invoice-4417.pdf",
"content_type": "application/pdf",
"size": 184203
}
]
}
}identity is the address it arrived at and delivered_to is the address it
was sent to — the same string unless a routing rule moved it, in which case
matched_by says which rule. to is every recipient on the message, you
included. Ids are bare UUIDs everywhere in this API; nothing is prefixed.
from_self is the one to check before you reply. A workspace with
several addresses sees its own outbound mail arrive at the others, and it is
genuinely inbound there — direction will not tell you apart from a
stranger. A handler written the obvious way, answer every
message.received, answers its own reply, which arrives at the first
address, which it answers. Skip anything where from_self is true unless you
mean it:
if (event.type === 'message.received' && !event.data.from_self) {
await answer(event.data.message_id);
}firewall is the other. Pidgeon Firewall reads every inbound message
for attempts to steer the agent reading it — hidden text, instructions
addressed to an AI, a sender pretending to be someone you trust — and says
suspicious with the kinds of signal that fired. The full signals, with what
each one found, are on the message. Pidgeon Firewall
explains each.
The same applies to the event stream, which spans
every mailbox the key reaches unless you name one with identity_id.
Mail arriving at hello@yourdomain.com produces one of these within seconds. Fetch the
message when you want the text — GET /v1/messages/{id} — which keeps the
body out of your logs unless you put it there.
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/af08b0a1-284f-464a-a36e-8e15bb7c2eb8 -H "Authorization: Bearer $PIDGEON_API_KEY"No endpoint to host
If your code cannot receive a request — a script on a laptop, a notebook, an agent runtime with no public address — hold the log open instead. The TS SDK ships it, because a generated client has nowhere to put a connection that stays open and the alternative was everybody writing the same forty lines:
import { streamEvents } from '@pidgeon-ai/sdk';
for await (const event of streamEvents({ apiKey: process.env.PIDGEON_API_KEY! })) {
if (event.type === 'message.received' && !event.data.from_self) {
await answer(event.data.message_id as string);
}
}Reconnection is handled and lossless. The connection closes itself after
about 280 seconds so the last thing you receive is a cursor rather than a
socket error; the helper resends it as Last-Event-ID, which the server
prefers over after, so there is no gap and no repeat. A revoked key stops
the loop rather than retrying for ever. Pass after: '1970-01-01T00:00:00Z' to start from the beginning of the log, signal to
stop, and identityId or type to narrow it.
Over plain HTTP it is GET /v1/events/stream with
Accept: text/event-stream; each frame carries the event as data, its type
as event: and the cursor — not the event's id — as id:.
Or a WebSocket
The same read over a socket, for a server that already speaks WebSocket. Some runtimes cannot set headers on a socket, so the key goes in the first message — never in the URL. Like every key it stays on your server: a browser tab holding one hands it to anybody with devtools (keys). To show events in a browser, relay them from your server.
const ws = new WebSocket('wss://pidgeon.ai/api/v1/events/socket?type=message.received');
ws.onopen = () =>
ws.send(JSON.stringify({ type: 'auth', api_key: process.env.PIDGEON_API_KEY }));
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.type === 'event') handle(message.event);
if (message.type === 'reconnect') reconnectAfter(message.cursor);
};You get ready, then one event per event, oldest first. Shortly before
280 seconds the server sends reconnect with a cursor and closes with
4000; open a new socket with after set to that cursor and nothing is
missed. A close with 4401 means the key was missing or wrong.
Before you act on any of it
Verify the signature. An unverified webhook endpoint is an unauthenticated
POST that says mail arrived, and acting on that is how you get software any
stranger can drive. That is the next page, and it
is the one page here worth reading twice.
When an endpoint goes down
We retry with a backoff, and after enough consecutive failures we disable the endpoint rather than keep calling into the dark. Turn it back on once you have fixed it — do not delete and recreate, which mints a new signing secret and breaks every receiver verifying against the old one.
PATCH /v1/webhooks/{id} {"status": "active"}
Re-enabling forgives the failure count. An endpoint that was fixed would otherwise be disabled again by its own history the first time it was slow.
To see what happened, and to send something again:
curl "https://pidgeon.ai/api/v1/deliveries?webhook_id=$WEBHOOK_ID" \
-H "Authorization: Bearer $PIDGEON_API_KEY"
curl -X POST https://pidgeon.ai/api/v1/deliveries/$DELIVERY_ID/replay \
-H "Authorization: Bearer $PIDGEON_API_KEY"A replay is not idempotent, and that is the point of it: your endpoint will be
called again. status is pending when it is queued, sending in flight,
delivered when your server answered, failed while we are still retrying,
and dead once we have stopped.