> ## Documentation Index
> Fetch the complete documentation index at: https://developer.maatstays.wtf/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook signatures

> Verify that a delivery is genuinely from Maat: the exact bytes to sign, the header scheme, two offline test vectors, and verifiers in Node, Python and Go.

## Verifying a delivery

Every delivery carries three headers:

| Header | What it is |
| :- | :- |
| `X-Signature` | `HMAC-SHA256(secret, "v1:" + timestamp + ":" + raw_body)`, hex-encoded. |
| `X-Signature-Timestamp` | Unix seconds. Covered *by* the signature — reject anything over 5 minutes old. |
| `X-Signature-Previous` | The same body signed with your previous secret. Rotation windows only. |

### The exact bytes

`X-Signature` is `HMAC-SHA256` over the byte string `v1:` + the timestamp + `:` + **the raw
body bytes exactly as they arrived**. The prefix and the timestamp are ASCII; the body is
transmitted as **UTF-8 and may contain non-ASCII characters**, so never re-encode it and never decode it into a charset of your own on the way
in. Sign the bytes. The key is your signing secret **as ASCII bytes, `whsec_` prefix
included** — no base64-decoding step, no key derivation of your own. The header value is **lowercase hex and
nothing else**: there is no `v1=` prefix on it, no comma-separated list and no algorithm
name, because the version already lives inside the signed string where it cannot be swapped
by whoever is replaying the body. The timestamp is Unix **seconds** as a decimal string,
covered by the signature, and a delivery whose timestamp is more than **300 seconds** away
from your clock must be rejected before anything is compared. `X-Signature-Previous`, when
present, is the same string signed with your previous secret during a rotation overlap —
accept either, and treat its absence as normal.

### A test vector

Check your verifier against this offline, before you register anything. The three inputs are
exactly what a delivery carries; the output is what your code must compute.

| Input | Value |
| :- | :- |
| Secret | `whsec_Quy9m3xTLd0Kj8vQe2NrWc5Fh7Yb1SaZpX4tG6UoPS0` |
| `X-Signature-Timestamp` | `1789200000` |
| Body | the 189 bytes below, exactly — one line, no trailing newline |

```text theme={null}
{"event_id":"9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d","event_type":"booking.confirmed","occurred_at":"2026-09-12T07:58:00Z","data":{"booking_reference":"5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d"}}
```

The string you sign is therefore `v1:1789200000:` followed by those bytes, and

```text theme={null}
X-Signature: b9eeb16c320e853232bad7b7200b69a4bfa3abce6580e1de5025a6c441e4fdd6
```

If your implementation produces something else, the usual causes are, in order: you signed a
re-serialised body rather than the bytes; you decoded the secret instead of using it as ASCII;
you left out the `v1:` prefix or the colon after the timestamp; or you uppercased the hex.

### A second vector, with multi-byte characters in it

The vector above is pure ASCII, so it cannot tell you whether your verifier re-encodes the
body. This one can, and it is the mistake worth catching before it ships: a verifier that
asks for the body as ASCII or Latin-1 — `getBytes("US-ASCII")`, `.encode("ascii")`,
`Encoding.ASCII.GetBytes` — passes the first vector, passes every test you write in English,
and then rejects a real delivery the first time a body carries a character outside ASCII.

Same secret, same timestamp. Only the body changes. It is synthetic — not the shape of any
real event — and carries `José 中村` purely to exercise multi-byte UTF-8. It is **221 bytes of
UTF-8** across 216 characters, because `é` costs two bytes and each of `中` and `村` costs
three.

```text theme={null}
{"event_id":"9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d","event_type":"booking.confirmed","occurred_at":"2026-09-12T07:58:00Z","data":{"booking_reference":"5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d","guest":{"name":"José 中村"}}}
```

```text theme={null}
X-Signature: 9cabd8106b9284af89b39af1124905b55c5ac5966df28b4d8db69486924de103
```

If the first vector passes and this one does not, the difference is your body encoding and
nothing else. What a re-encode produces varies by language — some truncate each character to
one byte, some substitute `?`, some raise — so there is no single wrong answer to compare
against. There is only this one: `61f817f1…` is what the delivery on your socket is signed
with.

Four rules, in order of how often they are gotten wrong:

1. **Verify against the raw request body, before any JSON parsing.** `JSON.parse`
   followed by `JSON.stringify` does not round-trip byte-identically — key order,
   whitespace and `1.0` → `1` all move — so a re-serialised body fails verification
   every time. Capture the bytes as they arrived.
2. **Reject a timestamp more than five minutes old** before comparing anything. The
   timestamp is inside the signed string, so a replayed body cannot carry a fresh one.
3. **Compare in constant time**, using your language's timing-safe comparison — never
   `==` on the hex strings.
4. **During a rotation, accept either signature.** `X-Signature-Previous` is present
   only while a rotation overlap is open; its absence is normal, not an error. If you
   lose your secret or it leaks, rotate it yourself with
   [`POST /v1/webhooks/endpoint/rotate-secret`](/guides/webhook-delivery#managing-your-endpoint-yourself) — we sign
   with both secrets for an overlap window so you deploy on your own clock, never against
   ours. Rotate **once** and store what it returns: a second rotation inside the overlap is
   refused with `409 rotation_in_progress` precisely so a retry can never push the secret
   you are running out of the signing set.

```javascript Node.js (Express) theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.MAAT_WEBHOOK_SECRET;

// express.raw, not express.json — verification needs the raw bytes.
app.post('/maat', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.header('X-Signature-Timestamp') ?? '';

    // 1. Reject a missing, unreadable or stale timestamp before comparing
    //    anything. Number('abc') is NaN and every comparison against NaN is
    //    false, so the window check has to be written as a test that NaN FAILS.
    const sentAt = Number(timestamp);
    if (!Number.isFinite(sentAt) || !(Math.abs(Date.now() / 1000 - sentAt) <= 300)) {
        return res.status(401).end();
    }

    // 2. HMAC over "v1:" + timestamp + ":" + the raw body.
    const expected = createHmac('sha256', secret)
        .update(`v1:${timestamp}:`)
        .update(req.body) // a Buffer — the bytes as delivered
        .digest('hex');

    // 3. Constant-time compare — against either signature during a rotation.
    const matches = (candidate) =>
        typeof candidate === 'string' &&
        candidate.length === expected.length &&
        timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));

    const verified =
        matches(req.header('X-Signature')) || matches(req.header('X-Signature-Previous'));
    if (!verified) return res.status(401).end();

    // 4. Only now parse.
    const event = JSON.parse(req.body.toString('utf8'));

    // 5. event_id is stable across our retries — use it as your idempotency key.
    //    A duplicate you have already processed should be answered 200 and ignored.

    if (event.event_type === 'endpoint.challenge') {
        return res.status(200).json({ challenge: event.data.challenge });
    }

    res.status(200).end();
});

app.listen(8443);
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, jsonify, request

app = Flask(__name__)
SECRET = os.environ["MAAT_WEBHOOK_SECRET"].encode()


@app.post("/maat")
def maat_webhook():
    raw_body = request.get_data()  # the raw bytes, before any JSON parsing
    timestamp = request.headers.get("X-Signature-Timestamp", "")

    # 1. Reject a missing, unreadable or stale timestamp before comparing
    #    anything. int("abc") RAISES, which would answer 500 — a malformed
    #    header is a refusal, not a fault of yours.
    try:
        sent_at = int(timestamp)
    except ValueError:
        return "", 401
    if abs(time.time() - sent_at) > 300:
        return "", 401

    # 2. HMAC over b"v1:" + timestamp + b":" + the raw body.
    expected = hmac.new(
        SECRET, b"v1:" + timestamp.encode() + b":" + raw_body, hashlib.sha256
    ).hexdigest()

    # 3. Constant-time compare — against either signature during a rotation.
    #    compare_digest RAISES on a str with a non-ASCII character, and a header
    #    value can carry one (WSGI decodes headers as latin-1), so a forged
    #    signature must not be able to answer 500. Ours is always lowercase hex.
    candidates = [
        request.headers.get("X-Signature", ""),
        request.headers.get("X-Signature-Previous", ""),
    ]
    if not any(
        sig and sig.isascii() and hmac.compare_digest(sig, expected)
        for sig in candidates
    ):
        return "", 401

    # 4. Only now parse.
    event = request.get_json(force=True)

    # 5. event_id is stable across retries — use it as your idempotency key.

    if event["event_type"] == "endpoint.challenge":
        return jsonify(challenge=event["data"]["challenge"])

    return "", 200
```

```go Go (net/http) theme={null}
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "log"
    "math"
    "net/http"
    "os"
    "strconv"
    "time"
)

var secret = []byte(os.Getenv("MAAT_WEBHOOK_SECRET"))

func expectedSignature(timestamp string, rawBody []byte) string {
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte("v1:" + timestamp + ":"))
    mac.Write(rawBody)
    return hex.EncodeToString(mac.Sum(nil))
}

func matches(candidate, expected string) bool {
    // ConstantTimeCompare is length-safe here because both are hex of the same
    // digest; hmac.Equal is constant time for equal lengths and returns false
    // otherwise, which is what we want.
    return hmac.Equal([]byte(candidate), []byte(expected))
}

func handler(w http.ResponseWriter, r *http.Request) {
    // 1. The raw bytes, before any JSON decoding.
    rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // 2. A missing, unreadable or stale timestamp is a 401, never a panic.
    timestamp := r.Header.Get("X-Signature-Timestamp")
    sentAt, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-sentAt)) > 300 {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    // 3. Constant-time compare — against either signature during a rotation.
    expected := expectedSignature(timestamp, rawBody)
    if !matches(r.Header.Get("X-Signature"), expected) &&
        !matches(r.Header.Get("X-Signature-Previous"), expected) {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    // 4. Only now parse.
    var event struct {
        EventID   string          `json:"event_id"`
        EventType string          `json:"event_type"`
        Data      json.RawMessage `json:"data"`
    }
    if err := json.Unmarshal(rawBody, &event); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // 5. event_id is stable across retries — use it as your idempotency key.

    if event.EventType == "endpoint.challenge" {
        var challenge struct {
            Challenge string `json:"challenge"`
        }
        _ = json.Unmarshal(event.Data, &challenge)
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(challenge)
        return
    }

    w.WriteHeader(http.StatusOK)
}

func main() {
    http.HandleFunc("/maat", handler)
    log.Fatal(http.ListenAndServe(":8443", nil))
}
```

Once a signature checks out, [Webhook delivery](/guides/webhook-delivery) covers retries,
ordering, the event catalogue and the rest of the endpoint's lifecycle.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.