Docs
Webhooks
Signed event delivery: event types, payload, retries and signature verification.
Webhooks push events to your HTTPS endpoint as they happen. Deliveries follow the Standard Webhooks scheme, and the same headers are also sent with the svix- prefix, so existing Standard Webhooks or Svix verification libraries work unchanged.
Create a webhook
curl -s -X POST https://api.162-55-213-24.sslip.io/v0/webhooks \
-H "Authorization: Bearer $MAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://agent.your-domain.eu/hooks/mail", "event_types": ["message.received", "message.security.review"]}'The response contains the signing secret (whsec_...). It is returned only once. Optionally restrict a webhook to up to 10 inboxes with inbox_ids.
Event types
| Event | When |
|---|---|
message.received | A message arrived in an inbox. With screen before notify (the default) it fires only after a clean verdict, or after a release. |
message.received.spam | A message arrived and the spam filter flagged it (it carries the spam label). |
message.sent | An outbound message was accepted by the next hop. |
message.failed | An outbound message failed after all retries. |
message.bounced | A delivery status notification reported that an outbound message could not be delivered (it gets the bounced label). |
message.complained | A recipient reported an outbound message as spam through a feedback loop (it gets the complained label). |
message.security.completed | Gatekeeper screening finished; extracted_text and the verdict are set. |
message.security.review | Gatekeeper screening finished with verdict review (or could not run). With screen before notify the message is held and the payload carries no body. |
domain.verified | All DNS records of a custom domain were found. |
draft.created | A draft was saved in an inbox. The payload carries the draft without attachment content. |
inbox.sending.paused | The bounce breaker paused sending from an inbox: hard bounce rate above 5% over at least 20 sends in 24 hours, spam complaints above 0.1% over at least 1000 sends, or 3 complaints in 24 hours below that volume. |
inbox.sending.resumed | Support resumed sending from an inbox. |
organization.sending.paused | The bounce breaker paused sending for the whole organization. |
organization.sending.resumed | Support resumed sending for the organization. |
security.av_unavailable | The virus scanner was unavailable and content was accepted with the av-unscanned label. Sent at most once per 10 minutes. |
Payload
Message events carry the message and its thread. domain.verified carries the domain. Sending pause events carry the inbox or organization, the reason and the counts that tripped the breaker.
{
"type": "event",
"event_type": "message.received",
"event_id": "5f0c7a2e-6b1d-4a57-9d1e-2f9a1c3b8e44",
"created_at": "2026-10-22T09:41:07Z",
"data": {
"message": { "id": "...", "inbox_id": "billing@agents.your-domain.eu", "subject": "Invoice 2026-118", "labels": ["received", "unread"] },
"thread": { "thread_id": "...", "message_count": 1 }
}
}With screen before notify, on by default for every organization, message.received fires only after the gatekeeper verdict is clear: first message.security.completed, then message.received. A message held for review fires only message.security.review, with text, html, extracted_text and previews left out, and fires message.received once a key with message_release releases it. Deliveries leave in event order on the first attempt; retries can arrive later than newer events. If an operator turns the setting off, message.received fires at ingest while the gatekeeper may still be running.
Signature
| Header | Value |
|---|---|
webhook-id | msg_<event id>, stable across retries. Use it to deduplicate. |
webhook-timestamp | Unix seconds when the attempt was signed. |
webhook-signature | v1,<base64 HMAC-SHA256> (a space separated list). |
svix-id, svix-timestamp, svix-signature | The same values, for Svix libraries. |
The signature is an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64 decoded part of the secret after whsec_. Reject timestamps more than 5 minutes away from your clock, and compare in constant time. Verify against the raw body bytes: parsing and re-serializing JSON changes them.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
/** body must be the raw request body, exactly as received. */
export function verifyWebhook(secret: string, headers: Headers, body: string): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatures = headers.get("webhook-signature");
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const digest = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
const expected = Buffer.from(`v1,${digest}`);
return signatures.split(" ").some((candidate) => {
const got = Buffer.from(candidate);
return got.length === expected.length && timingSafeEqual(got, expected);
});
}import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, headers, body: bytes) -> bool:
"""headers: a case insensitive mapping; body: the raw request bytes."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature", "")
if not msg_id or not timestamp:
return False
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
except ValueError:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(hmac.compare_digest(sig, expected) for sig in signatures.split())Delivery and retries
- Any
2xxstatus within 15 seconds counts as delivered. Redirects are not followed. - Failed deliveries are retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then marked failed.
- Events can arrive more than once and out of order. Deduplicate with
webhook-idand read the current state from the API when order matters. - Webhook URLs must be HTTPS and resolve to public addresses. Private, loopback and link local addresses are refused.
Event stream
For live updates without a public endpoint, open GET /v0/events/stream with a key holding message_read. It is a Server Sent Events stream: each event has an id, the event type as event and the webhook body above as data, with the same redaction for held mail. Narrow it with ?types=message.received,message.sent and ?inbox=<address>. A client that reconnects with Last-Event-ID (EventSource does this for you) continues after that event; without it the stream starts with new events.
curl -N https://api.162-55-213-24.sslip.io/v0/events/stream?types=message.received \
-H "Authorization: Bearer $MAI_API_KEY"- Up to 5 open streams per API key; more return
429 too_many_streams. - An idle stream gets a
: pingcomment every 15 seconds, so proxies keep it open. - A revoked or expired key loses its open streams within a minute.
- Delivery is at least once: deduplicate with
event_id. Use webhooks when events must reach you while no client is connected.
Endpoints
POST/v0/webhooks
Create a webhook (secret is returned once)
Body
| Name | Type | Notes |
|---|---|---|
url | string (uri) | required |
event_types | EventType[] | required, at least 1 item |
inbox_ids | string (email)[] | at most 10 items |
Responses
201Created: Webhook
GET/v0/webhooks
List webhooks
Responses
200OK
DELETE/v0/webhooks/{webhook}
Delete a webhook
Parameters
| Name | Type | Notes |
|---|---|---|
webhook | string (uuid) | in path, required |
Responses
204Deleted
GET/v0/events/stream
Live events as Server Sent Events (needs message_read)
Parameters
| Name | Type | Notes |
|---|---|---|
types | string | in query, Comma separated event types; all when absent |
inbox | string (email) | in query, Only events of this inbox |
Last-Event-ID | integer | in header, Resume after this event id, min 0 |
last_event_id | integer | in query, Same as Last-Event-ID, for clients that cannot set headers, min 0 |
Responses
200Event stream: string400Error envelope: Error404Error envelope: Error429Error envelope: Error
Webhook object
| Name | Type | Notes |
|---|---|---|
webhook_id | string (uuid) | |
url | string | |
event_types | EventType[] | |
inbox_ids | string[] | |
enabled | boolean | |
secret | string | whsec_..., returned only on create |