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>" ) )
- The key is the whole secret string as returned by the API, including the
whsec_prefix. It is in thesecretfield ofPOST /v0/webhooksandGET /v0/webhooks/{webhook_id}. - The message is the timestamp, a dot, then the exact bytes of the request body. Parse the JSON only after the check passes; re-serializing it changes the bytes.
- Compare in constant time.
- Reject timestamps more than 300 seconds from your clock. That stops an attacker from replaying a delivery they captured.
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
- Return any 2xx status within 10 seconds. Anything else, a timeout or a connection error counts as a failure.
- Redirects are not followed. Point the webhook at the final URL.
- Delivery is at least once. A delivery can arrive twice, for example after a network error on our side or a replay. Deduplicate on
X-MachineMail-Event-Id(the same event to the same endpoint) and do the work idempotently. - Do slow work, such as calling a model, after you respond. Put the event on a queue.
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.