Documentation
Inbound webhooks
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 | 30 s per attempt. |
| 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=b9c6c6cc9aa2378ed8a2a3c7e8e5b1ed6d10c4ed42ce169b5fe2b8a8bd91d2f6(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 times out from your side will be retried up to twice more. Use
data.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.