Events
Everything that happens in MachineMail is recorded as an event. Read the event log, stream events over SSE or WebSocket, or receive them as webhooks.
MachineMail records an event for everything that happens to your mail: a message arrives, a message is sent or bounces, an inbox is created or expires, a domain verifies. Events are stored in a replayable log and delivered three ways:
| Channel | Use it for |
|---|---|
| Webhooks | Servers that should be called when something happens. Retried, signed, logged. |
SSE: GET /v0/events/stream | A long-lived process or agent that wants a push stream over plain HTTP. |
WebSocket: GET /v0/ws | The same stream over a WebSocket, compatible with AgentMail’s protocol. |
Event log: GET /v0/events | Catching up after downtime, backfill and audit. |
All four carry the same events in the same shape.
Event types
| Event type | When | data |
|---|---|---|
message.received | An inbound message was accepted into INBOX. | message, thread |
message.received.quarantined | The trust layer quarantined an inbound message. | message, thread |
message.received.spam | An inbound message was classified as spam. | message, thread |
message.received.unauthenticated | An inbound message failed DMARC, or both SPF and DKIM. | message, thread |
message.received.blocked | The sender matched a receive block list. | message, thread |
message.sent | A message was sent from an inbox. | send, message, thread |
message.delivered | The receiving server accepted it. | delivery |
message.bounced | It bounced. | bounce |
message.complained | The recipient marked it as spam. | complaint |
message.rejected | Inbound mail was refused, for example because the inbox is paused. | reject |
domain.verified | A custom domain passed verification. | domain |
inbox.created | An inbox was created, including by catch-all. | inbox |
inbox.expired | An ephemeral inbox expired and was deleted. | inbox, deleted_messages |
draft.sent | A draft was sent, by you or on schedule. | draft_id, inbox_id, send |
webhook.test | You called the webhook test endpoint. Webhook only. | webhook_id, message |
Each inbound message produces exactly one of the message.received* types. If you only want mail an agent should read, subscribe to message.received alone.
Full payload examples are in the webhook events reference.
The envelope
Webhooks and both streams deliver the same JSON envelope. The event’s data sits under data, and is also spread at the top level for AgentMail compatibility:
message in a received event is the full message, including bodies and the trust report, so most handlers need no extra API call.
The event log
| Parameter | Notes |
|---|---|
after | An event_id or an ISO 8601 time. Returns newer events, oldest first. Use the last event_id you processed to resume. |
before | ISO 8601 time. |
event_types | Comma-separated or repeated. |
inbox_id, pod_id | Narrow to one inbox or pod. |
limit, page_token, ascending | Pagination. Without after, newest first. |
GET /v0/events/{event_id} returns one event. The log is also available under /v0/pods/{pod_id}/events and /v0/inboxes/{inbox_id}/events. Reading it needs the metrics_read permission, and lists only the events the key may read (the same per-event rule as the streams below; audit events need metrics_read). An event the key may not read is a 404 on GET /v0/events/{event_id}.
Event IDs sort in creation order, so after=evt_... is a reliable cursor.
Note
The log also holds agent registration audit events: registration.created, token.issued, agent.claimed and agent.revoked. They are not sent to webhooks or streams, and cannot be selected in a
webhook’s event_types.
Server-Sent Events
GET /v0/events/stream holds the connection open and writes each event as it happens.
- Filter with
inbox_id,pod_idandevent_types, each comma-separated or repeated. readyis sent once the subscription is live. Each event’s SSEidis itsevent_idand its SSEeventname is the event type.- A
: pingcomment is sent after 15 seconds without events, to keep proxies from closing the connection. - On reconnect, send
Last-Event-IDwith the lastevent_idyou saw. MachineMail replays up to 500 missed events first. The replay honoursevent_typesand your key’s scope but not theinbox_idorpod_idquery filters, so filter replayed events on your side. For longer gaps, page throughGET /v0/events?after=.... - Browsers’
EventSourcecannot set headers. Pass the key as?token=<key>instead (accepted on the two stream endpoints only).
The stream needs the metrics_read permission.
Which events a key receives
Both streams check every event against the key’s permissions, on top of its scope. An event is sent only when the key holds the read permission of what it describes:
| Events | Permission needed to receive them |
|---|---|
message.* | message_read |
inbox.* | inbox_read |
domain.* | domain_read |
draft.* | draft_read |
webhook.test | webhook_read |
Other events are skipped silently, live and on replay, and event_types cannot add them. A metrics_read-only key, for example, opens the SSE stream but receives no mail events. Audit events (agent registrations, members) are in the event log only and never streamed. A dashboard session has every permission, so it receives everything. The key is re-checked every 30 seconds while a stream is open: when it is revoked, or its permissions or scope change, the stream ends with an error. Reconnect to receive what the key allows now.
WebSocket
GET /v0/ws upgrades to a WebSocket that speaks AgentMail’s protocol. Authenticate with the Authorization header or ?token=. The key needs at least one of message_read, inbox_read, domain_read, draft_read or webhook_read (otherwise 403), and receives only the events those permissions allow.
On connect you receive every event in the key’s scope that its permissions allow. Narrow it with a subscribe message:
Events then arrive as the envelope above. Send { "type": "ping" } to get { "type": "pong" }. Invalid messages get { "type": "error", "message": "..." }. Sending subscribe again replaces the previous filter. The server closes connections that stay idle for 2 minutes, so ping periodically.
The WebSocket has no replay. After a reconnect, catch up with GET /v0/events?after=<last event_id>. See Realtime events for a full consumer.
Delivery guarantees
The event log is the source of truth. Webhooks are at least once: the same event can arrive more than once, so deduplicate on event_id. Streams are live only: events that happen while you are disconnected are not queued for you, apart from the SSE replay window.
Metrics
Counts derived from events are at /v0/metrics/events (counts per event type over time), /v0/metrics/usage and /v0/metrics/rates (bounce and complaint rates), with start, end and period parameters. They need metrics_read. See the API reference.