Webhook events
Every event type, the payload of each, signature verification, retries and replay.
MachineMail records an event when mail arrives, is sent, is delivered or bounces, and when inboxes, domains and drafts change. Each event is:
- POSTed to every matching webhook,
- pushed to live streams (
GET /v0/events/streamand the/v0/wsWebSocket), - kept in the event log (
GET /v0/events), with no retention limit.
Event types
| Event type | When |
|---|---|
message.received | Mail arrived and the trust layer found nothing to flag. |
message.received.quarantined | Mail arrived with trust level quarantined (prompt injection, phishing, or a blocked sender). Its INBOX label is replaced by QUARANTINE. |
message.received.spam | Mail arrived that the classifier scored as spam (is_spam at least 0.9). INBOX is replaced by SPAM. |
message.received.unauthenticated | Mail arrived that failed DMARC, or failed both SPF and DKIM. UNAUTHENTICATED is added; INBOX stays. |
message.received.blocked | Mail arrived from a sender on a receive block list. Its only label is BLOCKED. |
message.sent | A message was sent: send, reply, reply-all, forward or draft send. |
message.delivered | The receiving server accepted a sent message. |
message.bounced | A sent message bounced. Permanent bounces add the recipients to suppressions. |
message.complained | A recipient marked a sent message as spam. The recipient is suppressed. |
message.rejected | Mail arrived for a paused inbox and was not stored. |
domain.verified | A custom domain passed DNS verification. |
domain.updated | A DNS check changed a custom domain’s status or a record’s status. Becoming VERIFIED also fires domain.verified. |
inbox.created | An inbox was created, through the API, MCP, the dashboard, or a catch-all domain. |
inbox.expired | An ephemeral inbox reached expires_at and was deleted with its mail. |
draft.sent | A draft was sent, by request or on its send_at schedule. |
webhook.test | Sent only by POST /v0/webhooks/{webhook_id}/test, to that one webhook. |
An inbound message fires exactly one message.received* event. The checks run in this order and the first match wins: blocked sender, quarantined, spam, unauthenticated, otherwise message.received.
When you create a webhook, event_types picks the types it receives. Omitting it, or sending an empty array, subscribes it to every type. An unknown type returns 400 Unknown event type: <type>.
MachineMail does not emit AgentMail’s message.opened or calendar events.
Envelope
Every webhook body, SSE data: line and WebSocket event frame is the same JSON envelope:
| Field | Type | Meaning |
|---|---|---|
type | "event" | Always event. |
event_id | string | Unique, time-sortable (evt_...). Use it to deduplicate. |
event_type | string | One of the types above. |
timestamp | string (date-time) | When the event was recorded. |
data | object | The payload for this type (below). |
| payload keys | The keys of data are also copied to the top level, as AgentMail does (message, thread, send, bounce, …). |
The event log (GET /v0/events) returns a different shape for each event: event_id, event_type, organization_id, pod_id, inbox_id, timestamp, data and created_at.
Payloads
The payload is what appears under data (and at the top level). Message, ThreadItem, Inbox and Domain objects are the same as the API returns elsewhere.
- Message content is untrusted input. When you pass mail to a model, use
message.trust.content.textrather thantextorhtml. - Domain events have no inbox, so webhooks and streams filtered by inbox never receive them.
- Expired inboxes are reaped once a minute, so
inbox.expiredcan arrive up to about a minute afterexpires_at.
domain.updated
A DNS check changed the domain status or a records[].status (verification progress). The change to VERIFIED also sends domain.verified. Sent only to webhooks whose event_types list it (an empty list does not include it).
domain.verified
A domain became VERIFIED.
draft.sent
A draft was sent, explicitly or at its send_at. A message.sent event is emitted too.
draft_idstringRequiredinbox_idstringRequiredsendobjectRequiredsend:
message_idstringRequiredthread_idstringRequiredtimestampstring (date-time)Requiredinbox.created
An inbox was created (API, dashboard, catch-all or agent registration).
inbox.expired
An ephemeral inbox expired and was deleted with its mail.
deleted_messagesintegerRequiredmessage.bounced
A sent message bounced.
bounce is a Bounce:
inbox_idstringRequiredthread_idstringRequiredmessage_idstringRequiredtimestampstring (date-time)RequiredtypestringRequiredsub_typestringRequiredmessage.complained
A recipient marked a sent message as spam.
complaint is a Complaint:
inbox_idstringRequiredthread_idstringRequiredmessage_idstringRequiredtimestampstring (date-time)RequiredtypestringRequiredsub_typestringRequiredmessage.delivered
The receiving server accepted a sent message.
delivery is a Delivery:
inbox_idstringRequiredthread_idstringRequiredmessage_idstringRequiredtimestampstring (date-time)Requiredrecipientsstring[]Requiredmessage.received, message.received.blocked, message.received.quarantined, message.received.spam, message.received.unauthenticated
A message was received and stored. The sub-types use the same payload.
message.rejected
An incoming message was refused and not stored.
reject is a Reject:
inbox_idstringRequiredthread_idnullRequiredmessage_idnullRequiredtimestampstring (date-time)Requiredreason"inbox_paused"RequiredfromstringRequiredsubjectstringmessage.sent
A message was sent (send, reply, forward or draft).
send is a Send:
inbox_idstringRequiredthread_idstringRequiredmessage_idstringRequiredtimestampstring (date-time)Requiredrecipientsstring[]Requiredwebhook.test
Sent by POST /v0/webhooks/{webhook_id}/test to that webhook only, whatever its event_types.
webhook_idstringRequiredmessagestringRequiredDelivery
Each delivery is an HTTP POST with Content-Type: application/json, User-Agent: MachineMail-Webhooks/1.0 and these headers:
X-MachineMail-SignaturestringRequiredX-MachineMail-EventstringRequiredX-MachineMail-Event-IdstringRequiredX-MachineMail-Delivery-IdstringRequired- Respond with any
2xxwithin 10 seconds. Anything else counts as a failure: other status codes, timeouts, connection errors and redirects (redirects are not followed). - The URL must be public
httporhttps. It is checked again before every attempt, and private, loopback and link-local addresses are refused. - The first 1024 bytes of your response body and its status are stored on the delivery (
response_status,response_body). - Delivery is at least once. A retry, a replay or a crash can deliver the same event twice; deduplicate on
X-MachineMail-Event-Id. - Deliveries are not ordered. Use
timestampor the event ID, which sorts by time, to order events.
Retries
A failed attempt is retried on this schedule, measured from the previous attempt:
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 and later | 24 hours |
Retries stop when the next attempt would be more than 5 days after the delivery was created; the delivery is then failed. That is about 10 attempts. The delivery worker checks for due retries every 5 seconds.
Webhooks are never disabled automatically. PATCH /v0/webhooks/{webhook_id} with enabled: false stops new deliveries; deliveries already queued are still attempted. Events that happen while a webhook is disabled are not delivered later.
Inspecting and replaying
GET /v0/webhooks/{webhook_id}/deliveries?status=pending|delivered|failedlists deliveries withattempt,status,response_status,response_body,last_error,next_attempt_atanddelivered_at.POST /v0/webhooks/{webhook_id}/deliveries/{delivery_id}/replaysends the original event body again as a new delivery, right away, with a fresh signature timestamp. It works for any delivery status and whether or not the webhook is enabled. If it fails, the normal retry schedule applies.POST /v0/webhooks/{webhook_id}/testsends awebhook.testevent to that webhook right away and returns the delivery.
Both need the webhook_update permission.
Verifying signatures
Each webhook has a secret that starts with whsec_. It is returned when you create or fetch the webhook. There is no rotation endpoint: to rotate, create a new webhook and delete the old one.
The signature header is:
v1 is the lowercase hex HMAC-SHA256 of <t>.<raw request body>, keyed with the whole secret string (including whsec_, as UTF-8 bytes; the secret is not base64-decoded). t is the time of this attempt, so retries and replays carry new signatures.
To verify:
- Read the raw body bytes before parsing JSON.
- Split the header on
,and each part on the first=to gettandv1. - Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC and compare it with
v1in constant time.
Streams
The same envelopes are available without a public URL:
- Server-sent events:
GET /v0/events/stream, filtered byinbox_id,pod_idandevent_types. Resume after a disconnect withLast-Event-ID(up to 500 missed events are replayed). The stream starts with anevent: readyframe and sends a: pingcomment after 15 seconds without events. - WebSocket:
/v0/ws. Send{"type":"subscribe","inbox_ids":[...],"pod_ids":[...],"event_types":[...]}to narrow it, and{"type":"ping"}to get{"type":"pong"}. No resume. - Polling:
GET /v0/events?after=<event_id>returns everything after an event, oldest first.
Both streams accept the API key as ?token= for clients that cannot set an Authorization header. webhook.test events are not streamed.