Skip to content
multi.ai.inbox™
Menu

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

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.failedAn outbound message failed after all retries.
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.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: 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.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.

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.

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.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 : 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)[]at most 10 items

Responses

  • 201 Created: Webhook

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