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). |
message.sent | An outbound message left our server. |
message.delivered | The recipient’s server accepted it (where the transport reports it). |
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. |
inbox.expired | A temporary inbox reached expires_at and was wiped. Payload: { inbox }. |
webhook.test | You pressed “Send test” or called POST /v1/webhooks/:id/test. |
{
"id": "evt uuid",
"type": "message.received",
"created_at": "2026-09-24T12:00:00.000Z",
"data": {
"inbox": { "id": "…", "address": "support-bot@agents.agentboxd.com" },
"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();
// 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;
if (event.type === 'message.received') {
handle(event.data.message); // event.data.message.extracted_text
}
res.status(204).end();
});from flask import Flask, abort, request
from agentboxd import verify_webhook
app = Flask(__name__)
@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()
if event["type"] == "message.received":
handle(event["data"]["message"])
return "", 204#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. Every attempt shows up under Recent deliveries in the dashboard and in GET /v1/webhooks/:id/deliveries.
#No public URL?
Use messages.wait (long-poll) or the MCP server instead. Both work from a laptop or a job behind NAT.