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.
| 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
-
As a workspace owner or administrator, open Activity for the project and add a public HTTPS
endpoint. Creating one needs the
webhooks:managescope and a current human identity: an agent API key cannot register a webhook. - Save the one-time signing secret in your server's secret store. It is shown once and only the sealed form is kept.
- Create a synthetic test event. That creates an event, not an email.
- 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,425or429. 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
- Treating
message.acceptedas delivered. It means the provider took the submission. Delivery information arrives later, per recipient, asmessage.delivery_updated— and for some recipients it never arrives at all. - 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.
- Assuming order. Processing order can differ from event order, especially after a retry. If an operation depends on current state, fetch the resource.
- 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.