Your webhook endpoint is a URL on the public internet that accepts a POST and does something with it. Without verification, anyone who learns the URL can tell your system a flight landed. With a verifier that is subtly wrong, they still can, and you will not find out until it matters.
Most wrong verifiers are wrong in one of three ways: they check a body that is not the one that was signed, they compare with ===, or they ignore the timestamp. This guide is the scheme, the three mistakes with the fix in each framework, and the operational bits around secrets.
The scheme
Every FlightByte delivery carries this header:
x-flightbyte-signature: t=1789305971,v1=5f1c9a…e3b2t is the Unix time, in seconds, when the delivery was signed. v1 is:
v1 = hex( HMAC-SHA256( secret, "<t>" + "." + <raw request body> ) )The secret is the whsec_… string returned when the subscription was created (or the one you supplied). It is 24 random bytes, base64url-encoded, behind the prefix.
Three design choices, each with a reason:
- The timestamp is inside the signed string. An attacker who captures one valid delivery cannot change
tto make it look fresh; a newtneeds a new signature, which needs the secret. - The body is signed as bytes, not as JSON. JSON has many valid spellings of the same object; bytes have one.
v1=is a version. If the scheme ever changes, deliveries can carryv1=andv2=side by side while receivers migrate, the way Stripe did.
The other five headers (x-flightbyte-event-id, -event-type, -subscription-id, -delivery-attempt, -timestamp) are conveniences. Read them after verifying, never as a reason to skip it: anything in a request from the internet is attacker-controlled until the signature says otherwise.
Mistake one: verifying the wrong bytes
The signature covers the exact bytes FlightByte sent. If your framework parsed the JSON and you re-serialise the object, you get different bytes (key order, whitespace, number formatting, unicode escapes) and every delivery fails verification. It looks identical to a wrong secret from the outside, which is why it costs people an afternoon.
You need the raw body. Every framework can give it to you; each hides it somewhere different.
// Mount the raw parser on this route only; keep express.json() for the rest.
app.post("/hooks/flights", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body as Buffer; // bytes, not an object
if (!verify(process.env.FLIGHTBYTE_WEBHOOK_SECRET!, req.header("x-flightbyte-signature") ?? "", raw)) {
return res.status(401).end();
}
const event = JSON.parse(raw.toString("utf8"));
res.status(204).end();
// ...
});The rule underneath all seven: get the bytes first, verify, and only then parse.
Mistake two: comparing with ===
A string comparison stops at the first byte that differs. That means a wrong signature whose first byte is right takes a fraction longer to reject than one whose first byte is wrong, and with enough requests the difference is measurable across a network. Byte by byte, an attacker can walk towards a valid signature without the secret.
Whether that attack is practical against your endpoint is beside the point; the fix is one function call.
- Node.js:
crypto.timingSafeEqual(a, b). It throws if the lengths differ, so checka.length === b.lengthfirst. Length is not secret. - Python:
hmac.compare_digest(a, b). - Go:
hmac.Equal(a, b).
Compare like with like. Either decode both hex strings to bytes and compare the bytes, or compare the two lower-case hex strings; do not compare bytes on one side with a string on the other.
Mistake three: ignoring t
A verifier that checks only v1 accepts a delivery it already accepted last week. That is a replay: capture one valid "flight landed" and send it again at a moment of your choosing. The timestamp inside the signed string is the defence, and it only works if you check it.
Reject deliveries whose t is more than a few minutes from your clock, in either direction. FlightByte's own reference verifier uses five minutes; that absorbs clock skew between two servers on NTP and nothing more. Check it before computing the HMAC; there is no reason to spend the hash on a stale request.
Two things the window does not do:
- It does not replace deduplication. Within the window a replay is still valid. Retries are also replays with the same
idand body, on purpose. Store processed event ids and treat a repeat as already done. - It does not forgive a wrong clock. A receiver whose clock drifts ten minutes will reject every delivery with a
401, and FlightByte will log that as your endpoint refusing the event. Keep NTP running.
The reference verifier
All three checks in one function. Verify against the raw bytes, check the window, compute, compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string, rawBody: Buffer, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}These are the same functions as in the Webhooks reference and in the example receivers, where they run against a signed test sender.
Secrets: one or many, and rotating them
By default every subscription gets its own secret, returned in full exactly once, in the 201. After that the API shows a hint (whsec_Y0Cp2…MA0u); GET /v1/subscriptions/{id}/secret reveals it again to a caller with the API key.
One secret per subscription is the safer default and the more annoying one: a receiver that serves many bookings has to look the secret up by subscription id before it can verify. The alternative is to supply your own with secret at creation, so every subscription is signed with the same key and the receiver reads one environment variable. Both are fine. Choose the shared one when one service handles all your flights; choose per-subscription when different teams or tenants receive different flights.
Rotating. POST /v1/subscriptions/{id}/secret/rotate replaces the secret and returns the new one. From that moment deliveries are signed with the new key; a receiver still holding the old one answers 401, and FlightByte retries with backoff (four seconds, doubling, eight attempts) for roughly eight minutes before giving the event up. To rotate without that window, let the verifier accept a list:
const SECRETS = (process.env.FLIGHTBYTE_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
const ok = SECRETS.some((secret) => verify(secret, header, raw));Add the new secret to the list, deploy, rotate, then remove the old one on the next deploy. Nothing is dropped in either direction.
Where the secret lives
An environment variable or a secrets manager, never the repository and never a log line. The signature header is safe to log; the secret is not, and a verifier that logs "expected X got Y" has just written the secret's fingerprint to disk.
A second lock on the door
Signatures prove the sender. Two cheap things make the endpoint harder to reach in the first place.
- Your own header. A subscription's
headersfield is sent with every delivery. Put a bearer token in it and reject requests that lack it before you even read the body. It is not a substitute for the signature, but it stops random scanners at the door. - HTTPS. Use it. The signature does not encrypt anything; the flight, the gate and your metadata travel in the clear otherwise.
On its side, FlightByte refuses webhook URLs that resolve to private, loopback or link-local addresses, so a subscription cannot be pointed at something inside our network. The same idea applies to yours: an endpoint that takes a URL from a user should never fetch it blindly.
Testing the verifier
A verifier you have only tested with valid input is untested.
- The example receivers repo has a sender that signs a fixture the way FlightByte does.
--tamperflips a byte after signing,--stalesigns with a ten-minute-old timestamp, and a wrong secret in the environment covers the third case. All three must answer401; the plain send must answer204. POST /v1/subscriptions/{id}/testsends asubscription.testevent through the real pipeline with the real secret.GET /v1/subscriptions/{id}/deliveriesshows what your endpoint answered, so a401from a wrong clock is visible from our side too.
The checklist
- Read the raw body bytes before anything parses them.
- Parse the header into
tandv1; reject if either is missing. - Reject if
|now − t|is more than five minutes. - Compute
HMAC-SHA256(secret, t + "." + raw)as lower-case hex. - Compare lengths, then compare in constant time.
- Only now parse the JSON and read the other headers.
- Look the event id up; if you have seen it, answer
204and stop. - Answer
204, then do the work.