# Webhooks

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

Source: https://developer.mueslimail.com/docs/webhooks/

Webhooks push events to your HTTPS endpoint as they happen. Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/) 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

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

```json
{
  "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.

TypeScript:

```ts
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:

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

```bash
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:

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

* `201` Created: Webhook
* `404` Error envelope: Error

### GET /v0/webhooks

List webhooks

Responses:

* `200` OK

### DELETE /v0/webhooks/{webhook}

Delete a webhook

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `webhook` | string (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:

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

* `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:

| Name | Type | Notes |
| --- | --- | --- |
| `webhook` | string (uuid) | in path, required |
| `event` | string (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:

| Name | Type | Notes |
| --- | --- | --- |
| `webhook` | string (uuid) | in path, required |
| `event` | string (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:

| Name | Type | Notes |
| --- | --- | --- |
| `webhook` | string (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:

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

* `200` Event stream: string
* `400` Error envelope: Error
* `404` Error envelope: Error
* `429` Error 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 |
