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_id values for a few minutes and reject duplicates.
  • No ordering guarantee. Two messages on the same thread may arrive out of order. Order on received_at if it matters.