Documentation
mbag.ai mailboxes run on the same delivery pipeline as Mailbuttons, so everything below — wire format, retry policy, and signature scheme — applies exactly as written to a mailbox you provisioned on mbag.ai.
Mailbuttons delivers an HTTP webhook to your registered endpoint every time an inbound message passes the policy gate (see Policy reference). Rejected messages do not fire webhooks; they only produce audit-log entries.
There is no out-of-order ordering guarantee yet. There is no replay-detection or de-duplication beyond messageId; consumers are responsible for idempotency.
Delivery
| Property | Value |
|---|---|
| Method | POST |
| URL | The url you supplied to POST /v1/mailboxes/{id}/webhooks. |
| Content-Type | application/json |
| Encoding | UTF-8 |
| Body shape | {"event": "<event-name>", "data": {...}} |
| Retry policy | Up to 3 attempts; linear backoff (200 ms × attempt). |
| Timeout | None enforced by the delivery worker today — keep your endpoint fast; respond 2xx immediately and process asynchronously. |
| Signature header | X-Mailbuttons-Signature: sha256=<hex> |
A delivery is considered successful on any 2xx response. Anything else triggers retry. After the final retry the delivery is dropped (no dead-letter queue yet — track via the audit log).
Signature
Every delivery includes a header X-Mailbuttons-Signature of the form:
X-Mailbuttons-Signature: sha256=<hex-encoded HMAC-SHA-256 over the raw body bytes>| Property | Value |
|---|---|
| Algorithm | HMAC-SHA-256 |
| Key | The secret returned when you registered the webhook (a 64-char hex string) |
| Input | Raw HTTP body bytes — not parsed JSON |
| Encoding | Lowercase hex of the digest |
| Timestamp tolerance | None (no timestamp included in the signed payload) |
Verify the signature before parsing the body. The two SDKs provide a verifyWebhook / verify_webhook helper that does this with a constant-time comparison.
Worked example
Given the body:
{"event":"email.received","data":{"messageId":"abc"}}and the secret shhh-this-is-a-test-secret, the expected signature is:
sha256=7ebdec2b6583ecbc6676aa21680f6c6ebbf6d740eaaef4d66e30a3c1ea8f1047(You can reproduce this with echo -n '<body>' | openssl dgst -sha256 -hmac '<secret>'.)
A 5-line Node verifier:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(body: string, header: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(body).digest("hex");
const got = header.replace(/^sha256=/, "");
return expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}A 5-line Python verifier:
import hashlib, hmac
def verify(body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
got = header.removeprefix("sha256=")
return hmac.compare_digest(expected, got)Event types
Today the platform emits a single event type. Future event types will be added here as they ship.
`inbound_message`
Fires when a message passes the policy gate.
The wire payload is:
{
"event": "email.received",
"data": { ... }
}The two SDKs map "event": "email.received" to a discriminated WebhookEvent with type: "inbound_message".
Fields on data:
| Field | Type | Description |
|---|---|---|
email_id (a.k.a. messageId) |
string |
Stable JMAP identifier of the inbound message. Use as your idempotency key. |
thread_id |
string | null |
Conversation thread id. Reuse for follow-up replies. |
sender_email (a.k.a. from) |
string |
Verified sender address (post-DKIM realignment). |
recipient_email (a.k.a. to) |
string |
Mailbox address that received the message. |
received_at |
string |
ISO-8601 UTC timestamp. |
subject |
string | null |
Subject header. Absent on bare deliveries. |
body_text |
string | null |
Plaintext body. HTML-only messages produce null. |
verification |
Verification |
DKIM/SPF/DMARC results — see below. |
capabilities |
string[] |
Capability strings the policy granted this sender. |
The platform sends both email_id/messageId and sender_email/from aliases to ease migration; the SDKs accept either form.
`Verification`
| Field | Type | Description |
|---|---|---|
dkim |
Verdict |
DKIM verdict. |
spf |
Verdict |
SPF verdict. |
dmarc |
Verdict |
DMARC verdict. |
from_alignment |
boolean | null |
Whether the From header aligns with DKIM/SPF; null if not determinable. |
Verdict is one of: pass, fail, softfail, neutral, temperror, permerror, none.
Code samples
Express (Node)
import express from "express";
import { Mailbuttons, parseWebhook, verifyWebhook } from "@mailbuttons/sdk";
const client = new Mailbuttons({ apiKey: process.env.MAILBUTTONS_API_KEY! });
const SECRET = process.env.WEBHOOK_SECRET!;
const app = express();
// Capture the raw body BEFORE Express parses JSON.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const body = req.body.toString("utf8");
if (!verifyWebhook(body, req.header("x-mailbuttons-signature"), SECRET)) {
return res.status(401).end();
}
const event = parseWebhook(body);
if (event.type === "inbound_message") {
await client.reply(MAILBOX_ID, event.data, "Got it.");
}
res.status(204).end();
},
);
app.listen(3000);FastAPI (Python)
from fastapi import FastAPI, Request, Response
from mailbuttons import Mailbuttons, parse_webhook, verify_webhook
import os
app = FastAPI()
client = Mailbuttons(api_key=os.environ["MAILBUTTONS_API_KEY"])
SECRET = os.environ["WEBHOOK_SECRET"]
@app.post("/webhook")
async def webhook(request: Request) -> Response:
raw = await request.body()
sig = request.headers.get("x-mailbuttons-signature")
if not verify_webhook(raw, sig, SECRET):
return Response(status_code=401)
event = parse_webhook(raw)
if event.type == "inbound_message":
await client.reply(MAILBOX_ID, event.data, "Got it.")
return Response(status_code=204)curl (raw shape)
A real delivery from the platform looks like:
POST /webhook HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
X-Mailbuttons-Signature: sha256=4f0e8f...
Content-Length: 412
{"event":"email.received","data":{"email_id":"f1b2c3","thread_id":"t-9","sender_email":"alice@acme.com","recipient_email":"bot@my.app","received_at":"2026-04-29T12:00:00Z","subject":"Lunch?","body_text":"Free at noon?","verification":{"dkim":"pass","spf":"pass","dmarc":"pass","from_alignment":true},"capabilities":["read_calendar","propose_meeting"]}}Common pitfalls
- Body parsing. Verify the signature against the raw body bytes. JSON-parsing first (and re-stringifying for verification) breaks signature validation because key ordering and whitespace are not preserved.
- Retries and idempotency. A delivery that fails from your side (connection error or non-
2xxresponse) will be retried up to twice more. Usedata.email_id(a.k.a.messageId) as your idempotency key; do not key on receipt time. - Signature replay. Mailbuttons does not include a timestamp in the signed payload, so a captured signed body is technically replayable. If you need replay protection, store seen
email_idvalues for a few minutes and reject duplicates. - No ordering guarantee. Two messages on the same thread may arrive out of order. Order on
received_atif it matters.