---
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](https://machinemail.dev/docs/concepts/webhooks#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:

```json title="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](https://machinemail.dev/docs/reference/webhook-events) for the payload of each event type.

## How the signature works

```text title="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

**TypeScript**

```ts title="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);
}
```

```ts title="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');
  },
});
```

**Python**

```python title="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)
```

```python title="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.

**Go**

```go title="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](https://machinemail.dev/docs/concepts/webhooks).

## Test, inspect and replay

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

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

```json title="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`):

```bash title="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:

```bash title="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.

## Related

- [Webhooks](https://machinemail.dev/docs/concepts/webhooks)
- [Webhook events](https://machinemail.dev/docs/reference/webhook-events)
- [Realtime events](https://machinemail.dev/docs/guides/realtime-events)
