mbag.ai

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-2xx response) 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.