Skip to content
machinemail

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:

ChannelUse it for
WebhooksServers that should be called when something happens. Retried, signed, logged.
SSE: GET /v0/events/streamA long-lived process or agent that wants a push stream over plain HTTP.
WebSocket: GET /v0/wsThe same stream over a WebSocket, compatible with AgentMail’s protocol.
Event log: GET /v0/eventsCatching up after downtime, backfill and audit.

All four carry the same events in the same shape.

Event types

Event typeWhendata
message.receivedAn inbound message was accepted into INBOX.message, thread
message.received.quarantinedThe trust layer quarantined an inbound message.message, thread
message.received.spamAn inbound message was classified as spam.message, thread
message.received.unauthenticatedAn inbound message failed DMARC, or both SPF and DKIM.message, thread
message.received.blockedThe sender matched a receive block list.message, thread
message.sentA message was sent from an inbox.send, message, thread
message.deliveredThe receiving server accepted it.delivery
message.bouncedIt bounced.bounce
message.complainedThe recipient marked it as spam.complaint
message.rejectedInbound mail was refused, for example because the inbox is paused.reject
domain.verifiedA custom domain passed verification.domain
inbox.createdAn inbox was created, including by catch-all.inbox
inbox.expiredAn ephemeral inbox expired and was deleted.inbox, deleted_messages
draft.sentA draft was sent, by you or on schedule.draft_id, inbox_id, send
webhook.testYou 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.received
{
  "type": "event",
  "event_id": "evt_01m4fy8hba0000449dcj0gmkc5",
  "event_type": "message.received",
  "timestamp": "2026-10-09T09:00:16.106Z",
  "message": { "message_id": "msg_01m4fy8han0000y3ge3m73vw3w", "...": "..." },
  "thread": { "thread_id": "thd_01m4fy8h340001pp6c74x4688h", "...": "..." },
  "data": {
    "message": {
      "message_id": "msg_01m4fy8han0000y3ge3m73vw3w",
      "inbox_id": "support-agent@machinemail.dev",
      "thread_id": "thd_01m4fy8h340001pp6c74x4688h",
      "labels": ["INBOX", "UNREAD"],
      "from": "ada@example.com",
      "subject": "Re: Hello",
      "extracted_text": "Thanks! Your verification code is 482913.",
      "trust": { "level": "unknown", "score": 0.7, "...": "..." }
    },
    "thread": { "thread_id": "thd_01m4fy8h340001pp6c74x4688h", "message_count": 2, "...": "..." }
  }
}

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

Terminal
curl "https://api.machinemail.dev/v0/events?event_types=message.received&limit=20" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
Response
{
  "count": 1,
  "limit": 20,
  "events": [
    {
      "event_id": "evt_01m4fy8hba0000449dcj0gmkc5",
      "event_type": "message.received",
      "organization_id": "org_01m4fy8h140000efvagszh6rpx",
      "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
      "inbox_id": "support-agent@machinemail.dev",
      "timestamp": "2026-10-09T09:00:16.106Z",
      "data": { "message": { "...": "..." }, "thread": { "...": "..." } },
      "created_at": "2026-10-09T09:00:16.106Z"
    }
  ]
}
ParameterNotes
afterAn event_id or an ISO 8601 time. Returns newer events, oldest first. Use the last event_id you processed to resume.
beforeISO 8601 time.
event_typesComma-separated or repeated.
inbox_id, pod_idNarrow to one inbox or pod.
limit, page_token, ascendingPagination. 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.

Terminal
curl -N "https://api.machinemail.dev/v0/events/stream?inbox_id=support-agent@machinemail.dev&event_types=message.received" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
Stream
event: ready
data: {"type":"subscribed"}

id: evt_01m4fy8hba0000449dcj0gmkc5
event: message.received
data: {"type":"event","event_id":"evt_01m4fy8hba0000449dcj0gmkc5","event_type":"message.received",...}

: ping
  • Filter with inbox_id, pod_id and event_types, each comma-separated or repeated.
  • ready is sent once the subscription is live. Each event’s SSE id is its event_id and its SSE event name is the event type.
  • A : ping comment is sent after 15 seconds without events, to keep proxies from closing the connection.
  • On reconnect, send Last-Event-ID with the last event_id you saw. MachineMail replays up to 500 missed events first. The replay honours event_types and your key’s scope but not the inbox_id or pod_id query filters, so filter replayed events on your side. For longer gaps, page through GET /v0/events?after=....
  • Browsers’ EventSource cannot 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:

EventsPermission needed to receive them
message.*message_read
inbox.*inbox_read
domain.*domain_read
draft.*draft_read
webhook.testwebhook_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:

Client to server
{ "type": "subscribe", "inbox_ids": ["support-agent@machinemail.dev"], "event_types": ["message.received"] }
Server to client
{ "type": "subscribed", "inbox_ids": ["support-agent@machinemail.dev"], "event_types": ["message.received"] }

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.

Next