Docs · Guides

Webhooks

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

View as Markdown

Register an HTTPS endpoint and we POST a JSON event to it when something happens. Create webhooks from the dashboard or the API.

#Events

EventFires when
message.receivedAn inbound message was stored (not for bounce reports). In a workspace that screens agent mail 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.sentAn outbound message left our server.
message.deliveredEvery 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.bouncedDelivery failed permanently.
message.complainedThe recipient marked it as spam.
message.enrichedJEV 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.releasedA person or a key released a held 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.blockedAn inbound message was stopped by your allow/block lists. It is stored with the label blocked and hidden; message.received does not fire for it.
inbox.expiredA temporary inbox reached expires_at and was wiped. Payload: { inbox }.
domain.verified · domain.failedA custom domain 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_dueA 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.revokedSomeone connected an MCP client through the hosted connector, 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.resumedThe workspace was put under an 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.removedSomeone in the workspace removed an address from its 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_revokedAn 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.removedSomeone 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.sentAn on-call alert (or a digest of several) was emailed to your people on call. Payload: { trigger, mode: "immediate" | "digest", inbox, message_id, draft_id, recipients, items }: counts only, never addresses.
inbox.paused · inbox.resumedAn inbox was paused (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.updatedA draft was created, or edited, scheduled, rescheduled or unscheduled. Payload: { inbox, draft }.
draft.sentA 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.failedA scheduled draft was refused at its send_at (quota, daily cap, list, suppression…). draft.error says why.
draft.cancelledA draft was cancelled, so its scheduled send won’t happen.
calendar.invite.received · calendar.event.created · calendar.event.updated · calendar.event.cancelled · calendar.response.receivedMeetings: 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.extractedAn inbound attachment’s text is ready. Payload: { inbox, thread_id, message_id, attachment } with its extraction block, never the text. See Document extraction.
attachment.extraction_failedAn 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_inAn inbox minted a Sign in with Agentboxd token, or an app redeemed a code or token for it. Payload: { inbox, client, flow, jti, sub, expires_at }.
signup.created · signup.claimedAn agent created its own workspace with self-signup, or a person claimed it. Payload: { workspace, inbox } (+ claim_requested on signup.created).
agent.updated · agent.revoked · agent.deletedAn inbox’s agent card 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.reportedSomeone reported one of your agents through the directory. Payload: { inbox, reason }: never who reported it.
webhook.testYou pressed “Send test” or called POST /v1/webhooks/:id/test.

The event catalog 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
{
  "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.

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

#No public URL?

Open the event stream: the same events over a WebSocket your agent opens. Or use messages.wait (long-poll) or the MCP server. All three work from a laptop or a job behind NAT.