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.
The recipe
Section titled “The recipe”-
Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. -
Reject it if
webhook-timestampis 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. -
Build the signed content:
webhook-id, a dot,webhook-timestamp, a dot, and the request’s raw body.evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f... -
Strip the
whsec_prefix from the secret and base64-decode the rest. Those bytes are the key. -
Compute the HMAC-SHA256 of the content with the key and base64-encode the result.
-
The
webhook-signatureheader carries one or more space-separated signatures, each in thev1,<base64>format. Accept the delivery if any of them equals the one you computed, comparing in constant time.
The three most common mistakes
Section titled “The three most common mistakes”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.
The code
Section titled “The code”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.
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.
<?php
const TOLERANCE_SECONDS = 300;
function verifyWebhook(string $secret, array $headers, string $rawBody, ?int $now = null): bool{ $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { return false; } if (!ctype_digit($timestamp)) { return false; } if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) { return false; }
$key = base64_decode(substr($secret, strlen('whsec_')), true); if ($key === false) { return false; } // The RAW body, as received. $expected = hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true);
foreach (explode(' ', $signatures) as $entry) { [$version, $value] = array_pad(explode(',', $entry, 2), 2, ''); if ($version !== 'v1' || $value === '') { continue; } $received = base64_decode($value, true); if ($received !== false && hash_equals($expected, $received)) { return true; } } return false;}
$headers = array_change_key_case(getallheaders(), CASE_LOWER);$rawBody = file_get_contents('php://input');
if (!verifyWebhook(getenv('FH_WEBHOOK_SECRET'), $headers, $rawBody)) { http_response_code(400); exit;}
$event = json_decode($rawBody, true);// Store the event and process it later. Answer right away.error_log($event['type'] . ' ' . $event['id']);http_response_code(200);To try it, php -S 0.0.0.0:3000 webhook-receiver.php. In Laravel, pass $request->getContent() as the raw body and build the array with the three headers read by $request->header('webhook-id'), $request->header('webhook-timestamp') and $request->header('webhook-signature').
import base64import hashlibimport hmacimport jsonimport osimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["FH_WEBHOOK_SECRET"] # whsec_...TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret, headers, raw_body, now=None): msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: return False if not (timestamp.isascii() and timestamp.isdigit()): return False now = time.time() if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) # The RAW body, as received. signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = hmac.new(key, signed, hashlib.sha256).digest()
for entry in signatures.split(" "): version, _, value = entry.partition(",") if version != "v1" or not value: continue try: received = base64.b64decode(value, validate=True) except ValueError: continue if hmac.compare_digest(received, expected): return True return False
class WebhookHandler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get("Content-Length", 0)) raw_body = self.rfile.read(length) if not verify_webhook(SECRET, self.headers, raw_body): self.send_response(400) self.end_headers() return event = json.loads(raw_body) # Store the event and process it later. Answer right away. print(event["type"], event["id"]) self.send_response(200) self.end_headers()
if __name__ == "__main__": port = int(os.environ.get("PORT", "3000")) HTTPServer(("", port), WebhookHandler).serve_forever()Run it with python3 webhook_receiver.py (Python 3.9 or newer). In Flask, use request.get_data() as the raw body; in Django, request.body.
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.
Rotate the secret
Section titled “Rotate the secret”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:
- Rotate and store the new secret.
- Swap the secret on your server.
- 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).
Next step
Section titled “Next step”- Deliveries and retries: what your response means to Faturei Hoje.
- Best practices: answer fast and ignore duplicates by
webhook-id.