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.

Events are the half of the agent email API that tells you what happened after a 202. If you have not sent anything yet, start with the quickstart; if you are looking for the error codes and limits referenced below, they are on the documentation overview.

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.

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

TypeEmitted todayWhen
message.receivedYesInbound mail committed to an inbox, or a simulated inbound message in a test project.
message.queuedYesA send was accepted and a send intent created. Nothing has left yet.
message.acceptedYesThe provider accepted the submission. Still not delivery.
message.delivery_updatedYesA per-recipient status change reported by the provider.
message.failedYesThe send was definitively rejected, or its retry window expired.
message.submission_unknownYesA submission whose outcome could not be established. Held, never silently retried.
approval.requestedYesA send became a draft awaiting human review.
approval.rejectedYesA human rejected the pending draft.
inbox.createdYesA test, platform or custom-domain inbox was created.
domain.updatedYesA custom domain's ownership, sending or receiving status changed.
webhook.testYesA synthetic event you asked for. Never fanned out to other endpoints.
message.canceledNot yetThe state exists on the message; no event is written for it in this build.
message.quarantinedNot yetDeclared; not produced.
approval.approvedNot yetApproving sends the message, which produces message.queued.
approval.invalidatedNot yetDeclared; not produced.
inbox.updatedNot yetDeclared; not produced.
inbox.deletedNot yetDeclared; not produced.
project.sending_pausedNot yetDeclared; 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.

Typedata 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.rejectedapproval_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:

HeaderValue
webhook-id The event ID (evt_…). This is also your deduplication key.
webhook-timestampUnix 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 attemptNext attempt in
110 seconds
21 minute
35 minutes
430 minutes
52 hours
66 hours
7 and beyond12 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 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.