---
title: "Events"
description: "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."
canonical: "https://machinemail.dev/docs/concepts/events"
---

# 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.

Source: https://machinemail.dev/docs/concepts/events

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](https://machinemail.dev/docs/concepts/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.released`                 | A held message was released because a [trusted domain](https://machinemail.dev/docs/concepts/trusted-domains#mail-you-already-received) now vouches for it. Opt-in. | `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.

`message.released` is sent instead of `message.received` for mail a trusted domain releases after the fact, so an agent is not handed a backlog as if it had just arrived. It is opt-in: a webhook or stream receives it only when its `event_types` list it, and an empty list does not include it. The same holds for the `trusted_domain.created`, `trusted_domain.updated` and `trusted_domain.deleted` events described in [Trusted domains](https://machinemail.dev/docs/concepts/trusted-domains#events).

Full payload examples are in the [webhook events reference](https://machinemail.dev/docs/reference/webhook-events).

## 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:

```json title="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

```bash title="Terminal"
curl "https://api.machinemail.dev/v0/events?event_types=message.received&limit=20" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

```json title="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"
    }
  ]
}
```

| 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.

```bash title="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"
```

```text title="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:

| 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:

```json title="Client to server"
{ "type": "subscribe", "inbox_ids": ["support-agent@machinemail.dev"], "event_types": ["message.received"] }
```

```json title="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](https://machinemail.dev/docs/guides/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](https://machinemail.dev/docs/reference).

## Next

- [Webhooks](https://machinemail.dev/docs/concepts/webhooks)
- [Realtime events](https://machinemail.dev/docs/guides/realtime-events)
- [Webhook events reference](https://machinemail.dev/docs/reference/webhook-events)
