verify webhooks.md
title: "Verify webhooks" description: "Verify the X-MachineMail-Signature header on webhook deliveries in TypeScript, Python and Go, reject replays, dedupe retries, and test or replay deliveries." canonical: "https://machinemail.dev/docs/guides/verify-webhooks"
Verify webhooks
Verify the X-MachineMail-Signature header on webhook deliveries in TypeScript, Python and Go, reject replays, dedupe retries, and test or replay deliveries.
Source: https://machinemail.dev/docs/guides/verify-webhooks
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.
What a delivery looks like
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:
{
"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
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);
}
// 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');
},
});
Python
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)
# 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
With FastAPI, read the raw body with await request.body() and pass the bytes to the same function.
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func verifyMachinemailSignature(secret, header string, body []byte, tolerance time.Duration) bool {
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
v1 = v
}
}
if t == 0 || v1 == "" {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
expected := mac.Sum(nil)
got, err := hex.DecodeString(v1)
if err != nil {
return false
}
return hmac.Equal(expected, got)
}
func main() {
secret := os.Getenv("MACHINEMAIL_WEBHOOK_SECRET")
http.HandleFunc("/hooks/machinemail", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 50<<20))
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
if !verifyMachinemailSignature(secret, r.Header.Get("X-MachineMail-Signature"), body, 300*time.Second) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
log.Printf("%s %s", event.EventType, event.EventID)
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
Warning: Frameworks that parse JSON before your handler runs (Express with
express.json(), for example) hand you an object, not the original bytes. Use a raw body parser on the webhook route.
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:
curl -X POST https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/test \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
{
"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.
List recent deliveries, optionally filtered by status (pending, delivered or failed):
curl "https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/deliveries?status=failed" \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
Each delivery records attempt, response_status, the first 1 KB of response_body, last_error and next_attempt_at. After you fix your endpoint, replay a delivery. It creates a new delivery of the same event and attempts it immediately:
curl -X POST https://api.machinemail.dev/v0/webhooks/whk_01m4fy8heb0000jbck9me3gqxb/deliveries/dlv_01m4fyc2a10000r7m3q9x4k2zt/replay \
-H "Authorization: Bearer $MACHINEMAIL_API_KEY"
A replay has a new X-MachineMail-Delivery-Id but the same X-MachineMail-Event-Id, so your deduplication still works. Testing and replaying need the webhook_update permission. The dashboard shows the same log with a replay button at /app/webhooks.
Rotate a secret
There is no rotate endpoint. Create a second webhook for the same URL, accept both secrets while you switch over, then delete the old webhook.