# How to trigger an AI agent when an email arrives

> How does an AI agent start work the moment an email lands, with or without a public URL?

## 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 `POST` to 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.

```sh
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](https://pidgeon.ai/docs/firewall.md) 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](https://pidgeon.ai/docs/webhooks/events.md) 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:

```ts
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](https://pidgeon.ai/blog/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:

```sh
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](https://pidgeon.ai/docs/gmail-forwarding.md) 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](https://pidgeon.ai/signup), create an address, and register a webhook. [Webhooks](https://pidgeon.ai/docs/webhooks.md) has the payload field by field, [verifying webhooks](https://pidgeon.ai/docs/webhooks/verifying.md) the three ways to get the signature wrong, [retries](https://pidgeon.ai/docs/webhooks/retries.md) the schedule, and [test mode](https://pidgeon.ai/docs/test-mode.md) the inbound simulator.

## Questions

### Which webhook event fires when an email arrives?

message.received. It carries the message and thread ids, the sender, the subject, the SPF, DKIM and DMARC results and the Firewall verdict, never the body; fetch the text with GET /v1/messages/{id}.

### How do I verify a Pidgeon webhook?

Check x-pidgeon-signature, an HMAC-SHA256 over x-pidgeon-timestamp, a full stop and the raw body, keyed with the endpoint's secret. verifyWebhook in the TypeScript SDK and verify_webhook in Python do it, and refuse anything older than five minutes.

### What if my agent has no public URL?

Call GET /v1/messages/await. It holds the request open until mail arrives at the address, for up to 280 seconds, and returns a cursor so the next call misses nothing.

### How do I test it before production?

Use a test key and POST /v1/test/inbound. It delivers a message through the real inbound pipeline, so your webhook receives message.received, and no mail leaves Pidgeon.
