Verify webhooks · MachineMail Docs

What a delivery looks like

MachineMail signs every webhook delivery with your webhook’s secret. Verify the signature before you trust anything in the body. Without it, anyone who finds your endpoint URL can post a fake message.received and steer your agent.

Each delivery is an HTTPS POST with a JSON body and these headers:

Header Value
Content-Type application/json
User-Agent MachineMail-Webhooks/1.0
X-MachineMail-Signature t=<unix seconds>,v1=<hex signature>
X-MachineMail-Event The event type, for example message.received
X-MachineMail-Event-Id The event ID, for example evt_01m4fy8hba0000449dcj0gmkc5
X-MachineMail-Delivery-Id The delivery ID, for example dlv_01m4fy8hk70000a2b4c6d8e0f1

If you gave the webhook custom headers, such as Authorization: Bearer <token> for an endpoint that requires one, they are sent on every delivery as well. They are not part of the signed payload: the signature covers the timestamp and the body only, so verify it as below even when your endpoint also checks a token.

The body has the event envelope. The event’s data is in data, and its keys are also repeated at the top level for AgentMail compatibility:

Body (trimmed)

{
  "type": "event",
  "message": { "message_id": "msg_01m4fy8han0000y3ge3m73vw3w", "inbox_id": "support-agent@machinemail.dev", "trust": { "level": "known" } },
  "thread": { "thread_id": "thd_01m4fy8h340001pp6c74x4688h", "message_count": 2 },
  "event_id": "evt_01m4fy8hba0000449dcj0gmkc5",
  "event_type": "message.received",
  "timestamp": "2026-10-09T09:00:16.106Z",
  "data": {
    "message": { "message_id": "msg_01m4fy8han0000y3ge3m73vw3w" },
    "thread": { "thread_id": "thd_01m4fy8h340001pp6c74x4688h" }
  }
}

See Webhook events for the payload of each event type.

How the signature works

Signature

X-MachineMail-Signature: t=1791536416,v1=5f2b9c...e81a

v1 = hex( HMAC-SHA256( key = webhook secret, message = "<t>.<raw request body>" ) )

Verify in your language

TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyMachinemailSignature(secret: string, header: string | null, rawBody: string, toleranceSeconds = 300): boolean {
  if (!header) return false;
  const parts = new Map(
    header.split(',').map((p) => {
      const i = p.indexOf('=');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const;
    }),
  );
  const t = Number(parts.get('t'));
  const v1 = parts.get('v1');
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

server.ts

// Bun (or any runtime with the Fetch API Request/Response).
import { verifyMachinemailSignature } from './verify';

const secret = process.env.MACHINEMAIL_WEBHOOK_SECRET!;
const seen = new Set<string>(); // use your database in production

Bun.serve({
  port: 3000,
  async fetch(req) {
    if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
    const raw = await req.text(); // raw body, before JSON.parse
    if (!verifyMachinemailSignature(secret, req.headers.get('x-machinemail-signature'), raw)) {
      return new Response('Invalid signature', { status: 401 });
    }
    const eventId = req.headers.get('x-machinemail-event-id')!;
    if (seen.has(eventId)) return new Response('Duplicate', { status: 200 });
    seen.add(eventId);

const event = JSON.parse(raw);
    if (event.event_type === 'message.received') {
      // Queue the work and return quickly.
      console.log('New message', event.data.message.message_id, event.data.message.trust?.level);
    }
    return new Response('ok');
  },
});

verify.py

import hashlib, hmac, time

def verify_machinemail_signature(secret: str, header: str | None, raw_body: bytes, tolerance: int = 300) -> bool:
    if not header:
        return False
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

app.py

# Flask
import json, os
from flask import Flask, request
from verify import verify_machinemail_signature

app = Flask(__name__)
SECRET = os.environ["MACHINEMAIL_WEBHOOK_SECRET"]
seen = set()  # use your database in production

@app.post("/hooks/machinemail")
def machinemail_hook():
    raw = request.get_data()  # raw bytes, before parsing
    if not verify_machinemail_signature(SECRET, request.headers.get("X-MachineMail-Signature"), raw):
        return "Invalid signature", 401
    event_id = request.headers["X-MachineMail-Event-Id"]
    if event_id in seen:
        return "Duplicate", 200
    seen.add(event_id)

event = json.loads(raw)
    if event["event_type"] == "message.received":
        msg = event["data"]["message"]
        print("New message", msg["message_id"], (msg.get("trust") or {}).get("level"))
    return "ok", 200

Respond fast and deduplicate

Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and then every 24 hours, until 5 days after the event. After that the delivery is marked failed. See Webhooks.

Test, inspect and replay

Send a synthetic webhook.test event to an endpoint and get the delivery result back:

Terminal

curl -X POST https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/test \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"

Response

{
  "delivery_id": "dlv_01m4fyc2a10000r7m3q9x4k2zt",
  "webhook_id": "whk_01m4fy8heb0000jbck9me3gqxb",
  "event_id": "evt_01m4fyc29z0000bq7c1n5f8wvd",
  "event_type": "webhook.test",
  "attempt": 1,
  "status": "delivered",
  "response_status": 200,
  "response_body": "ok",
  "delivered_at": "2026-10-09T09:05:12.402Z",
  "created_at": "2026-10-09T09:05:12.311Z",
  "updated_at": "2026-10-09T09:05:12.402Z"
}

The test event’s data is { "webhook_id": "...", "message": "This is a test event from MachineMail." }. It is signed like any other delivery, so it exercises your verification code.