webhooks.md


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 as JSON, signed with a secret only you and MachineMail know.

Creating a webhook

curl

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

TypeScript

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

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()
{
  "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.

Filters and scope

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

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

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:

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.

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.

curl "https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/deliveries?status=failed" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
{
  "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.

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

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

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

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:

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