# Email webhooks for agents: signature verification and retries
URL: https://emailforagents.ai/docs/events-and-webhooks
Summary: Poll the durable event stream or receive signed webhooks: the event catalogue, HMAC-SHA256 signature verification, the retry schedule, and secret rotation.

1. [Home](https://emailforagents.ai/)
2. [Docs](https://emailforagents.ai/docs)
3. Events & webhooks

Docs

## Know what happened to a message

Events record receipt, send processing, provider acceptance, recipient delivery changes and approval requests. Provider acceptance is not delivery, and this API keeps the two apart — preserve that distinction in your own interface.

Last reviewed September 21, 2026 against the
implementation in this repository.

Events are the half of the
[agent email API](https://emailforagents.ai/product/developers) that tells you what happened after a
`202`. If you have not sent anything yet, start with the
[quickstart](https://emailforagents.ai/docs/quickstart); if you are looking for the error codes and limits
referenced below, they are on the [documentation overview](https://emailforagents.ai/docs).

### Two ways to read the same events

Every event is written once to a durable, per-project sequence. You can pull it or have it
pushed, and the two are not alternatives — the cursor is how you recover whatever the push path
missed.

| | Polling | Webhooks
| Credential | Any credential with `events:read` | Endpoint created by a workspace owner or administrator
| Ordering | Ascending `project_sequence`, gapless within a project | Best effort. Retries reorder freely.
| Recovery after downtime | Resume from your stored cursor |
None built in. Missed and canceled deliveries are not replayed; use the cursor.

| Envelope | `id`, `type`, `sequence`, `occurred_at`,
`data`, `environment`, `api_version` | `id`, `type`, `api_version`, `data`

Note the envelopes differ: the polled record carries `sequence`,
`occurred_at` and `environment`; the webhook body does not. If your
handler needs those, read them from the poll endpoint or from the resource itself.

### The event catalogue

Eighteen event types are declared and accepted in a subscription. Not all of them are produced
by the current build, and pretending otherwise would send you off to write a branch that never
runs, so the table says which is which.

| Type | Emitted today | When
| `message.received` | Yes | Inbound mail committed to an inbox, or a simulated inbound message in a test project.
| `message.queued` | Yes | A send was accepted and a send intent created. Nothing has left yet.
| `message.accepted` | Yes | The provider accepted the submission. Still not delivery.
| `message.delivery_updated` | Yes | A per-recipient status change reported by the provider.
| `message.failed` | Yes | The send was definitively rejected, or its retry window expired.
| `message.submission_unknown` | Yes | A submission whose outcome could not be established. Held, never silently retried.
| `approval.requested` | Yes | A send became a draft awaiting human review.
| `approval.rejected` | Yes | A human rejected the pending draft.
| `inbox.created` | Yes | A test, platform or custom-domain inbox was created.
| `domain.updated` | Yes | A custom domain's ownership, sending or receiving status changed.
| `webhook.test` | Yes | A synthetic event you asked for. Never fanned out to other endpoints.
| `message.canceled` | Not yet | The state exists on the message; no event is written for it in this build.
| `message.quarantined` | Not yet | Declared; not produced.
| `approval.approved` | Not yet | Approving sends the message, which produces `message.queued`.
| `approval.invalidated` | Not yet | Declared; not produced.
| `inbox.updated` | Not yet | Declared; not produced.
| `inbox.deleted` | Not yet | Declared; not produced.
| `project.sending_paused` | Not yet | Declared; not produced. Read the inbox policy instead.

Subscribing to a type in the second group is accepted and simply never fires. Treat an unknown
`type` as forward compatibility rather than an error: ignore it and acknowledge.

### Payload shapes

Payloads are deliberately thin — identifiers and the one fact that changed, not a copy of the
message. Re-read the resource when you need its current state, because by the time you process
an event it may have moved on.

| Type | `data` fields
| `message.received` | `message_id`, `thread_id`, `inbox_id`,
`environment`, `simulated`
| `message.queued` | `message_id`, `thread_id`, `inbox_id`
| `message.accepted`, `message.submission_unknown` | `message_id`
| `message.failed` | `message_id`, and `reason` when the failure came from the send job
(`provider_retry_window_expired`,
`reply_parent_headers_not_available`)

| `message.delivery_updated` | `message_id`, `recipient`, `status` — one of
`delivered`, `bounced`, `complained`,
`suppressed`, `delayed`, `failed`
| `approval.requested` | `approval_id`, `draft_id`
| `approval.rejected` | `approval_id`
| `inbox.created` | `inbox_id`, `address`, `environment`, plus
`domain_id` for a custom-domain inbox

| `domain.updated` | `domain_id`, `mutates_customer_dns` (always `false`),
and the statuses that changed

| `webhook.test` | `webhook_id`, `simulated: true`, `environment`,
`kind`

A `complained` or `suppressed` status, and a permanent bounce, add the
recipient to a workspace suppression list. Later sends to that address are refused with
`recipient_suppressed` — a 422, not a transient failure.

### Polling with a cursor

```
GET /v1/projects/{project_id}/events?limit=50
Authorization: Bearer YOUR_API_KEY

# Next request: reuse next_cursor from the response.
GET /v1/projects/{project_id}/events?cursor=YOUR_NEXT_CURSOR&limit=50
```

```
{
  "data": [
    {
      "id": "evt_4d2a...",
      "type": "message.accepted",
      "sequence": 1487,
      "occurred_at": "2026-09-21T09:14:02.118Z",
      "data": { "message_id": "msg_2f1c..." },
      "environment": "live",
      "api_version": "2026-09-19"
    }
  ],
  "next_cursor": "1487",
  "has_more": false
}
```

`limit` accepts 1 to 100 and defaults to 25. The cursor is the numeric
`project_sequence` of the last event you were given, so it is stable, comparable and
safe to store as text. Sequences are per project, never global.

- **Persist the cursor only after the work is durable.** Storing it first turns a
crash into silently skipped events.

- **A credential scoped to specific inboxes only sees those inboxes' events.** The
sequence numbers it receives will therefore have gaps. That is expected; do not treat a gap as
loss.

- **`cursor_expired` (HTTP 410)** means the events after your cursor are
no longer retained. It is not retryable — restart from the first remaining event with no
cursor, and reconcile from resource state.

- **`invalid_cursor` (HTTP 400)** means the value is not a plausible
sequence at all.

### Configuring an endpoint

1.
As a workspace owner or administrator, open Activity for the project and add a public HTTPS
endpoint. Creating one needs the `webhooks:manage` scope
and a current human identity: an agent API key cannot register a webhook.

2.
Save the one-time signing secret in your server's secret store. It is shown once and only the
sealed form is kept.

3. Create a synthetic test event. That creates an event, not an email.
4.
Inspect the endpoint's delivery attempts and confirm your server actually received and
authenticated the callback.

The URL is validated before it is stored, and the rules are strict on purpose:

- `https:` only; no other scheme is accepted.
- No port other than 443.
- No credentials in the URL, and no fragment.
No loopback, `.localhost`, `.local`, `.internal`, private or
link-local address. Delivery re-resolves DNS and refuses private, link-local and
cloud-metadata addresses again at dispatch time, because a registration-time check alone can
be defeated by a DNS change.

Subscriptions are validated against the event catalogue; an unknown type is rejected with
`invalid_request`. An endpoint may also be limited to specific inboxes, in which case
events from other inboxes are never fanned out to it. Test events are the exception to fan-out
entirely: `webhook.test` creates exactly one delivery, to the endpoint you asked
about, and is never broadcast.

**Creating an endpoint and queueing a test event do not prove HTTP delivery.**
A held or failed attempt means your endpoint has not acknowledged anything. The test response
even tells you whether an egress dispatcher was available. Read the recorded attempt outcome
rather than assuming success. Test events are rate-limited to one per endpoint per 60 seconds.

### Verifying a signature

Three headers arrive with every callback:

| Header | Value
| `webhook-id` | The event ID (`evt_…`). This is also your deduplication key.
| `webhook-timestamp` | Unix seconds at dispatch.
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 of
`id + "." + timestamp + "." + rawBody`, keyed with your signing secret.

```
POST /your-endpoint HTTP/1.1
content-type: application/json
webhook-id: evt_4d2a...
webhook-timestamp: 1789981242
webhook-signature: v1,K7f0k...base64...

{
  "id": "evt_4d2a...",
  "type": "message.accepted",
  "api_version": "2026-09-19",
  "data": { "message_id": "msg_2f1c..." }
}
```

Verify the exact raw bytes of the body before parsing them. Re-serialising the JSON
first will change whitespace or key order and the signature will not match. Compare in constant
time.

```
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(secret, id, timestamp, rawBody, signature) {
  const seconds = Number(timestamp);
  if (!Number.isFinite(seconds) ||
      Math.abs(Date.now() / 1000 - seconds) > 300) return false;
  const digest = createHmac('sha256', secret)
    .update(id + '.' + timestamp + '.' + rawBody).digest('base64');
  const expected = Buffer.from('v1,' + digest);
  const received = Buffer.from(signature);
  return expected.length === received.length &&
    timingSafeEqual(expected, received);
}
```

Reject anything whose signature fails or whose timestamp is more than 300 seconds from your
clock — that window is what stops an old, valid callback being replayed at you later. Then
deduplicate on `webhook-id`, enqueue the work durably, and return a
`2xx` promptly. Keep the deduplication even when your endpoint is fast: a response
can be lost after you have already done the work, and the delivery will be retried.

### The retry schedule

A delivery gets up to **eight attempts**. The delay after attempt n is
fixed, not jittered:

| After attempt | Next attempt in
| 1 | 10 seconds
| 2 | 1 minute
| 3 | 5 minutes
| 4 | 30 minutes
| 5 | 2 hours
| 6 | 6 hours
| 7 and beyond | 12 hours

An attempt is terminal — no further retry — when any of these is true:

- Your endpoint accepted it.
- It was the eighth attempt.
Your endpoint returned a 4xx that is not `408`, `409`,
`425` or `429`. Those four are treated as retryable; every other 4xx is
read as "this will never work" and the delivery is exhausted.

Each attempt is recorded with its number, HTTP status, outcome and a safe response excerpt
capped at 200 characters, and the delivery ends in `succeeded`,
`exhausted` or `canceled`. If outbound webhook egress is not configured,
attempts are recorded as held rather than being lost, so you can tell "not delivered" from "not
attempted".

Webhook retries and email retries are separate machinery with separate rules. Redelivering an
event must never cause the send that produced it to happen again — that is what
[send idempotency](https://emailforagents.ai/blog/retrying-agent-email-safely) is for, and event-ID
deduplication is for this.

### Disabling and rotating

Both operations take the endpoint's current `expected_revision`. A mismatch returns
`version_conflict` (HTTP 412), so a stale browser tab cannot silently undo a newer
change. Refresh and look at the current state before retrying.

- **Disable** stops new and queued callbacks for that endpoint. Re-enabling permits
future events; it does not replay the ones missed in between. Recover those from the events
cursor.

- **Rotate signing secret** replaces the key immediately and shows the new secret
once. Save it in your receiver first, then send a test event and confirm the signature
verifies.

Both bump the endpoint revision, and every queued delivery from the previous revision is
canceled rather than sent with a stale secret. A request already in flight can still arrive
signed with the old secret, so keep the previous value accepted for the few seconds it takes
to drain.

### Four ways this goes wrong

1. **Treating `message.accepted` as delivered.** It means the provider
took the submission. Delivery information arrives later, per recipient, as
`message.delivery_updated` — and for some recipients it never arrives at all.

2. **Parsing before verifying.** Any framework that gives you a parsed body has
already thrown away the bytes the signature covers. Capture the raw body first.

3. **Assuming order.** Processing order can differ from event order, especially
after a retry. If an operation depends on current state, fetch the resource.

4. **Relying on webhooks alone.** There is no customer-triggered replay in this
build. The durable cursor is the recovery path, so keep a poller — even a slow one — behind
your endpoint.

Related: [what happens before a send produces any event at all](https://emailforagents.ai/docs/sending-controls), [cursors on the message and thread lists](https://emailforagents.ai/docs/search), and the
[quickstart send step](https://emailforagents.ai/docs/quickstart#send) that produces the first
`message.queued` you will see.

Longer form: [the send, receive and reply loop over REST](https://emailforagents.ai/blog/agent-email-api-loop).

[Previous Sending controls](https://emailforagents.ai/docs/sending-controls) [Next Custom domains](https://emailforagents.ai/docs/domains)
