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.
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.
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_abc123andabc123are 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:
hmac.new(secret.encode("utf-8"), # "whsec_…", verbatimf"{timestamp}.".encode("utf-8") + raw_body, # raw_body is byteshashlib.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: 0re-serialised by a language that decoded it as a float becomes0.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.
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.
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.timingSafeEqualin Node throws if the two buffers differ in length. Checka.length === b.lengthbefore 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.
import crypto from "node:crypto";import express from "express";const SECRET = process.env.AMOMIC_WEBHOOK_SECRET; // "whsec_…", verbatimconst 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 "=" onlyif (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 fastreturn res.status(200).send("ok");});
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.
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 #
What arrives in `data`
Event reference — the exact field list for each of the twenty event types.
When verification is not the problem
Retries and failures — reading the delivery log, and why an endpoint got disabled.
The delivery contract
Getting events — headers, envelope, and the rules your endpoint has to follow.
Securing the rest of it
Security and data — key handling, retention, and what we store.