Realtime events
Consume MachineMail events live over Server-Sent Events or WebSocket, with filters, keepalives, reconnection, backfill from the event log and deduplication by event_id.
Webhooks need a public URL. When your agent runs on a laptop, in a sandbox or behind a firewall, open a stream instead. MachineMail offers two:
| SSE | WebSocket | |
|---|---|---|
| Endpoint | GET /v0/events/stream | GET /v0/ws (upgrade) |
| Filters | Query parameters | A subscribe message, changeable at any time |
| Resume after a drop | Last-Event-ID header, replayed by the server | Backfill yourself from GET /v0/events |
| Keepalive | Server sends a comment every 15 seconds | Client sends ping |
| Permission | metrics_read | Any of the read permissions below |
Both carry the same event envelope as webhooks, and both only see events inside the key’s scope. A pod-scoped key streams its pod; an inbox-scoped key streams its inbox. Both also send an event only when the key holds its read permission: message.* needs message_read, inbox.* inbox_read, domain.* domain_read, draft.* draft_read and webhook.test webhook_read. See Events for the event types.
Authentication
Send Authorization: Bearer <key> when your client can. Browser EventSource and browser WebSocket cannot set headers, so both endpoints also accept the key as ?token=<key>.
Warning
A key in a URL can end up in proxy logs and browser history. In a browser, use an inbox-scoped key with only the permissions the page needs. See API keys.
Server-Sent Events
- The first frame is always
event: ready. Wait for it before you treat the stream as live. - Each event frame has
eventset to the event type,idset to the event ID, anddataset to the JSON envelope. - When nothing happens for 15 seconds the server writes a
: pingcomment. SSE parsers ignore it; it keeps proxies from closing the connection.
Filters
| Parameter | Effect |
|---|---|
inbox_id (or inbox_ids) | Only events for these inboxes. |
pod_id (or pod_ids) | Only events for these pods. |
event_types | Only these event types. |
Each takes one value, repeated parameters (event_types=a&event_types=b), a comma-separated list (event_types=a,b) or a JSON array.
Resuming with Last-Event-ID
Reconnect with the header Last-Event-ID: <last evt_ id you processed> and the server first replays up to 500 events that happened after it, then continues live. EventSource sends this header for you on automatic reconnects.
Note
The replay honours event_types and the key’s scope, but not the inbox_id or pod_id query filters. After a reconnect you can receive events for other inboxes in scope. Filter on inbox_id in
your handler, or use an inbox-scoped key.
TypeScript consumer
This consumer uses fetch, so it can send the Bearer header. It parses frames, reconnects with exponential backoff, resumes with Last-Event-ID and drops duplicates.
Reset delay to 1 second once a connection has delivered ready, if you want fast reconnects after long healthy sessions. In a browser, new EventSource(url + '&token=' + key) gives you reconnection and Last-Event-ID for free.
Python consumer
The 60-second read timeout is safe because the server pings every 15 seconds; a silent minute means the connection is dead.
WebSocket
Connect to wss://api.machinemail.dev/v0/ws. On open you are subscribed to every event in the key’s scope. Narrow it with a subscribe message, which you can send again at any time to replace the filter:
Then every event arrives as a text frame with the envelope ("type": "event"). The other messages:
| Client sends | Server answers |
|---|---|
{ "type": "ping" } | { "type": "pong" } |
| Invalid JSON | { "type": "error", "message": "Invalid JSON" } |
Any other type | { "type": "error", "message": "Unknown message type: ..." } |
The server closes connections that are idle for 120 seconds. Send a ping every 30 seconds or so.
Note
The WebSocket does not replay missed events. After a reconnect, backfill from the event log with GET /v0/events?after=<last event_id>, which returns newer events oldest first. That endpoint needs metrics_read.
TypeScript consumer
GET /v0/events items have the same event_id, event_type, timestamp and data as stream events, without the top-level copies of the data keys, so read from data in your handler.
Python consumer
additional_headers is the argument name in websockets 14 and later; older versions call it extra_headers.
Backfill and deduplication rules
- Store the last
event_idyou processed, durably if missing an event matters. - Event IDs sort in creation order, so
GET /v0/events?after=<event_id>returns exactly what came later, oldest first, 50 per page by default and up to 500 withlimit. afteralso accepts an ISO 8601 timestamp, for example when you start a consumer for the first time.- Always deduplicate by
event_id. Backfill and the live stream overlap by design. - The event log also contains event types that are never streamed or sent to webhooks, such as
webhook.test. Passevent_typeswhen you backfill.