# Webhooks

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

Source: https://aimulti.inbox/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.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.

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

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

| Name | Type | Notes |
| --- | --- | --- |
| `url` | string (uri) | required |
| `event_types` | EventType[] | required, at least 1 item |
| `inbox_ids` | string (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:

| Name | Type | Notes |
| --- | --- | --- |
| `webhook` | string (uuid) | in path, required |

Responses:

* `204` Deleted

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