---
title: "Webhooks"
description: "MachineMail webhooks POST signed events to your HTTPS endpoint, filtered by event type, pod or inbox, with retries for five days and a replayable delivery log."
canonical: "https://machinemail.dev/docs/concepts/webhooks"
---

# Webhooks

> MachineMail webhooks POST signed events to your HTTPS endpoint, filtered by event type, pod or inbox, with retries for five days and a replayable delivery log.

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

A webhook is an HTTPS endpoint that MachineMail calls when an event happens. Each call is a `POST` with the [event envelope](https://machinemail.dev/docs/concepts/events#the-envelope) as JSON, signed with a secret only you and MachineMail know.

## Creating a webhook

**curl**

```bash title="Terminal"
curl https://api.machinemail.dev/v0/webhooks \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/machinemail",
    "event_types": ["message.received", "message.received.quarantined"],
    "inbox_ids": ["support-agent@machinemail.dev"],
    "client_id": "support-hook"
  }'
```

**TypeScript**

```ts title="create-webhook.ts"
const res = await fetch('https://api.machinemail.dev/v0/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://example.com/hooks/machinemail',
    event_types: ['message.received', 'message.received.quarantined'],
    inbox_ids: ['support-agent@machinemail.dev'],
    client_id: 'support-hook',
  }),
});
const webhook = await res.json();
// Store webhook.secret; you need it to verify deliveries.
```

**Python**

```python title="create_webhook.py"
import os, requests

webhook = requests.post(
    "https://api.machinemail.dev/v0/webhooks",
    headers={"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"},
    json={
        "url": "https://example.com/hooks/machinemail",
        "event_types": ["message.received", "message.received.quarantined"],
        "inbox_ids": ["support-agent@machinemail.dev"],
        "client_id": "support-hook",
    },
).json()
```

```json title="Response"
{
  "webhook_id": "whk_01m4fy8heb0000jbck9me3gqxb",
  "url": "https://example.com/hooks/machinemail",
  "event_types": ["message.received", "message.received.quarantined"],
  "inbox_ids": ["support-agent@machinemail.dev"],
  "secret": "whsec_hbANq3v94BDGbt1lDkzB8Y0tXJNVOSo9",
  "enabled": true,
  "status": "active",
  "client_id": "support-hook",
  "created_at": "2026-10-09T09:00:16.203Z",
  "updated_at": "2026-10-09T09:00:16.203Z"
}
```

| Field         | Notes                                                                                                           |
| ------------- | --------------------------------------------------------------------------------------------------------------- |
| `url`         | Required. `http` or `https` to a public address. Private and internal addresses are refused.                    |
| `event_types` | Event types to send. Omit it or pass an empty list to receive every type. Unknown types are a validation error. |
| `inbox_ids`   | Only events for these inboxes.                                                                                  |
| `pod_ids`     | Only events for these pods.                                                                                     |
| `client_id`   | Idempotency key.                                                                                                |
| `headers`     | Custom request headers sent with every delivery. Values are write-only. See [Custom headers](#custom-headers).  |

You can also create webhooks in the dashboard at [machinemail.dev/app/webhooks](https://machinemail.dev/app/webhooks).

## Filters and scope

An event is delivered to a webhook when all of these hold:

- the webhook is enabled,
- `event_types` is empty or contains the event's type (`message.released` and the `trusted_domain.*` events are sent only when listed: an empty list does not include them),
- `pod_ids` is empty or contains the event's pod,
- `inbox_ids` is empty or contains the event's inbox.

A webhook never delivers an event its creator could not read. Creating one needs the read permission of every listed event type (`message.*` needs `message_read`, `inbox.*` `inbox_read`, `domain.*` `domain_read`, `draft.*` `draft_read`, `webhook.test` `webhook_read`), otherwise `403`. An empty `event_types` from a key without all of them is stored as the types that key may read. Adding event types or changing the `url` of an existing webhook follows the same rule.

A webhook created with a pod-scoped key, or under `/v0/pods/{pod_id}/webhooks`, is bound to that pod (its `pod_id` is set) and only receives that pod's events. The same goes for inbox keys and `/v0/inboxes/{inbox_id}/webhooks`. Events without an inbox, such as `domain.verified`, never match a webhook filtered by `inbox_ids`.

## The request

```http title="Request"
POST /hooks/machinemail HTTP/1.1
Content-Type: application/json
User-Agent: MachineMail-Webhooks/1.0
X-MachineMail-Signature: t=1791536416,v1=5f2c...e81a
X-MachineMail-Event: message.received
X-MachineMail-Event-Id: evt_01m4fy8hba0000449dcj0gmkc5
X-MachineMail-Delivery-Id: dlv_01m4fy8hbc0000ywxk3s1bkzf4

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

Respond with any `2xx` status within 10 seconds. Do slow work after you respond.

## Signatures

`X-MachineMail-Signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The HMAC is computed with the webhook's `secret` (the full string, including `whsec_`) over the timestamp, a dot and the raw request body:

```text
v1 = hex(hmac_sha256(secret, t + "." + raw_body))
```

To verify, recompute it from the raw body bytes (not re-serialized JSON), compare in constant time, and reject timestamps more than 5 minutes from your clock to stop replays. Code for TypeScript, Python and Go is in [Verify webhooks](https://machinemail.dev/docs/guides/verify-webhooks).

The secret is returned on the webhook object (`GET /v0/webhooks/{webhook_id}`), so keep keys with `webhook_read` private.

## Retries

A delivery fails on a non-`2xx` response, a timeout or a connection error. Redirects are not followed and count as failures. Failed deliveries are retried after:

| Attempt     | Delay after the previous failure |
| ----------- | -------------------------------- |
| 2           | 1 minute                         |
| 3           | 5 minutes                        |
| 4           | 30 minutes                       |
| 5           | 2 hours                          |
| 6           | 12 hours                         |
| 7 and later | 24 hours                         |

Retries stop five days after the event; the delivery is then marked `failed`. Delivery is at least once. The same event can arrive twice, for example if your server processed it but the response was lost, so deduplicate on `X-MachineMail-Event-Id` (the same value as `event_id` in the body).

## Delivery log, replay and test

Every attempt is recorded.

```bash title="Terminal"
curl "https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/deliveries?status=failed" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
```

```json title="Delivery"
{
  "delivery_id": "dlv_01m4fy8hbc0000ywxk3s1bkzf4",
  "webhook_id": "whk_01m4fy8heb0000jbck9me3gqxb",
  "event_id": "evt_01m4fy8hba0000449dcj0gmkc5",
  "event_type": "message.received",
  "attempt": 3,
  "status": "pending",
  "response_status": 503,
  "response_body": "upstream unavailable",
  "next_attempt_at": "2026-10-09T09:36:16.000Z",
  "last_error": "HTTP 503",
  "created_at": "2026-10-09T09:00:16.106Z",
  "updated_at": "2026-10-09T09:06:16.000Z"
}
```

`status` is `pending` (waiting for its next attempt), `delivered` or `failed`. `response_body` keeps the first 1 KB of your response.

| Method | Path                                                        | Notes                                                      |
| ------ | ----------------------------------------------------------- | ---------------------------------------------------------- |
| `GET`  | `/v0/webhooks/{webhook_id}/deliveries`                      | Filter with `status`. Paginated.                           |
| `POST` | `/v0/webhooks/{webhook_id}/deliveries/{delivery_id}/replay` | Sends the same event again now, as a new delivery.         |
| `POST` | `/v0/webhooks/{webhook_id}/test`                            | Sends a `webhook.test` event now and returns the delivery. |

Replay and test need `webhook_update`. The dashboard shows the same log with a replay button on each webhook's page.

## Updating and disabling

`PATCH /v0/webhooks/{webhook_id}` accepts:

| Field                               | Notes                                                                           |
| ----------------------------------- | ------------------------------------------------------------------------------- |
| `url`                               | New endpoint.                                                                   |
| `event_types`                       | Replaces the list.                                                              |
| `add_inbox_ids`, `remove_inbox_ids` | Edit the inbox filter.                                                          |
| `add_pod_ids`, `remove_pod_ids`     | Edit the pod filter.                                                            |
| `enabled`                           | `false` stops deliveries (`status` becomes `disabled`); `true` resumes them.    |
| `headers`                           | Replaces the custom headers. `null` or `{}` removes them; omit it to keep them. |

Events that happen while a webhook is disabled are not queued for it. Use the [event log](https://machinemail.dev/docs/concepts/events#the-event-log) to catch up.

`DELETE /v0/webhooks/{webhook_id}` removes it. Pending deliveries for a deleted webhook fail.

## Custom headers

Some endpoints only accept requests that carry a credential of their own, such as `Authorization: Bearer <token>`. Give the webhook `headers` and MachineMail sends them with every delivery, alongside the `X-MachineMail-*` headers: each attempt, retry, replay and test event.

```bash title="Terminal"
curl https://api.machinemail.dev/v0/webhooks \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/routines/inbox-triage",
    "event_types": ["message.received"],
    "headers": { "Authorization": "Bearer '"$ROUTINE_TOKEN"'" }
  }'
```

From the CLI, read the value from stdin (`-`) or an environment variable (`env:VAR`) so it stays out of your shell history:

```bash title="Terminal"
machinemail webhooks create --url https://hooks.example.com/routines/inbox-triage \
  --event-types message.received --header "Authorization: env:ROUTINE_TOKEN"

printf 'Bearer %s' "$ROUTINE_TOKEN" | machinemail webhooks update whk_01m4fy8heb0000jbck9me3gqxb --header "Authorization: -"
```

Header values are write-only. They are stored encrypted, and no response, delivery record, event or log ever contains them: the webhook object lists only `header_names` (and `headers_updated_at`). If your endpoint repeats a value back in its response, it is replaced with `[redacted]` in the delivery's `response_body`.

| Method  | Path                                | Notes                                                                                                       |
| ------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `POST`  | `/v0/webhooks`                      | `headers` sets them on a new webhook.                                                                       |
| `PATCH` | `/v0/webhooks/{webhook_id}`         | `headers` replaces the whole set; `null` or `{}` removes them all; omit it to keep them.                    |
| `GET`   | `/v0/webhooks/{webhook_id}/headers` | `{"header_names": [...]}`. Never the values.                                                                |
| `PATCH` | `/v0/webhooks/{webhook_id}/headers` | `headers` adds or replaces the named headers, `remove_headers` removes names; the rest stay. Returns `204`. |

Setting and removing headers needs `webhook_update` (`webhook_create` on create); reading the names needs `webhook_read`. The pod and inbox routes (`/v0/pods/{pod_id}/webhooks/...`, `/v0/inboxes/{inbox_id}/webhooks/...`) have the same endpoints.

Rules:

- At most 10 headers per webhook; names and values together at most 8 KB.
- Names are HTTP tokens (letters, digits and ``!#$%&'*+-.^_`|~``) of up to 64 characters, compared without regard to case. A name given again in another case replaces the header.
- Values are printable ASCII of up to 4,096 characters, with no line breaks or control characters. Surrounding spaces are removed.
- `Authorization` and `Cookie` are allowed. These names are reserved and refused with `400`: `Host`, `Content-Length`, `Content-Type`, `Content-Encoding`, `Transfer-Encoding`, `Connection`, `Upgrade`, `TE`, `Trailer`, `Keep-Alive`, `Expect`, `User-Agent`, `Accept-Encoding`, and any name starting with `Proxy-`, `Sec-`, `X-MachineMail-`, `Svix-` or `Webhook-`.
- While a webhook has custom headers, moving its `url` to another origin (scheme, host or port) needs `headers` in the same request, either the headers to send there or `null`. A value is never sent to a new server unless you send it again.

Custom headers are not part of the signature: `X-MachineMail-Signature` covers the timestamp and the body only. Keep verifying the signature; a header value proves only that the sender knows it.

## Next

- [Verify webhooks](https://machinemail.dev/docs/guides/verify-webhooks)
- [Events](https://machinemail.dev/docs/concepts/events)
- [Webhook events reference](https://machinemail.dev/docs/reference/webhook-events)
