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.

json
{
"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:

ts
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:

ts
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.

ts
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.