Webhooks · MachineMail Docs
Creating a webhook
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 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 }'
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.
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()
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. |
Filters and scope
An event is delivered to a webhook when all of these hold:
- the webhook is enabled,
event_typesis empty or contains the event’s type,pod_idsis empty or contains the event’s pod,inbox_idsis 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.* needs inbox_read, domain.* needs domain_read, draft.* needs draft_read, webhook.test needs 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.
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.
curl "https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/deliveries?status=failed" \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
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 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:
- 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.
AuthorizationandCookieare allowed. These names are reserved and refused with400:Host,Content-Length,Content-Type,Content-Encoding,Transfer-Encoding,Connection,Upgrade,TE,Trailer,Keep-Alive,Expect,User-Agent,Accept-Encoding, and any name starting withProxy-,Sec-,X-MachineMail-,Svix-orWebhook-.- While a webhook has custom headers, moving its
urlto another origin (scheme, host or port) needsheadersin the same request, either the headers to send there ornull. 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.