Docs

Webhooks

Signed event delivery: event types, payload, retries and signature verification.

View as Markdown

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

EventWhen
message.receivedA message arrived in an inbox. With screen before notify (the default) it fires only after a clean verdict, or after a release.
message.received.spamA message arrived and the spam filter flagged it (it carries the spam label).
message.sentAn outbound message was accepted by the next hop.
message.delivered
message.delivery_delayed
message.failedAn outbound message failed after all retries.
message.rejected
message.dropped
message.bouncedA delivery status notification reported that an outbound message could not be delivered (it gets the bounced label).
message.complainedA recipient reported an outbound message as spam through a feedback loop (it gets the complained label).
message.suppressed
message.security.completedGatekeeper screening finished; extracted_text and the verdict are set.
message.security.reviewGatekeeper screening finished with verdict review (or could not run). With screen before notify the message is held and the payload carries no body.
domain.verifiedAll DNS records of a custom domain were found.
draft.createdA draft was saved in an inbox. The payload carries the draft without attachment content.
inbox.sending.pausedThe 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.resumedSupport resumed sending from an inbox.
organization.sending.pausedThe bounce breaker paused sending for the whole organization.
organization.sending.resumedSupport resumed sending for the organization.
security.av_unavailableThe 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_createdThe 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.actedThe inbox agent took a label or assignment action: action names it and target is the message, the thread or the ticket.
inbox.agent.skippedThe 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.

POST body
{
  "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

HeaderValue
webhook-idmsg_<event id>, stable across retries. Use it to deduplicate.
webhook-timestampUnix seconds when the attempt was signed.
webhook-signaturev1,<base64 HMAC-SHA256> (a space separated list).
svix-id, svix-timestamp, svix-signatureThe 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.

TypeScript
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);
  });
}
Python
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 2xx status 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-id and 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}/events lists the events delivered to one webhook, newest first, with the delivery state and the last attempt. Filter with ?status=failed or ?status=succeeded.
  • GET /v0/webhooks/{webhook}/events/{event}/attempts returns every attempt of one event, oldest first, with attempt_no, status_code (null when the connection failed), latency_ms and the redacted response_excerpt.
  • POST /v0/webhooks/{webhook}/events/{event}/replay queues the event again, also when it succeeded: deploy the fixed handler, then replay instead of waiting for the next event. The answer is 202, and the new attempt appears with the next attempt_no.
  • POST /v0/webhooks/{webhook}/rotate-secret issues a new secret and returns it once. For 24 hours the delivery is signed with both secrets, so a verifier holding either one accepts every event; after previous_secret_valid_until only 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
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 : ping comment 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

NameTypeNotes
urlstring (uri)required
event_typesEventType[]required, at least 1 item
inbox_idsstring (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

  • 201 Created: Webhook
  • 404 Error envelope: Error

GET/v0/webhooks

List webhooks

Responses

  • 200 OK

DELETE/v0/webhooks/{webhook}

Delete a webhook

Parameters

NameTypeNotes
webhookstring (uuid)in path, required

Responses

  • 204 Deleted

GET/v0/webhooks/{webhook}/events

Events delivered to this webhook, newest first, each with its last attempt (requires webhook_read)

Parameters

NameTypeNotes
webhookstring (uuid)in path, required
statuspending | succeeded | failedin query, Filter on the delivery state
limitintegerin query, min 1, max 100, default 20
page_tokenstringin query

Responses

  • 200 OK: WebhookEventList
  • 403 Error envelope: Error
  • 404 Error envelope: Error

GET/v0/webhooks/{webhook}/events/{event}/attempts

Every attempt of one event, oldest first (requires webhook_read)

Parameters

NameTypeNotes
webhookstring (uuid)in path, required
eventstring (uuid)in path, required

Responses

  • 200 OK: WebhookAttemptList
  • 403 Error envelope: Error
  • 404 Error 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

NameTypeNotes
webhookstring (uuid)in path, required
eventstring (uuid)in path, required

Responses

  • 202 Queued: WebhookReplay
  • 403 Error envelope: Error
  • 404 Error 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

NameTypeNotes
webhookstring (uuid)in path, required

Responses

  • 200 OK: RotateSecret
  • 403 Error envelope: Error
  • 404 Error envelope: Error

GET/v0/events/stream

Live events as Server Sent Events (needs message_read)

Parameters

NameTypeNotes
typesstringin query, Comma separated event types; all when absent
inboxstring (email)in query, Only events of this inbox
Last-Event-IDintegerin header, Resume after this event id, min 0
last_event_idintegerin query, Same as Last-Event-ID, for clients that cannot set headers, min 0

Responses

  • 200 Event stream: string
  • 400 Error envelope: Error
  • 404 Error envelope: Error
  • 429 Error envelope: Error

Webhook object

NameTypeNotes
webhook_idstring (uuid)
urlstring
event_typesEventType[]
inbox_idsstring[]
enabledboolean
secretstringwhsec_..., returned only on create

WebhookEvent object

NameTypeNotes
event_idstring (uuid)
event_typestring
statuspending | succeeded | failedDelivery state
attemptsintegerAttempts claimed so far
last_attempt_statussucceeded | failed
last_status_codeinteger
last_latency_msinteger
last_attempt_atstring (date-time)
created_atstring (date-time)

WebhookAttempt object

NameTypeNotes
attempt_nointeger
statussucceeded | failed
status_codeintegerNull when the connection failed
latency_msinteger
response_excerptstringAt most 2 KB of the response body with credentials redacted; response headers are never stored, max length 2048
errorstring
created_atstring (date-time)

RotateSecret object

NameTypeNotes
webhook_idstring (uuid)
secretstringThe new signing secret, shown once
rotated_atstring (date-time)
previous_secret_valid_untilstring (date-time)Until then the dispatcher signs with the previous secret too