Skip to content
machinemail

Verify webhooks

Check the X-MachineMail-Signature header on every webhook delivery in TypeScript, Python and Go, reject replays, deduplicate retries, and test or replay deliveries.

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:

HeaderValue
Content-Typeapplication/json
User-AgentMachineMail-Webhooks/1.0
X-MachineMail-Signaturet=<unix seconds>,v1=<hex signature>
X-MachineMail-EventThe event type, for example message.received
X-MachineMail-Event-IdThe event ID, for example evt_01m4fy8hba0000449dcj0gmkc5
X-MachineMail-Delivery-IdThe delivery ID, for example dlv_01m4fy8hk70000a2b4c6d8e0f1

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 the secret field of POST /v0/webhooks and GET /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

verify.ts
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

With FastAPI, read the raw body with await request.body() and pass the bytes to the same function.

main.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:

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.

List recent deliveries, optionally filtered by status (pending, delivered or failed):

Terminal
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:

Terminal
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.