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

EventFires when
message.receivedAn inbound message was stored (not for bounce reports).
message.sentAn outbound message left our server.
message.deliveredThe recipient’s server accepted it (where the transport reports it).
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.
inbox.expiredA temporary inbox reached expires_at and was wiped. Payload: { inbox }.
webhook.testYou pressed “Send test” or called POST /v1/webhooks/:id/test.
POST body
{
  "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.

server.ts
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();
});
app.py
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.