Skip to content

Verify the signature

Your endpoint is a public address: anyone can send a POST to it. The signature is what proves that the delivery came from Faturei Hoje, with your endpoint’s secret, and that the body was not changed on the way. Check the signature on every delivery, before using the body.

The signature follows the Standard Webhooks spec, so that spec’s libraries work too. The examples below use only each language’s standard library.

  1. Read the webhook-id, webhook-timestamp and webhook-signature headers.

  2. Reject it if webhook-timestamp is more than 5 minutes away from your clock, in either direction. This is what stops someone from keeping a genuine delivery and sending it again later.

  3. Build the signed content: webhook-id, a dot, webhook-timestamp, a dot, and the request’s raw body.

    evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f...
  4. Strip the whsec_ prefix from the secret and base64-decode the rest. Those bytes are the key.

  5. Compute the HMAC-SHA256 of the content with the key and base64-encode the result.

  6. The webhook-signature header carries one or more space-separated signatures, each in the v1,<base64> format. Accept the delivery if any of them equals the one you computed, comparing in constant time.

Signing the JSON instead of the raw body. The signed content is the exact text that arrived, byte for byte. If your framework has already parsed the body into an object and you turn it back into text, whitespace, field order and accents may change, and the signature no longer matches. Read the raw body before any parsing.

Comparing with ==. A regular comparison stops at the first different character, and the time it takes tells an attacker how many characters they got right. Use the language’s constant-time comparison: crypto.timingSafeEqual in Node.js, hash_equals in PHP, hmac.compare_digest in Python.

Expecting a single signature. During a secret rotation the header carries two. Split on spaces and check each one.

Each example is a complete server with no dependencies: it receives the POST, checks the signature, answers 400 when it does not match and 200 when it does. The secret comes from the FH_WEBHOOK_SECRET environment variable.

webhook-receiver.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.FH_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody) // the RAW body, as received
.digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verifyWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
// Store the event and process it later. Answer right away.
console.log(event.type, event.id);
res.writeHead(200).end();
});
}).listen(Number(process.env.PORT ?? 3000));

Run it with node webhook-receiver.mjs. In Express, use express.raw({ type: 'application/json' }) on the webhook route, so req.body arrives as the raw Buffer, and pass req.body to verifyWebhook.

All three examples were run against deliveries signed by the very code that sends the webhooks: they accept a genuine delivery, with one or two signatures, and reject a changed body, a changed signature, a wrong secret and an old timestamp.

POST /public/v1/webhook_endpoints/{id}/rotate_secret generates a new secret and returns it in secret. Through the API it only appears in that response, as on creation; in the dashboard, the owner and admins can reveal it again by confirming their password.

For 24 hours after the rotation, each delivery goes out with two signatures in webhook-signature, separated by a space: the new secret’s first, then the previous one’s. That way your server keeps accepting deliveries while you swap the secret, whichever one it still uses. After 24 hours, only the new secret signs.

webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,3oZaGW1LqbpVYbfVQ5Yz8XJHsd3Wj/2f0sK7QpTnC1U=

Step by step:

  1. Rotate and store the new secret.
  2. Swap the secret on your server.
  3. Confirm in the delivery log that deliveries keep succeeding.

Rotating again within the 24 hours discards the oldest secret: at most two coexist, the current one and the one right before it. If you rotated twice in a row, your server must be on one of the last two.

Rotate when someone who knew the secret leaves the team or the vendor, or when the secret went through a place you do not control (a ticket, a chat, a log).