The short answer
Subscribe a webhook to message.received, verify its signature, answer 2xx at once and hand the message id to your agent. With Pidgeon the event arrives within seconds of the mail, signed with HMAC-SHA256, and tried up to seven times if your server is down. If your agent has no public URL, it makes one call instead, GET /v1/messages/await, which holds open until mail lands at its address.
Two ways to be told
An agent that reads email needs something to wake it up. There are two shapes, and the right one depends on where your code runs:
- A webhook. Pidgeon sends a
POSTto your URL when something happens. Your code sleeps until then. This is the shape for a server, a serverless function or a workflow tool, anything with an HTTPS address the internet can reach. - A wait. Your code asks, and the answer comes back when the mail does. This is the shape for a script on a laptop, a notebook, a browser agent or an agent runtime with no public address.
Both read the same log of events, so you can start with the wait while you build and move to a webhook without changing what your agent does with a message.
The webhook
Register an endpoint and name the events you act on. An endpoint that names none is sent every type.
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"]
}'
The answer carries the signing secret, once. Store it.
What arrives is ids and metadata, never the body: message_id, thread_id, the address it arrived at, the sender, the subject, the SPF, DKIM and DMARC results and the Firewall verdict. Your handler fetches the text with GET /v1/messages/{id} when the agent needs it, so the mail stays out of your request logs.
Two fields decide whether your agent should act at all. from_self is true when the mail came from another of your own addresses; an agent that answers every message.received without checking it will answer its own replies. owner_type is human once a person has taken the conversation, and then the agent should leave it alone.
Other events are worth a trigger too. message.invitation fires when a meeting invitation arrives, thread.updated when a conversation changes owner or status, and message.bounced when the receiving server refuses something your agent sent. The event list has every type.
Verify, then acknowledge
An unverified webhook endpoint is an open POST that says mail arrived. Anyone who finds the URL can tell your agent what to do. Every delivery carries x-pidgeon-timestamp and x-pidgeon-signature, an HMAC-SHA256 over the timestamp, a full stop and the raw body. The TypeScript SDK checks both:
import { verifyWebhook } from '@pidgeon-ai/sdk';
export async function POST(req: Request) {
const event = await verifyWebhook({
rawBody: await req.text(),
headers: req.headers,
secret: process.env.PIDGEON_WEBHOOK_SECRET!,
});
if (event.type === 'message.received' && !event.data.from_self) {
await queue.add('answer', { messageId: event.data.message_id });
}
return new Response(null, { status: 204 });
}
Verify the raw text before you parse it, because re-serialised JSON no longer matches the signature. A timestamp more than five minutes old is refused as a replay. The Python package has the same function, pidgeon.webhooks.verify_webhook.
Then return. A model call takes seconds, and Pidgeon gives your endpoint 10 seconds before it counts the attempt as failed and tries again. Put the work on a queue and answer 204.
Retries and what they mean for your handler
A delivery that fails is retried after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours: seven attempts in all. After the last one it is kept, not dropped, and you can replay it from Developers → Events or with POST /v1/deliveries/{id}/replay once your server is fixed. After 20 failures in a row the endpoint is disabled; set it back with PATCH /v1/webhooks/{id} and {"status": "active"}, rather than deleting it, which would mint a new secret.
Delivery is at least once. If your handler finished and the connection dropped before Pidgeon saw the 2xx, the event comes again. Every event has a stable id, also sent as the X-Pidgeon-Event-Id header. Record the ids you have handled and return early on a repeat, or a retry becomes a second reply from your agent. Idempotent email for AI agents covers that end to end.
No public URL: wait for the mail
A webhook needs an HTTPS address that is not private or loopback, so localhost cannot receive one. For an agent without a public address, GET /v1/messages/await holds one request open until a message arrives at an address, for up to 280 seconds:
curl -G https://pidgeon.ai/api/v1/messages/await \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-d identity_id=$IDENTITY_ID \
-d timeout=60
The answer is { message, timed_out, cursor, held_back }. When nothing came, timed_out is true. Call again with after set to the cursor, and nothing that arrived between the two calls is missed. Narrow the wait with from, subject_contains or has_code. Mail filed as spam is not returned, only counted in held_back with its id.
From an agent it is the MCP tool await_message, messagesAwait in the TypeScript SDK and pidgeon messages await in the CLI. Many MCP clients give up on a call at 60 seconds, so keep each wait under that and chain them with the cursor.
To watch every event rather than one address, the TypeScript SDK's streamEvents reads GET /v1/events/stream and reconnects without a gap.
Test it before anything is real
A test key (pgn_test_…) runs everything a live key runs, including events and webhooks, and no mail leaves Pidgeon. POST /v1/test/inbound plays the sender: it delivers a message to one of your test key's addresses through the real inbound pipeline, so your webhook gets the same message.received a stranger's email would produce. Pass in_reply_to and it arrives as a reply in an existing thread.
That makes the whole loop testable in CI: an inbound message, your verified handler, your agent's reply, and a second inbound message answering it. To test your handler's retry path, return a 500 once and watch the delivery come back.
When this is not the right tool
Pidgeon's events cover mail that arrives at a Pidgeon address. If the mail your agent should act on lands in a person's existing inbox at another provider, Pidgeon never sees it unless it is forwarded to a Pidgeon address (Gmail forwarding sets that up). And if your code only needs to know about mail once a day, a scheduled GET /v1/events?after=, passing the created_at of the last event you handled, is simpler than either a webhook or a wait.
Start
Sign up, create an address, and register a webhook. Webhooks has the payload field by field, verifying webhooks the three ways to get the signature wrong, retries the schedule, and test mode the inbound simulator.