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.mueslimail.com/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.delivered | |
message.delivery_delayed | |
message.failed | An outbound message failed after all retries. |
message.rejected | |
message.dropped | |
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.suppressed | |
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: sending_health on that inbox carries the window, the thresholds that tripped and the scope that paused. |
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. |
message.cancelled | |
rule.matched | |
inbox.agent.draft_created | The inbox agent prepared a draft: action is draft_reply or draft_forward and draft_id names the row. Nothing was sent, and sending it is a person's act. |
inbox.agent.acted | The inbox agent took a label or assignment action: action names it and target is the message, the thread or the ticket. |
inbox.agent.skipped | The inbox agent looked at a message and took no action. reason is one of no_action, spam, budget or disabled. |
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.
Deliveries, replay and secret rotation
Every attempt is recorded with the status code, how long the endpoint took, and at most 2 KB of the response body with credentials redacted. Response headers are never read, so the Authorization header of your own endpoint cannot be read back from us. Attempts are kept 30 days.
GET /v0/webhooks/{webhook}/eventslists the events delivered to one webhook, newest first, with the delivery state and the last attempt. Filter with?status=failedor?status=succeeded.GET /v0/webhooks/{webhook}/events/{event}/attemptsreturns every attempt of one event, oldest first, withattempt_no,status_code(nullwhen the connection failed),latency_msand the redactedresponse_excerpt.POST /v0/webhooks/{webhook}/events/{event}/replayqueues the event again, also when it succeeded: deploy the fixed handler, then replay instead of waiting for the next event. The answer is202, and the new attempt appears with the nextattempt_no.POST /v0/webhooks/{webhook}/rotate-secretissues a newsecretand returns it once. For 24 hours the delivery is signed with both secrets, so a verifier holding either one accepts every event; afterprevious_secret_valid_untilonly the new secret is used. Rotate again inside the window and the older secret is dropped at once.
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.mueslimail.com/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)[] | Inboxes the caller can see (404 otherwise). A webhook keeps the scope of the caller who created it: events of inboxes outside that scope are never delivered, whatever inbox_ids says, and a scoped webhook receives events without an inbox only when its creator is an owner or admin., at most 10 items |
Responses
201Created: Webhook404Error envelope: Error
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/webhooks/{webhook}/events
Events delivered to this webhook, newest first, each with its last attempt (requires webhook_read)
Parameters
| Name | Type | Notes |
|---|---|---|
webhook | string (uuid) | in path, required |
status | pending | succeeded | failed | in query, Filter on the delivery state |
limit | integer | in query, min 1, max 100, default 20 |
page_token | string | in query |
Responses
200OK: WebhookEventList403Error envelope: Error404Error envelope: Error
GET/v0/webhooks/{webhook}/events/{event}/attempts
Every attempt of one event, oldest first (requires webhook_read)
Parameters
| Name | Type | Notes |
|---|---|---|
webhook | string (uuid) | in path, required |
event | string (uuid) | in path, required |
Responses
200OK: WebhookAttemptList403Error envelope: Error404Error envelope: Error
POST/v0/webhooks/{webhook}/events/{event}/replay
Queue the event again for this webhook, also when it succeeded (requires webhook_write, audited)
Parameters
| Name | Type | Notes |
|---|---|---|
webhook | string (uuid) | in path, required |
event | string (uuid) | in path, required |
Responses
202Queued: WebhookReplay403Error envelope: Error404Error envelope: Error
POST/v0/webhooks/{webhook}/rotate-secret
Issue a new signing secret; the previous one verifies for 24 more hours (requires webhook_write, audited)
Parameters
| Name | Type | Notes |
|---|---|---|
webhook | string (uuid) | in path, required |
Responses
200OK: RotateSecret403Error envelope: Error404Error envelope: Error
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 |
WebhookEvent object
| Name | Type | Notes |
|---|---|---|
event_id | string (uuid) | |
event_type | string | |
status | pending | succeeded | failed | Delivery state |
attempts | integer | Attempts claimed so far |
last_attempt_status | succeeded | failed | |
last_status_code | integer | |
last_latency_ms | integer | |
last_attempt_at | string (date-time) | |
created_at | string (date-time) |
WebhookAttempt object
| Name | Type | Notes |
|---|---|---|
attempt_no | integer | |
status | succeeded | failed | |
status_code | integer | Null when the connection failed |
latency_ms | integer | |
response_excerpt | string | At most 2 KB of the response body with credentials redacted; response headers are never stored, max length 2048 |
error | string | |
created_at | string (date-time) |
RotateSecret object
| Name | Type | Notes |
|---|---|---|
webhook_id | string (uuid) | |
secret | string | The new signing secret, shown once |
rotated_at | string (date-time) | |
previous_secret_valid_until | string (date-time) | Until then the dispatcher signs with the previous secret too |