---
title: "Webhook events"
description: "Every MachineMail webhook event type and its payload, the event envelope, delivery retries and replay, and how to verify the signature on each delivery."
canonical: "https://machinemail.dev/docs/reference/webhook-events"
---

# Webhook events

> Every MachineMail webhook event type and its payload, the event envelope, delivery retries and replay, and how to verify the signature on each delivery.

Source: https://machinemail.dev/docs/reference/webhook-events

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](https://machinemail.dev/docs/reference/api/webhooks),
- pushed to live streams ([`GET /v0/events/stream`](https://machinemail.dev/docs/reference/api/events) and the `/v0/ws` WebSocket),
- kept in the event log ([`GET /v0/events`](https://machinemail.dev/docs/reference/api/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](https://machinemail.dev/docs/reference/api/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:

```json title="Envelope"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.received",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "message": { "...": "the payload keys, at the top level" },
  "thread": { "...": "..." },
  "data": {
    "message": { "...": "the same payload, again under data" },
    "thread": { "...": "..." }
  }
}
```

| 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](https://machinemail.dev/docs/reference/api/objects#message), [ThreadItem](https://machinemail.dev/docs/reference/api/objects#threaditem), [Inbox](https://machinemail.dev/docs/reference/api/objects#inbox) and [Domain](https://machinemail.dev/docs/reference/api/objects#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.text` rather than `text` or `html`.
- `message.received*` payloads carry `message.authentication_results` (SPF, DKIM and DMARC verdicts with their domains), as AgentMail's do. See [Authentication](https://machinemail.dev/docs/trust/authentication#authentication_results-on-the-message).
- Domain events have no inbox, so webhooks and streams filtered by inbox never receive them.
- Expired inboxes are reaped once a minute, so `inbox.expired` can arrive up to about a minute after `expires_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).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `domain` | `Domain` | required |  |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "domain.updated",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "domain": {
    "pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
    "domain_id": "dom_01k6x3d5kq0000g3q9sx5b8j0m",
    "domain": "example.com",
    "...": "..."
  }
}
```

### domain.verified

A domain became `VERIFIED`.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `domain` | `Domain` | required |  |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "domain.verified",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "domain": {
    "pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
    "domain_id": "dom_01k6x3d5kq0000g3q9sx5b8j0m",
    "domain": "example.com",
    "...": "..."
  }
}
```

### draft.sent

A draft was sent, explicitly or at its `send_at`. A `message.sent` event is emitted too.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `draft_id` | `string` | required | ID of the sent (and now deleted) draft. |
| `inbox_id` | `string` | required | ID of the inbox. |
| `send` | `object` | required |  |

`send`:

| Field | Type | | Description |
| --- | --- | --- | --- |
| `message_id` | `string` | required | ID of the message. |
| `thread_id` | `string` | required | ID of the thread. |
| `timestamp` | `string (date-time)` | required | When the message was sent. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "draft.sent",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "draft_id": "drf_01k6x3b7hn0000e1n7qv3z6g8j",
  "inbox_id": "agent@machinemail.dev",
  "send": {
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "timestamp": "2026-10-01T12:00:00.000Z"
  }
}
```

### inbox.created

An inbox was created (API, dashboard, catch-all, agent registration or an import).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox` | `Inbox` | required |  |
| `import_source` | `"agentmail"` |  | Set when an importer copied the inbox from another provider (import_source on Create Inbox). Such events are recorded and streamed but never sent to webhooks. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "inbox.created",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "inbox": {
    "pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
    "inbox_id": "agent@machinemail.dev",
    "email": "agent@machinemail.dev",
    "...": "..."
  },
  "import_source": "agentmail"
}
```

### inbox.expired

An ephemeral inbox expired and was deleted with its mail.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox` | `Inbox` | required |  |
| `deleted_messages` | `integer` | required | Number of messages deleted with it. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "inbox.expired",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "inbox": {
    "pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
    "inbox_id": "agent@machinemail.dev",
    "email": "agent@machinemail.dev",
    "...": "..."
  },
  "deleted_messages": 1
}
```

### message.bounced

A sent message bounced.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `bounce` | `Bounce` | required |  |

`bounce` is a [Bounce](https://machinemail.dev/docs/reference/api/objects#bounce):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox_id` | `string` | required | ID of the inbox. |
| `thread_id` | `string` | required | ID of the thread. |
| `message_id` | `string` | required | ID of the message. |
| `timestamp` | `string (date-time)` | required | When it happened. |
| `type` | `string` | required | Bounce type: Permanent or Transient (or the provider type). |
| `sub_type` | `string` | required | Bounce sub-type, General when the provider gives none. |
| `recipients` | `Recipient[]` | required | Bounced recipients. Permanent bounces are added to the suppression list. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.bounced",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "bounce": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "timestamp": "2026-10-01T12:00:00.000Z",
    "type": "string",
    "sub_type": "string",
    "recipients": [
      {
        "address": "agent@machinemail.dev",
        "status": "string"
      }
    ]
  }
}
```

### message.complained

A recipient marked a sent message as spam.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `complaint` | `Complaint` | required |  |

`complaint` is a [Complaint](https://machinemail.dev/docs/reference/api/objects#complaint):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox_id` | `string` | required | ID of the inbox. |
| `thread_id` | `string` | required | ID of the thread. |
| `message_id` | `string` | required | ID of the message. |
| `timestamp` | `string (date-time)` | required | When it happened. |
| `type` | `string` | required | Complaint type, complaint when the provider gives none. |
| `sub_type` | `string` | required | Complaint sub-type, abuse when the provider gives none. |
| `recipients` | `Recipient[]` | required | Complaining recipients, as {address, status} objects (AgentMail documents plain strings). They are added to the suppression list. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.complained",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "complaint": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "timestamp": "2026-10-01T12:00:00.000Z",
    "type": "string",
    "sub_type": "string",
    "recipients": [
      {
        "address": "agent@machinemail.dev",
        "status": "string"
      }
    ]
  }
}
```

### message.delivered

The receiving server accepted a sent message.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `delivery` | `Delivery` | required |  |

`delivery` is a [Delivery](https://machinemail.dev/docs/reference/api/objects#delivery):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox_id` | `string` | required | ID of the inbox. |
| `thread_id` | `string` | required | ID of the thread. |
| `message_id` | `string` | required | ID of the message. |
| `timestamp` | `string (date-time)` | required | When it happened. |
| `recipients` | `string[]` | required | Delivered recipients. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.delivered",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "delivery": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "timestamp": "2026-10-01T12:00:00.000Z",
    "recipients": ["agent@machinemail.dev"]
  }
}
```

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

| Field | Type | | Description |
| --- | --- | --- | --- |
| `message` | `Message` | required |  |
| `thread` | `ThreadItem` | required |  |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.received",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "message": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "...": "..."
  },
  "thread": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "labels": ["UNREAD"],
    "...": "..."
  }
}
```

### message.rejected

An incoming message was refused and not stored.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `reject` | `Reject` | required |  |

`reject` is a [Reject](https://machinemail.dev/docs/reference/api/objects#reject):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox_id` | `string` | required | ID of the inbox. |
| `thread_id` | `null` | required | Always null: nothing was stored. |
| `message_id` | `null` | required | Always null: nothing was stored. |
| `timestamp` | `string (date-time)` | required | When it happened. |
| `reason` | `"inbox_paused" \| "plan_limit" \| "plan_limit_expired"` | required | Why the message was rejected. inbox_paused: the inbox is paused. plan_limit (MachineMail): the organization reached a Free plan limit and already holds as much mail as it may. plan_limit_expired (MachineMail): held mail was not released in time and was deleted. |
| `from` | `string` | required | Sender of the rejected message. |
| `subject` | `string` |  | Subject of the rejected message. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.rejected",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "reject": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": null,
    "message_id": null,
    "timestamp": "2026-10-01T12:00:00.000Z",
    "reason": "inbox_paused",
    "from": "Support Agent <agent@machinemail.dev>",
    "subject": "Order #1042"
  }
}
```

### message.released

A held message was released into the inbox because a trusted domain added with `apply_to_existing` now vouches for it (MachineMail). Sent instead of `message.received`, so agents are not handed a backlog as new mail. Sent only to webhooks whose `event_types` list it (an empty list does not include it).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `message` | `Message` | required |  |
| `thread` | `ThreadItem` | required |  |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.released",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "message": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "...": "..."
  },
  "thread": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "labels": ["UNREAD"],
    "...": "..."
  }
}
```

### message.sent

A message was sent (send, reply, forward or draft).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `send` | `Send` | required |  |
| `message` | `Message` | required | The sent message. |
| `thread` | `ThreadItem` | required | Its thread. |

`send` is a [Send](https://machinemail.dev/docs/reference/api/objects#send):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `inbox_id` | `string` | required | ID of the inbox. |
| `thread_id` | `string` | required | ID of the thread. |
| `message_id` | `string` | required | ID of the message. |
| `timestamp` | `string (date-time)` | required | When it happened. |
| `recipients` | `string[]` | required | Every recipient (to, cc and bcc). |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "message.sent",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "send": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "timestamp": "2026-10-01T12:00:00.000Z",
    "recipients": ["agent@machinemail.dev"]
  },
  "message": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
    "...": "..."
  },
  "thread": {
    "inbox_id": "agent@machinemail.dev",
    "thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
    "labels": ["UNREAD"],
    "...": "..."
  }
}
```

### trusted\_domain.created

A trusted domain was created (MachineMail): the entry as it is now. Sent only to webhooks whose `event_types` list it (an empty list does not include it).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `trusted_domain` | `TrustedDomain` | required | A trusted domain or address. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "trusted_domain.created",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "trusted_domain": {
    "trusted_domain_id": "string",
    "entry_type": "domain",
    "domain": "example.com",
    "...": "..."
  }
}
```

### trusted\_domain.deleted

A trusted domain was deleted (MachineMail): the entry as it was. Sent only to webhooks whose `event_types` list it (an empty list does not include it).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `trusted_domain` | `TrustedDomain` | required | A trusted domain or address. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "trusted_domain.deleted",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "trusted_domain": {
    "trusted_domain_id": "string",
    "entry_type": "domain",
    "domain": "example.com",
    "...": "..."
  }
}
```

### trusted\_domain.updated

A trusted domain was updated (MachineMail): the entry as it is now. Sent only to webhooks whose `event_types` list it (an empty list does not include it).

| Field | Type | | Description |
| --- | --- | --- | --- |
| `trusted_domain` | `TrustedDomain` | required | A trusted domain or address. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "trusted_domain.updated",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "trusted_domain": {
    "trusted_domain_id": "string",
    "entry_type": "domain",
    "domain": "example.com",
    "...": "..."
  }
}
```

### webhook.test

Sent by `POST /v0/webhooks/{webhook_id}/test` to that webhook only, whatever its `event_types`.

| Field | Type | | Description |
| --- | --- | --- | --- |
| `webhook_id` | `string` | required | ID of the tested webhook. |
| `message` | `string` | required | This is a test event from MachineMail. |

```json title="Example"
{
  "type": "event",
  "event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
  "event_type": "webhook.test",
  "timestamp": "2026-10-01T12:00:00.000Z",
  "data": {
    "...": "the same keys as at the top level"
  },
  "webhook_id": "whk_01k6x3c2jp0000f2p8rw4a7h9k",
  "message": "string"
}
```


## Delivery

Each delivery is an HTTP `POST` with `Content-Type: application/json`, `User-Agent: MachineMail-Webhooks/1.0` and these headers:


| Field | Type | | Description |
| --- | --- | --- | --- |
| `X-MachineMail-Signature` | `string` | required | t=<unix seconds>,v1=<hex HMAC-SHA256>: the HMAC of <t>.<raw request body> with the webhook secret as key. Recompute it over the raw body, compare in constant time, and reject old t values (the reference verifier allows 300 seconds). |
| `X-MachineMail-Event` | `string` | required | Event type, as in event_type. |
| `X-MachineMail-Event-Id` | `string` | required | ID of the event, as in event_id. The same for every delivery and replay of the event: dedupe on it. |
| `X-MachineMail-Delivery-Id` | `string` | required | ID of this delivery (GET /v0/webhooks/{webhook_id}/deliveries). A replay is a new delivery. |


- Respond with any `2xx` within **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 `http` or `https`. 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 `timestamp` or 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|failed` lists deliveries with `attempt`, `status`, `response_status`, `response_body`, `last_error`, `next_attempt_at` and `delivered_at`.
- `POST /v0/webhooks/{webhook_id}/deliveries/{delivery_id}/replay` sends 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}/test` sends a `webhook.test` event 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:

```text
X-MachineMail-Signature: t=1759320000,v1=5f2b6c...e91a
```

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

1. Read the raw body bytes before parsing JSON.
2. Split the header on `,` and each part on the first `=` to get `t` and `v1`.
3. Reject the request if `t` is more than 5 minutes from your clock.
4. Compute the HMAC and compare it with `v1` in constant time.

**TypeScript**

```ts title="verify.ts"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyMachinemailSignature(secret: string, header: string | null, rawBody: string, toleranceSeconds = 300): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Example with a fetch-style handler (Bun, Deno, Hono, Next.js route handlers).
export async function handle(req: Request): Promise<Response> {
  const raw = await req.text();
  if (!verifyMachinemailSignature(process.env.MACHINEMAIL_WEBHOOK_SECRET!, req.headers.get('x-machinemail-signature'), raw)) {
    return new Response('invalid signature', { status: 401 });
  }
  const event = JSON.parse(raw);
  // Deduplicate on event.event_id, then hand off to a queue and return quickly.
  return new Response(null, { status: 204 });
}
```

**Python**

```python title="verify.py"
import hashlib
import hmac
import time

def verify_machinemail_signature(secret: str, header: str | None, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
    if not header:
        return False
    parts = {}
    for part in header.split(","):
        key, _, value = part.partition("=")
        parts[key.strip()] = value.strip()
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_seconds:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

# Flask example
from flask import Flask, request, abort
import json, os

app = Flask(__name__)

@app.post("/webhooks/machinemail")
def machinemail_webhook():
    raw = request.get_data()
    if not verify_machinemail_signature(os.environ["MACHINEMAIL_WEBHOOK_SECRET"], request.headers.get("X-MachineMail-Signature"), raw):
        abort(401)
    event = json.loads(raw)
    # Deduplicate on event["event_id"], then process asynchronously.
    return "", 204
```

## Streams

The same envelopes are available without a public URL:

- **Server-sent events**: `GET /v0/events/stream`, filtered by `inbox_id`, `pod_id` and `event_types`. Resume after a disconnect with `Last-Event-ID` (up to 500 missed events are replayed). The stream starts with an `event: ready` frame and sends a `: ping` comment 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.
