Webhooks
Get a signed HTTP call when mail arrives, is scanned, or contains your keywords. Test the whole path with the sample letter before anything real arrives.
A webhook is a notification, not a permission. It never runs an agent, grants API access or authorizes physical handling. A delivered event proves your receiver answered; it does not prove anything was processed.
Set up a webhook
- Add your receiver URLOpen Webhooks, choose Add webhook, and enter a name and a public HTTPS URL on port 443. Private networks, credentials in URLs and redirects are not supported. The webhook is active as soon as you save it.
- Pick events and keywordsMail received, Scan ready and Keywords matched are on by default. Add keyword rules if you want a ping when a sender or phrase appears. Live is the default; Sandbox and per-agent webhooks are under Advanced.
- Save the signing secretStore the generated
whsec_secret in your receiver’s secret manager. It is shown once and is separate from your mailbox.bot API key. - Test it with the sample letterChoose Test webhook (the Sample test). The Mojave Land Partners sample is opened and scanned for you, its real events are delivered to your receiver, and the result shows your receiver’s actual HTTP answer and which keywords matched.
Three webhooks per environment · Ten keyword rules per webhook. Pause a webhook to stop deliveries; edits to name, URL, events or keywords take effect on the next event.
Live, Sandbox and agents
Live webhooks receive your real mail. Sandbox webhooks receive test-environment mail created with sandbox keys. The sample letter reaches every webhook you have, in either environment, always flagged sample: true. A webhook bound to one agent receives only that agent’s mail.
Existing agent callback settings (one URL per agent, X-Mailbox-Signature) are unchanged and separate.
Events
Subscribe to the transitions your receiver needs. Notifications follow persisted mail changes, not clicks.
| Event | Meaning |
|---|---|
| inbound.received | A mail item or package was received for you at the facility. The envelope is photographed; contents are not scanned yet. |
| inbound.action.requested | You (or an agent proposal you approved) asked for Open & scan, Forward or Discard. |
| inbound.pages_ready | Every page of an Open & scan has readable text (or terminally failed). Fetch the pages with your API key. |
| inbound.keywords_matched | One of your keyword rules matched the envelope or the scanned contents. Sent once per item, rule and page set; never repeated for the same pages. |
| inbound.action.completed | The scan, forward (with tracking) or discard finished. |
Every event carries the item. inbound.received, inbound.pages_ready and inbound.keywords_matched also carry keywords: the rules that currently match the readable pages. A package (parcel) only ever has an envelope corpus, so its keywords can only match where: "envelope".
There is no historical backfill: a webhook receives events that happen after it is saved. Adding a rule does not enable receiving or run OCR.
Payload
Use the notification to identify the change, then fetch the current item and its pages through the authenticated API.
{
"schema_version": 2,
"event_id": "00000000-0000-4000-8000-000000000001",
"event_type": "inbound.keywords_matched",
"created_at": "2026-09-22T17:04:11Z",
"environment": "live",
"sample": true,
"item": {
"id": "00000000-0000-4000-8000-000000000002",
"reference": "SAMPLE-09941F955E23",
"kind": "letter",
"sender": "Mojave Land Partners",
"received_at": "2026-09-15T16:20:00Z",
"scanned_at": "2026-09-22T17:04:05Z",
"status": "scanned",
"pages": { "envelope": 1, "contents": 1 }
},
"keywords": [
{
"rule_id": "00000000-0000-4000-8000-000000000003",
"rule": "Solar deal",
"matched_terms": ["solar-040", "due diligence"],
"where": "contents",
"page_numbers": [1]
}
],
"api_url": "https://mailbox.bot/api/v1/inbound-items/00000000-0000-4000-8000-000000000002",
"pages_url": "https://mailbox.bot/api/v1/inbound-items/00000000-0000-4000-8000-000000000002/pages"
}The payload contains identifiers, the sender line as the scan read it, the received and scanned dates, page counts, the action for action events, and keywords with your own matched terms (normalized), where they matched and on which pages. It never contains page text, images, your signing secret or an agent’s MAILBOX.md.
Fetch the item with api_url (GET /api/v1/inbound-items/:id) and its pages with pages_url (GET /api/v1/inbound-items/:id/pages) using an API key with inbound.read; the MCP counterpart is get_inbound_item. A signature does not widen a credential’s scope. environment is the webhook’s environment for sample events and the item’s for real mail.
Keywords
Route a letter when the envelope or its scanned contents mention a sender, property, invoice or any other literal term, without an agent run.
{
"id": "00000000-0000-4000-8000-000000000010",
"name": "Solar deal",
"source": "letter",
"match": "all",
"terms": ["SOLAR-040", "due diligence"],
"exclude_terms": ["returned"]
}A rule has a stable ID, a name, where to look (envelope, letter for the scanned contents, or any), the words or phrases to look for, an any/all mode, and words that cancel the match. Terms are literal: no regexes, prompts or instructions. Matching is deterministic, runs on saved text, and never purchases OCR or invokes a language model.
Text and terms are Unicode-normalized (NFKC), lowercased and whitespace-normalized before literal substring comparison. Missing, pending or failed text on a selected page prevents matching; it is not treated as a negative result. Limits are 100 pages, 32,000 characters per page and 1 MiB of text; oversized sources are not partially matched. A rule fires at most once per item and page set: the envelope when it becomes readable at arrival, the contents when a scan finishes.
A printed sender name is unverified document text, not proof of identity. Scanned mail may contain uncertainty or malicious instructions: a matching term must never grant handling, spending or access permissions.
Sample test
Every account has a fictional letter from Mojave Land Partners in its inbound mail. Test webhook on a saved webhook (the Sample test) opens and scans that letter for you (free, no facility work; if it was already scanned it is first returned to just-arrived, so every test fires fresh events), delivers its real inbound.pages_ready and, if a rule matches, inbound.keywords_matched events to your receiver, and returns one verdict:
{
"check": "sample_webhook_delivery",
"status": "success",
"endpoint": { "name": "Solar receiver", "status": "active", "environment": "live" },
"sample_id": "00000000-0000-4000-8000-000000000002",
"ocr": { "envelope": "ready", "contents": "ready" },
"keywords": { "rules": 1, "matched": [{ "rule": "Solar deal", "terms": ["solar-040"], "where": "contents" }] },
"deliveries": [
{ "event_type": "inbound.pages_ready", "status": "delivered", "http_status": 200, "attempts": 1, "delivered_at": "2026-09-22T17:04:12Z" },
{ "event_type": "inbound.keywords_matched", "status": "delivered", "http_status": 200, "attempts": 1, "delivered_at": "2026-09-22T17:04:13Z" }
],
"reason": "receiver_acknowledged_keyword_match",
"run_started_at": "2026-09-22T17:04:05Z"
}success is only possible with an active webhook, readable sample contents and a 2xx from your receiver; when keywords are configured, the keyword event itself must have been acknowledged. not_tested and not_ready tell you what to fix (a paused webhook, a queued delivery, no keyword in the sample); failed reports your receiver’s actual status. The same test is available as POST /api/v1/webhooks/endpoints/:id/sample-test and the MCP tool test_webhook_endpoint_with_sample. The sample says SOLAR-040, Mojave Land Partners and due diligence, so a rule with any of those matches out of the box.
Verify signatures
Every delivery is signed with the webhook’s own secret using HMAC-SHA256 and the Standard Webhooks header format:
webhook-id: <immutable event ID>
webhook-timestamp: <Unix seconds for this delivery attempt>
webhook-signature: v1,<base64 signature>
signed bytes = event ID + "." + timestamp + "." + exact raw request body
key bytes = base64-decode(secret after the "whsec_" prefix)Verify against the original bytes before parsing JSON. Reject missing or invalid signatures and timestamps more than five minutes in the past or future. Compare signatures in constant time. Keep clocks synchronized, and deduplicate by the immutable event ID in durable storage.
Rotate secret returns a new secret once. Deploy it within 24 hours; during that window a request carries two space-separated v1, signatures and either one verifies. The dashboard shows when the previous key expires.
Using an existing agent callback?
Existing agent callback integrations keep their original X-Mailbox-Signature header and payloads. That legacy signature signs timestamp.rawBody and uses the original key-prefix/hex format. Do not switch an existing receiver to the new verifier without creating a webhook here.
Build a receiver
This Node/TypeScript example verifies the signature and acknowledges test deliveries. Supply your own durable queue; apply your server’s request-body limits before reading the body. A delivery attempt has a 10-second deadline, a maximum 64 KiB request, and reads no more than 4 KiB of your response.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.MAILBOX_WEBHOOK_SECRET;
if (!secret?.startsWith("whsec_")) return new Response(null, { status: 503 });
const id = request.headers.get("webhook-id") ?? "";
const timestamp = request.headers.get("webhook-timestamp") ?? "";
const signatures = request.headers.get("webhook-signature") ?? "";
const seconds = Number(timestamp);
if (!id || !/^\d+$/.test(timestamp) || !Number.isSafeInteger(seconds)
|| Math.abs(Date.now() / 1000 - seconds) > 300) {
return new Response(null, { status: 401 });
}
// Read once, before JSON parsing. Do not reserialize the body for verification.
const raw = Buffer.from(await request.arrayBuffer());
const signed = Buffer.concat([Buffer.from(id + "." + timestamp + "."), raw]);
const expected = createHmac("sha256", Buffer.from(secret.slice(6), "base64"))
.update(signed).digest();
const valid = signatures.split(/\s+/).some(signature => {
const [version, encoded] = signature.split(",");
if (version !== "v1" || !encoded) return false;
const candidate = Buffer.from(encoded, "base64");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
if (!valid) return new Response(null, { status: 401 });
const event = JSON.parse(raw.toString("utf8"));
if (event.event_type === "webhook.test") return new Response(null, { status: 204 });
// Implement this with YOUR durable queue and a unique event-ID constraint.
// Return success only after the enqueue commits; duplicates should be no-ops.
await durablyEnqueueOnce(id, event);
return new Response(null, { status: 204 });
}Return a quick 2xx only after the event is durably accepted. Do slow API retrieval, agent work, email generation and handling proposals in your worker. If enqueueing fails, return a failure so delivery is retried. Do not log the signing secret, authorization headers, page text or private API credentials.
Delivery & retries
The Logs tab on the Webhooks page lists deliveries 25 at a time and reports each delivery’s status, your receiver’s HTTP answer, attempt count, next attempt and the exact JSON that was sent. Queued is not delivered. Delivered records a successful receiver response; it does not prove your agent processed the mail.
A delivery makes at most five attempts, with bounded backoff and jitter for retryable failures and respect for Retry-After on 429 and 503. Other 3xx and 4xx answers stop sooner. Five consecutive failed attempts pause the webhook; inspect the cause before resuming it. Capacity limits can reject new work, so this is not a guarantee of delivery or ordering during overload.
Duplicate or out-of-order notifications are possible. Keep durable event-ID deduplication and fetch the current item before acting. Only failed deliveries can be replayed; a replay reuses the event ID with a fresh timestamp and signature. Editing a webhook supersedes queued work tied to its previous revision.
Completed delivery history is kept for 30 days. Do not reuse request IDs, and keep your own durable receipts. Agent callback logs are separate and unchanged.