# Webhooks

Source: https://agentboxd.com/docs/webhooks

> Signed POSTs for new mail and delivery events, and how to verify them in TypeScript and Python.

Register an HTTPS endpoint and we POST a JSON event to it when something happens. Create webhooks from the [dashboard](https://agentboxd.com/app/webhooks) or the API.

## Events

| Event | Fires when |
| --- | --- |
| `message.received` | An inbound message was stored (not for bounce reports). In a workspace that [screens agent mail](https://agentboxd.com/docs/api#screening-reads) it carries no content until the message is screened (`withheld` is set): subscribe to `message.enriched` and `message.released` too, and handle each message once. |
| `message.sent` | An outbound message left our server. |
| `message.delivered` | Every recipient is an Agentboxd inbox and got it, or the recipient’s server confirmed it (where the transport reports it). Most servers don’t, so most mail stays `sent`. |
| `message.bounced` | Delivery failed permanently. |
| `message.complained` | The recipient marked it as spam. |
| `message.enriched` | JEV categorised an inbound message. Same payload as `message.received`, with the new `ai` fields and `ai:*` labels. Sent at most once per message, always after `message.received`. In a screening workspace it carries the content only when the message is screened. |
| `message.released` | A person or a key released a [held](https://agentboxd.com/docs/api#held-mail) message to agents. The message with its content, plus `released: { at, by, reason }`: `by` is `user:<id>` or `key:<id>`, `reason` why it was held. It is also the audit record of the release. |
| `message.received.blocked` | An inbound message was stopped by your [allow/block lists](https://agentboxd.com/docs/api#allow-and-block-lists). It is stored with the label `blocked` and hidden; `message.received` does not fire for it. |
| `inbox.expired` | A [temporary inbox](https://agentboxd.com/docs/temporary-inboxes) reached `expires_at` and was wiped. Payload: `{ inbox }`. |
| `domain.verified` · `domain.failed` | A [custom domain](https://agentboxd.com/docs/custom-domains) passed all its required DNS records and can send (the first time, or again after failing), or a verified domain failed them 3 checks in a row and sending from it is paused. Payload: `{ domain: { id, domain, status, dkim }, previous_status }`, sent to webhooks that are not limited to certain inboxes. |
| `domain.dkim_rotated` · `domain.dkim_rotation_due` | A custom domain switched to its new DKIM key, or its key has signed for a year and should be rotated. Payload: `{ domain: { id, domain, status, dkim } }`, sent to webhooks that are not limited to certain inboxes. |
| `connector.authorized` · `connector.revoked` | Someone connected an MCP client through the [hosted connector](https://agentboxd.com/docs/mcp#hosted-connector-recommended), or a connection ended: disconnected (`reason: "user"`), revoked by the app (`client_revocation`), a refresh token used twice (`refresh_token_reuse`), its approver left the workspace (`member_removed`) or Agentboxd ended the app’s connections (`operator`). Payload: `{ connector }` (+ `reason`), with the app, the access and the inbox if it is limited to one. |
| `workspace.stopped` · `workspace.resumed` | The workspace was put under an [emergency stop](https://agentboxd.com/docs/emergency-stop), or it was lifted. Payload: `{ workspace, reason, actor }` / `{ workspace, stopped_at, actor }`, where `actor` is `{ type: "user" \| "api_key" \| "connector", id }`. Sent to webhooks that are not limited to certain inboxes. |
| `suppression.removed` | Someone in the workspace removed an address from its [suppression list](https://agentboxd.com/docs/deliverability#your-suppression-list). Payload: `{ suppression: { address, reason, created_at, expires_at }, actor: { type: "user" \| "key" \| "connector", id } }`: the record of who allowed mail to that address again. |
| `member.invited` · `member.invite_revoked` | An owner invited someone (`resent: true` when an invite was sent again with a new link), or an invite was revoked: by an owner, or because the person who sent it left, was removed or was made a member. Payload: `{ invite, resent, actor }` / `{ invite, actor }`, where `invite` is `{ id, email, role, status, invited_by, created_at, sent_at, expires_at }`. Sent to webhooks that are not limited to certain inboxes. |
| `member.joined` · `member.role_changed` · `member.removed` | Someone accepted an invite; an owner changed a role; someone left (`reason: "left"`), was removed (`"removed"`) or deleted their account (`"account_deleted"`). Payload: `{ member, invite_id }` / `{ member, previous_role, actor }` / `{ member, reason, actor }`, where `member` is `{ user_id, email, name, role }`. Together these events are the workspace’s record of who joined, left and could do what. |
| `escalation.sent` | An on-call alert (or a digest of several) was emailed to your [people on call](https://agentboxd.com/docs/human-on-call). Payload: `{ trigger, mode: "immediate" \| "digest", inbox, message_id, draft_id, recipients, items }`: counts only, never addresses. |
| `inbox.paused` · `inbox.resumed` | An inbox was [paused](https://agentboxd.com/docs/api#inboxes) (sends refused, inbound events held) or resumed. Payload: `{ inbox, reason }` / `{ inbox, held_events }`; after `inbox.resumed` the held `message.received` events are emitted in the order the mail arrived (webhook deliveries run in parallel, so order by the events’ `created_at`). |
| `draft.created` · `draft.updated` | A [draft](https://agentboxd.com/docs/drafts) was created, or edited, scheduled, rescheduled or unscheduled. Payload: `{ inbox, draft }`. |
| `draft.sent` | A draft was approved (or its `send_at` came) and handed to the outbound queue. Payload: `{ inbox, draft, message }`; `draft.approved_by` says who approved it. Delivery then shows in the `message.*` events. |
| `draft.failed` | A scheduled draft was refused at its `send_at` (quota, daily cap, list, suppression…). `draft.error` says why. |
| `draft.cancelled` | A draft was cancelled, so its scheduled send won’t happen. |
| `calendar.invite.received` · `calendar.event.created` · `calendar.event.updated` · `calendar.event.cancelled` · `calendar.response.received` | [Meetings](https://agentboxd.com/docs/meetings#webhooks-to-react-to): an invite became an event, the inbox sent a meeting, a meeting changed or was cancelled, or a guest answered one of the inbox’s meetings. Payload: `{ inbox, event, message_id }`, plus `attendee` on `calendar.response.received`. A full payload needs `calendar:read`. |
| `attachment.extracted` | An inbound attachment’s text is ready. Payload: `{ inbox, thread_id, message_id, attachment }` with its `extraction` block, never the text. See [Document extraction](https://agentboxd.com/docs/document-extraction). |
| `attachment.extraction_failed` | An attachment couldn’t be read, or was skipped because the plan’s extracted pages are used up; `attachment.extraction.error` says why. |
| `identity.token_issued` · `identity.signed_in` | An inbox minted a [Sign in with Agentboxd](https://agentboxd.com/docs/agent-identity) token, or an app redeemed a code or token for it. Payload: `{ inbox, client, flow, jti, sub, expires_at }`. |
| `signup.created` · `signup.claimed` | An agent created its own workspace with [self-signup](https://agentboxd.com/docs/agent-signup), or a person claimed it. Payload: `{ workspace, inbox }` (+ `claim_requested` on `signup.created`). |
| `agent.updated` · `agent.revoked` · `agent.deleted` | An inbox’s [agent card](https://agentboxd.com/docs/agent-directory) was created, changed or restored; revoked by you or suspended by the operator (`reason`); or deleted. Payload: `{ inbox, card }` (+ `reason`) / `{ inbox }`. In these events `card.keys.agent` lists only the active agent keys. |
| `agent.reported` | Someone reported one of your agents through the directory. Payload: `{ inbox, reason }`: never who reported it. |
| `webhook.test` | You pressed “Send test” or called `POST /v1/webhooks/:id/test`. |

The [event catalog](https://agentboxd.com/app/webhooks/events) in the dashboard (and `GET /v1/webhooks/events`, no key needed) shows the exact body of every event, in both payload formats.

## Envelope payloads

Set `payload: "envelope"` on a webhook (or pick **Envelope** in the dashboard) and message events carry only `{ inbox_id, thread_id, message_id, from, to, subject, labels, received_at }` in `data`: no bodies, headers, attachments or AI content ever leave in the POST. Your agent fetches the message with `GET /v1/messages/:id` when it needs it. Draft events carry `{ inbox_id, thread_id, draft_id, status, to, subject, labels, send_at, sent_message_id, error_code }` in the same way, and attachment events `{ inbox_id, thread_id, message_id, attachment_id, content_type, status, method, pages, error_code }`. The default is `full` when the key may read what the events carry (`messages:read` for message and attachment events, `drafts:read` for draft events), else `envelope`. Asking for `full` without them is refused (`403 insufficient_permissions`). Dashboard users can always choose `full`. Each webhook also reports its last 24 hours (deliveries, failures, error rate, last success and failure) in `stats` and on its dashboard card.

POST body:

```json
{
  "id": "evt uuid",
  "type": "message.received",
  "created_at": "2026-09-24T12:00:00.000Z",
  "data": {
    "inbox": { "id": "…", "address": "support-bot@homingbox.net" },
    "thread_id": "…",
    "message": { "extracted_text": "…", "labels": [], "attachments": [] }
  }
}
```

A webhook created without `events` gets all message events, including `message.enriched`. Webhooks created before categorisation existed keep their own list: add the event with `PATCH /v1/webhooks/:id` or in the dashboard. `message.enriched` is best effort. If sending it fails after the results were saved, the results stay on the message, so an agent that must act on categories should also check `ai.enriched_at` when it reads mail. See [Categories and risk flags](https://agentboxd.com/docs/api#categories-and-risk-flags).

## Verify the signature

Each POST carries `X-Mailroom-Timestamp` (unix seconds) and `X-Mailroom-Signature` = `hex(HMAC-SHA256(secret, "<timestamp>.<raw body>"))`. Verify against the **raw** body, before parsing it. Both helpers reject timestamps more than 300 seconds off and compare in constant time.

server.ts:

```ts
import express from 'express';
import { verifyWebhook, type WebhookEvent } from 'agentboxd';

const app = express();
// The content of new mail comes with one of these.
const MAIL_EVENTS: string[] = ['message.received', 'message.enriched', 'message.released'];

// Verify against the raw body, exactly as received.
app.post('/hooks/mail', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(
    String(req.header('X-Mailroom-Signature')),
    String(req.header('X-Mailroom-Timestamp')),
    req.body, // Buffer
    process.env.WEBHOOK_SECRET!,
  );
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString('utf8')) as WebhookEvent;
  // withheld: not screened yet, or held. A later event brings the content.
  if (MAIL_EVENTS.includes(event.type) && !event.data.message.withheld) {
    handle(event.data.message); // once per message id: event.data.message.extracted_text
  }
  res.status(204).end();
});

```

app.py:

```python
from flask import Flask, abort, request
from agentboxd import verify_webhook

app = Flask(__name__)
MAIL_EVENTS = {"message.received", "message.enriched", "message.released"}


@app.post("/hooks/mail")
def mail_hook():
    if not verify_webhook(
        request.headers.get("X-Mailroom-Signature"),
        request.headers.get("X-Mailroom-Timestamp"),
        request.get_data(),  # raw bytes
        WEBHOOK_SECRET,
    ):
        abort(401)
    event = request.get_json()
    # "withheld": not screened yet, or held. A later event brings the content.
    if event["type"] in MAIL_EVENTS and "withheld" not in event["data"]["message"]:
        handle(event["data"]["message"])  # once per message id
    return "", 204

```

New mail can reach `handle` up to three times: `message.received`, `message.enriched` once its check is done, and `message.released` for held mail a person released. In a workspace that [screens agent mail](https://agentboxd.com/docs/api#screening-reads), the events from before the message was screened or released have `withheld` and are skipped; in one that doesn’t, each of them carries the content. So make `handle` act once per message id, for example with a label you add when you are done.

## Rotate the secret

We store the secret encrypted and show only its last 4 characters after creation. To replace it, call `POST /v1/webhooks/:id/rotate-secret` (or **Rotate secret** in the dashboard). Deliveries are signed with the new secret from then on; the old one stops at once.

- Without downtime: choose the new secret yourself, deploy your endpoint so it accepts a signature made with either secret, then call `rotate-secret` with `{ "secret": "…" }`, and remove the old secret afterwards.
- Or rotate first (a generated secret is returned once) and update your endpoint. Deliveries that fail in between are retried for about 24 hours.

## Endpoint requirements

- A public **HTTPS** URL, port 443 or 1024–65535, with no username or password in it.
- The hostname must resolve only to public addresses. Private, loopback, link-local and other reserved addresses (IPv4 and IPv6), names like `localhost`, `*.local` or `*.internal`, and single-label hosts are refused with `422 invalid_webhook_url` when you create or update the webhook.
- We resolve the hostname again for every delivery and connect only to an address we checked. If it now resolves to a private address, the attempt fails with “resolves to a private or reserved address”.
- Redirects are not followed: a `3xx` answer is a failed attempt. Give us the final URL.

## Retries

Any non-2xx response or a timeout (10 s) counts as a failure. We retry 8 times with exponential backoff starting at 680 s, about 24 hours in total. A delivery that falls more than 6 hours behind this schedule (your workspace has many deliveries waiting at once and its endpoints are slow) is not sent late: it is marked `failed`. Every delivery shows up under **Recent deliveries** in the dashboard and in `GET /v1/webhooks/:id/deliveries`, with its status code, response time and error.

## Send a delivery again

Fixed your endpoint? Press **Redeliver** on a row of **Recent deliveries**, or call `POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver` (or `POST /v1/webhooks/:id/redeliver` with `{ "event_id": "…" }`). We send the same payload again, signed with your current secret and a new `X-Mailroom-Timestamp`, so a check that refuses old timestamps still accepts it. It is one attempt; `X-Mailroom-Delivery` stays the same, so skip ids you already handled. A delivery that is still being retried can’t be sent again until it succeeds or gives up.

> **Note:** Respond fast and do the work afterwards. If your handler calls a model, queue the event and return 204 first.

## No public URL?

Open the [event stream](https://agentboxd.com/docs/realtime-events): the same events over a WebSocket your agent opens. Or use `messages.wait` (long-poll) or the [MCP server](https://agentboxd.com/docs/mcp). All three work from a laptop or a job behind NAT.
