Docs · Guides
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 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 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 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. It is stored with the label blocked and hidden; message.received does not fire for it. |
inbox.expired | A temporary inbox reached expires_at and was wiped. Payload: { inbox }. |
domain.verified · domain.failed | A 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_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, 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, 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. 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. Payload: { trigger, mode: "immediate" | "digest", inbox, message_id, draft_id, recipients, items }: counts only, never addresses. |
inbox.paused · inbox.resumed | An 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.updated | A draft 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: 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. |
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 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, or a person claimed it. Payload: { workspace, inbox } (+ claim_requested on signup.created). |
agent.updated · agent.revoked · agent.deleted | An 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.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 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.
{
"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.
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();
});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 "", 204New 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-secretwith{ "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,*.localor*.internal, and single-label hosts are refused with422 invalid_webhook_urlwhen 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
3xxanswer 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.