# Verifying signatures

> Every webhook carries a timestamped HMAC-SHA256 signature over the raw body. Verify it, in constant time, against the exact bytes you received.

Source: https://docs.amomic.in/webhooks/signatures

---

Your webhook URL is a public HTTP endpoint. Anyone who learns it can POST to it, and a forged `ticket.created` that your system trusts is a real problem. The signature is what separates an event we sent from one somebody else made up.

Verify every request. Reject anything that does not verify, without parsing it.

## The header

```
Amomic-Signature: t=1754049600,v1=8f3ac91b0d7e4f2a6c5b8e1d0a9f3c72e4b6d8a1c3e5f7091b2d4a6c8e0f2a4b6
```

Two comma-separated `key=value` pairs:

| Field | Meaning |
|---|---|
| `t` | The Unix timestamp, in **seconds**, at which the signature was generated. |
| `v1` | HMAC-SHA256 of the signed string, as **lowercase hexadecimal**. Version 1 of the scheme. |

Parse it by splitting on `,` and then each piece on the **first** `=`. Do not split on every `=` — the value is hex today, but a scheme that survives contact with base64 later should not break on padding.

## What is signed

Exactly this string:

```
{t}.{body}
```

The timestamp from the header, a literal `.` (full stop), and the raw request body. Nothing else — not the URL, not the method, not the other headers.

The HMAC key is your endpoint's signing secret: the `whsec_…` string, **used as raw UTF-8 bytes**.

::: danger Do not transform the secret
Two things people do to the secret, both of which produce a signature that never matches:

- **Base64-decoding it.** The characters after `whsec_` look like base64 because they are — the secret was generated as base64url of 32 random bytes. It is still used as a string. Do not decode it.
- **Stripping the `whsec_` prefix.** The prefix is part of the key. `whsec_abc123` and `abc123` are different keys.

Pass the secret to your HMAC function exactly as the console gave it to you, prefix and all.
:::

So the reference computation, in Python, is:

```python
hmac.new(
    secret.encode("utf-8"),                             # "whsec_…", verbatim
    f"{timestamp}.".encode("utf-8") + raw_body,         # raw_body is bytes
    hashlib.sha256,
).hexdigest()                                            # lowercase hex
```

Use the timestamp **as it appeared in the header**, as a string. Parse it to an integer for the tolerance check, but feed the original characters into the HMAC. They are the same today; using the original string means they stay the same if the format ever grows leading zeroes or a different width.

## Verify against the raw bytes

This is the part that costs people an afternoon, so it gets its own section.

The body we send is serialised compactly. There is no space after `:` and no space after `,`:

```
{"id":"evt_9f2c4a17b03e5d8194ca2f60","type":"message.received","created_at":"2026-08-01T10:00:00Z",...}
```

Those exact bytes are the `{body}` in the signed string. Byte-for-byte.

If your framework parsed the JSON into a map and you serialise it again to verify, you get a different byte sequence — most standard libraries insert `", "` and `": "` — and the HMAC over that sequence has nothing to do with ours. Your verification code can be flawlessly correct and still fail every request.

Re-serialising also breaks in ways that are harder to see than whitespace:

- **Key order.** Most JSON parsers preserve insertion order, some do not. One reordered key is a completely different signature.
- **Unicode escaping.** The body is pure ASCII: every non-ASCII character is escaped, so a customer named प्रिया arrives as `\u092a\u094d\u0930\u093f\u092f\u093e`. Most serialisers write that back as raw UTF-8, which is different bytes.
- **Number formatting.** `duration_seconds: 0` re-serialised by a language that decoded it as a float becomes `0.0`.
- **Trailing newline.** Some frameworks append one when they hand you the "body".

The rule: capture the bytes off the wire, verify those bytes, and only then parse them.

::: warning Verify before you parse, not after
Parsing untrusted input is itself an operation you are choosing to perform on data from an unauthenticated stranger. Order matters: read bytes, verify, parse. Never parse, verify, act.
:::

### Getting raw bytes in each framework

| Stack | How | Trap |
|---|---|---|
| Express | `express.raw({ type: "application/json" })` on the route | A global `app.use(express.json())` registered before the route consumes the stream first. `req.body` is then an object and the bytes are gone. |
| Fastify | `addContentTypeParser("application/json", { parseAs: "buffer" }, …)` or a raw-body plugin | The default JSON parser runs before your handler. |
| FastAPI / Starlette | `raw = await request.body()` | Declaring a Pydantic model or `dict` body parameter makes the framework parse it for you. Take `Request`. |
| Flask | `request.get_data()` | `request.get_json()` and `request.json` both parse. `get_data()` after a parse still returns the cached bytes, but do not rely on ordering — call it first. |
| Django | `request.body` | Only if nothing has read the stream. Accessing `request.POST` on a non-form request can consume it. |
| PHP | `file_get_contents("php://input")` | Readable once on some SAPI configurations. Read it into a variable at the top of the handler. |
| Laravel | `$request->getContent()` | Middleware that mutates the request can rebuild the body. Exclude the webhook route from anything that does. |
| Go | `io.ReadAll(r.Body)` | `json.NewDecoder(r.Body).Decode(...)` consumes it. Read to `[]byte` first, then `json.Unmarshal`. |
| Rails | `request.raw_post` | `params` is already parsed; do not rebuild the body from it. |
| Next.js (pages router) | `export const config = { api: { bodyParser: false } }`, then read the stream | Body parsing is on by default and silently produces a parsed object. |
| AWS API Gateway / Lambda | `event.body` | If `isBase64Encoded` is true you must base64-decode to get the original bytes. Binary media type settings on the gateway change this behaviour. |

## The timestamp tolerance

Reject the request if `t` differs from your current time by more than **300 seconds** in either direction.

The reason is replay. Without the timestamp check, an attacker who captures one valid delivery — from a log file, a misconfigured proxy, a screenshot in a support ticket — can POST that exact request, signature and all, forever. The signature stays valid because nothing about it expires. A `ticket.created` replayed a thousand times is a thousand tickets.

The timestamp is inside the signed string, so an attacker cannot advance it without breaking the HMAC. Five minutes of validity is what an attacker gets instead of eternity.

Check in **both** directions. A signature from ten minutes in the future is as suspicious as one from ten minutes in the past, and it usually means your own server clock is wrong.

::: note 300 seconds is generous on purpose
Every attempt — a first try, a retry, or a **Replay** from the console — is signed when it is sent, with a fresh timestamp. The window only has to cover the clock difference between the two machines and the time the request spends in transit. Five minutes is generous for that; do not tighten it to a few seconds.

If you widen it instead, understand what you are buying: a 24-hour tolerance is a 24-hour replay window.
:::

Run NTP on the machine that verifies. Clock drift is the failure mode that looks like a signature bug, and it is the one that appears out of nowhere on a host that worked yesterday.

## Compare in constant time

Use `hmac.compare_digest`, `crypto.timingSafeEqual`, `hash_equals`, or `hmac.Equal`. Never `==`.

This is not a theoretical concern that security checklists include out of habit. `==` on strings in every mainstream language short-circuits at the first differing byte, so comparing an attacker's guess against the real digest takes measurably longer when more leading characters are right. An attacker who can send requests and time the responses recovers the correct digest one hex character at a time — 64 characters, 16 guesses each, about a thousand requests. Your endpoint is on the public internet and answers as fast as you can make it. That is the whole attack.

Two details the constant-time function will not save you from:

- **Compare lengths first, carefully.** `crypto.timingSafeEqual` in Node *throws* if the two buffers differ in length. Check `a.length === b.length` before calling it, and return false rather than letting an exception become a 500 — a 500 is a retryable status, so a malformed signature would get sent to you four times.
- **Do not lowercase, trim, or normalise the received digest before comparing.** We emit lowercase hex. Anything else is not our signature.

## Complete verifiers

Each of these does the same five steps: parse the header, check the tolerance, recompute the HMAC over `t` + `.` + raw bytes, compare in constant time, and only then hand the bytes to a parser.

::: codegroup

```javascript Node.js
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.AMOMIC_WEBHOOK_SECRET; // "whsec_…", verbatim
const TOLERANCE_SECONDS = 300;

function parseSignatureHeader(header) {
  // "t=1754049600,v1=8f3a…" → { t: "1754049600", v1: "8f3a…" }
  const fields = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");          // first "=" only
    if (i > 0) fields[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  return fields;
}

export function verifyAmomicSignature(rawBody, header, secret = SECRET) {
  if (!header || !Buffer.isBuffer(rawBody)) return false;

  const fields = parseSignatureHeader(header);
  const rawTimestamp = fields.t;
  const received = fields.v1;
  if (!rawTimestamp || !received) return false;

  // 1. Tolerance. Parse for the comparison only; sign with the original string.
  const timestamp = Number.parseInt(rawTimestamp, 10);
  if (!Number.isFinite(timestamp)) return false;
  const skew = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (skew > TOLERANCE_SECONDS) return false;

  // 2. Recompute over "{t}." + the exact bytes received.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawTimestamp + ".")
    .update(rawBody)
    .digest("hex");

  // 3. Constant-time compare. timingSafeEqual throws on a length mismatch.
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

const app = express();

app.post("/hooks/amomic", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyAmomicSignature(req.body, req.get("Amomic-Signature"))) {
    return res.status(400).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  handleAsync(event);            // never awaited inline — ack fast
  return res.status(200).send("ok");
});
```

```python Python
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, Request, Response

SECRET = os.environ["AMOMIC_WEBHOOK_SECRET"]  # "whsec_…", verbatim
TOLERANCE_S = 300

app = FastAPI()


def _parse_header(header: str) -> dict[str, str]:
    fields: dict[str, str] = {}
    for piece in header.split(","):
        key, sep, value = piece.partition("=")   # first "=" only
        if sep:
            fields[key.strip()] = value.strip()
    return fields


def verify_amomic_signature(
    raw_body: bytes,
    header: str | None,
    secret: str = SECRET,
    tolerance_s: int = TOLERANCE_S,
) -> bool:
    if not header:
        return False

    fields = _parse_header(header)
    raw_timestamp = fields.get("t")
    received = fields.get("v1")
    if not raw_timestamp or not received:
        return False

    # 1. Tolerance, both directions.
    try:
        timestamp = int(raw_timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - timestamp) > tolerance_s:
        return False

    # 2. Recompute over "{t}." + the exact bytes received.
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # 3. Constant-time compare.
    return hmac.compare_digest(expected, received)


# Request, not a Pydantic model — a typed body parameter parses the JSON and the
# bytes you need are gone before your code runs.
@app.post("/hooks/amomic")
async def amomic_webhook(request: Request) -> Response:
    raw = await request.body()

    if not verify_amomic_signature(raw, request.headers.get("Amomic-Signature")):
        return Response(status_code=400)

    event = json.loads(raw)
    await enqueue(event["id"], event)
    return Response(status_code=200)
```

```php PHP
<?php

const AMOMIC_TOLERANCE_S = 300;

function amomic_verify_signature(
    string $rawBody,
    ?string $header,
    string $secret,
    int $toleranceS = AMOMIC_TOLERANCE_S
): bool {
    if ($header === null || $header === '') {
        return false;
    }

    // Parse "t=…,v1=…" splitting each piece on the first "=" only.
    $fields = [];
    foreach (explode(',', $header) as $piece) {
        $bits = explode('=', $piece, 2);
        if (count($bits) === 2) {
            $fields[trim($bits[0])] = trim($bits[1]);
        }
    }

    if (!isset($fields['t'], $fields['v1'])) {
        return false;
    }

    $rawTimestamp = $fields['t'];
    $received     = $fields['v1'];

    // 1. Tolerance, both directions.
    if (!ctype_digit($rawTimestamp)) {
        return false;
    }
    if (abs(time() - (int) $rawTimestamp) > $toleranceS) {
        return false;
    }

    // 2. Recompute over "{t}." + the exact bytes received.
    //    $secret is the whsec_… string as-is: no base64_decode, no prefix stripping.
    $expected = hash_hmac('sha256', $rawTimestamp . '.' . $rawBody, $secret);

    // 3. Constant-time compare.
    return hash_equals($expected, $received);
}

// --- handler ---------------------------------------------------------------

$rawBody = file_get_contents('php://input');           // read once, keep it
$header  = $_SERVER['HTTP_AMOMIC_SIGNATURE'] ?? null;  // Amomic-Signature
$secret  = getenv('AMOMIC_WEBHOOK_SECRET');

if (!amomic_verify_signature($rawBody, $header, $secret)) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);

enqueue($event['id'], $event);   // durable and fast

http_response_code(200);
echo 'ok';
```

```go Go
package amomic

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

const toleranceSeconds int64 = 300

// VerifySignature checks an Amomic-Signature header against the raw request body.
// secret is the whsec_… string exactly as issued.
func VerifySignature(rawBody []byte, header, secret string) bool {
	if header == "" {
		return false
	}

	var rawTimestamp, received string
	for _, piece := range strings.Split(header, ",") {
		key, value, found := strings.Cut(piece, "=") // first "=" only
		if !found {
			continue
		}
		switch strings.TrimSpace(key) {
		case "t":
			rawTimestamp = strings.TrimSpace(value)
		case "v1":
			received = strings.TrimSpace(value)
		}
	}
	if rawTimestamp == "" || received == "" {
		return false
	}

	// 1. Tolerance, both directions.
	timestamp, err := strconv.ParseInt(rawTimestamp, 10, 64)
	if err != nil {
		return false
	}
	skew := time.Now().Unix() - timestamp
	if skew < 0 {
		skew = -skew
	}
	if skew > toleranceSeconds {
		return false
	}

	// 2. Recompute over "{t}." + the exact bytes received.
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(rawTimestamp))
	mac.Write([]byte("."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	// 3. Constant-time compare.
	return hmac.Equal([]byte(expected), []byte(received))
}

// --- handler ---------------------------------------------------------------

func Handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Read the body to []byte first. A json.Decoder on r.Body consumes it.
		rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		if !VerifySignature(rawBody, r.Header.Get("Amomic-Signature"), secret) {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		var event struct {
			ID   string          `json:"id"`
			Type string          `json:"type"`
			Data json.RawMessage `json:"data"`
		}
		if err := json.Unmarshal(rawBody, &event); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		if err := Enqueue(event.ID, rawBody); err != nil {
			w.WriteHeader(http.StatusInternalServerError) // retryable
			return
		}

		w.WriteHeader(http.StatusOK)
	}
}
```

:::

## Which status code to return when verification fails

Return **`400`**.

A `400` stops the retry ladder immediately — any 4xx other than `429` tells us the request itself is wrong and repeating it will not help. That is exactly right for a signature failure: the same bytes will fail the same way four times, and burning four attempts on it only delays the events queued behind it.

Do **not** return `500` for a bad signature. A `500` is retryable, so a stream of forged requests would turn into four times as many forged requests, and genuine failures would take a minute longer to surface.

Do not return `200` either — you lose the ability to see the problem in the delivery log, and the console will show a healthy endpoint that is silently discarding everything.

## Troubleshooting: "signature does not match"

In descending order of how often each one is the actual cause.

::: details 1. You verified a re-serialised body
By far the most common. Your framework handed you a parsed object, you called `JSON.stringify` / `json.dumps` / `json_encode` on it, and hashed the result.

The body we sign has no spaces after `:` or `,`. Almost every standard serialiser adds them. Unicode escaping, key order, and float formatting can all differ too.

**Check:** log `rawBody.length` (or `len(raw_body)`) alongside the `Content-Length` header we sent. If they differ, you are not looking at our bytes. Log the first 80 characters of what you are hashing — if you see `{"id": "evt_` with a space after the colon, that is the bug.

**Fix:** capture bytes before anything parses them. See the framework table above.
:::

::: details 2. A body-parser middleware ran first
Same outcome as (1) but harder to spot, because your code never explicitly re-serialises anything — the framework did it for you and handed you an object that looks like the truth.

The classic is `app.use(express.json())` at the top of an Express app. By the time your route's `express.raw` runs, the stream has been drained; `req.body` is an object, `Buffer.isBuffer(req.body)` is false, and hashing it produces nonsense. Django's `request.POST`, Laravel middleware that rebuilds the request, and Next.js's default `bodyParser` all do the same thing in their own dialect.

**Check:** assert the type of what you received. In Node, `Buffer.isBuffer(req.body)` must be `true`. In Python, `type(raw)` must be `bytes`.

**Fix:** exclude the webhook route from global body parsing, or mount the raw parser before the JSON one.
:::

::: details 3. Wrong secret
Every endpoint has its own signing secret. If you have a dev endpoint and a production endpoint, or you deleted and recreated an endpoint while debugging, the secret in your environment may belong to a different one.

Also check that you have not transformed it: no base64 decode, no stripping of the `whsec_` prefix, no trailing newline picked up from a file, no shell quoting that swallowed a character.

**Check:** print `len(secret)` and its first eight characters. It must start `whsec_`. Compare against the endpoint you are actually receiving from — the delivery log in the console tells you which endpoint got the request.

**Fix:** the secret is shown once and cannot be recovered. If you no longer have it, add a new endpoint for the same URL, switch your receiver to its secret, then delete the old one. There is no secret rotation.
:::

::: details 4. Clock skew
Your HMAC is right and you are rejecting on the tolerance check instead. This one appears suddenly on a host that was fine yesterday, and it affects every request equally.

**Check:** log the computed skew, not just the boolean result — `int(time.time()) - timestamp`. If it is a consistent number of seconds (or a suspiciously round number of hours, which means a timezone bug rather than drift), that is your answer. Distinguishing this from a real HMAC mismatch takes one log line and is worth adding permanently.

**Fix:** run NTP. If you are inside a container on a host with correct time, check that the container is not frozen or suspended between deliveries.
:::

::: details 5. Wrong hash input
Ordinary mistakes in constructing the signed string:

- Hashing the body alone, without `{t}.` in front.
- Hashing the whole header value instead of just the `v1` portion's input.
- Using `.` where you meant to join with something else, or forgetting the separator entirely.
- Base64-encoding the digest instead of hex-encoding it. We send lowercase hex.
- Using SHA-1 or SHA-512.
- Uppercasing the hex digest before comparing. Ours is lowercase.

**Check:** capture one real request in your own logs — the raw body bytes and the `Amomic-Signature` header; a **Test** delivery is fine — and unit-test your verifier against it. The console's **Payload** view shows the event, but not the exact bytes or the signature, so it can't stand in for this. A test that pins one real body, timestamp and signature catches all of these in one run and keeps catching them.
:::

::: details 6. A proxy rewrote the body
Rarer, but real, and maddening because your code is correct.

- **AWS API Gateway** may hand your Lambda a base64-encoded body. Check `isBase64Encoded` and decode before verifying.
- **Gateways that re-encode or pretty-print JSON** change the bytes. Anything that "normalises" a payload in transit invalidates the signature.
- **Unescaping.** The body is ASCII with `\u` escapes. A proxy or middleware that decodes those escapes into UTF-8 changes the bytes, so events with a Hindi name or an emoji fail while the rest verify. That pattern — some events verify, some do not — points straight here.
- **Compression or chunked-transfer handling** that appends a newline.

**Check:** if events with only English text pass and ones with other scripts or emoji fail, something in the path is rewriting the body, not your HMAC.
:::

## Rotation, and multiple `v1` values

A delivery today carries exactly one `v1` value. If you are writing a verifier from scratch, accepting a header that contains several `v1=` pairs and returning true if **any** of them matches costs you three lines and does no harm. Do not write a verifier that *requires* more than one.

Note that a naive parse-into-a-map implementation, like the ones above, keeps only the last `v1`. That is fine today.

There is no secret-rotation endpoint. To change a signing secret you delete the endpoint and create a new one, which means a gap unless you add the new endpoint first, run both in parallel while your receiver accepts either secret, and then remove the old one.

## Next

::: cards

::: card What arrives in `data`
[Event reference](/webhooks/events) — the exact field list for each of the twenty event types.
:::

::: card When verification is not the problem
[Retries and failures](/webhooks/reliability) — reading the delivery log, and why an endpoint got disabled.
:::

::: card The delivery contract
[Getting events](/webhooks/overview) — headers, envelope, and the rules your endpoint has to follow.
:::

::: card Securing the rest of it
[Security and data](/operate/security) — key handling, retention, and what we store.
:::

:::
