Verifying webhook signatures
Every webhook request carries X-Webhook-Signature: t=<unix seconds>,v1=<hex>. Check it before you trust a request: anyone can send a POST to your endpoint.
How the signature works
tis when we sent this attempt, as Unix time in seconds. Each retry is signed again with a newt.v1is the HMAC-SHA256 of the string<t>.<raw request body>, keyed with the endpoint's signing secret (the wholewhsec_...string, as UTF-8), written in lowercase hex.
To verify a request:
- Read the raw request body exactly as received, before parsing the JSON. JSON that you parse and serialise again won't match.
- Split the header on
,and=to gettandv1. - Reject the request if
tis more than 5 minutes (300 seconds) away from your clock, so old requests can't be replayed. Keep your server clock in sync. - Compute the HMAC-SHA256 of
t, a dot and the raw body with your secret, and compare it withv1in constant time. - If it doesn't match, answer
400and ignore the request.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON parsing)
// now: Unix time in seconds, optional; pass 1762653600 to check the sample request in the docs
function isValidWebhook(secret, signatureHeader, rawBody, now = Date.now() / 1000) {
const parts = new Map(
signatureHeader.split(",").map((part) => {
const index = part.indexOf("=");
return index < 0 ? [part.trim(), ""] : [part.slice(0, index).trim(), part.slice(index + 1)];
}),
);
const rawTimestamp = parts.get("t") ?? "";
const given = parts.get("v1") ?? "";
const timestamp = Number(rawTimestamp);
if (!/^[0-9]+$/.test(rawTimestamp) || !Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;
if (!/^[0-9a-f]{64}$/.test(given)) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return timingSafeEqual(Buffer.from(given, "hex"), expected);
}
rawBody is the body as a string, exactly as received.
Python
import hashlib
import hmac
import re
import time
# raw_body: the request body exactly as received (bytes, before JSON parsing)
# now: Unix time in seconds, optional; pass 1762653600 to check the sample request in the docs
def is_valid_webhook(secret, signature_header, raw_body, now=None):
if now is None:
now = time.time()
parts = {}
for part in signature_header.split(","):
key, _, value = part.partition("=")
parts[key.strip()] = value
timestamp = parts.get("t", "")
given = parts.get("v1", "")
if not re.fullmatch(r"[0-9]{1,16}", timestamp) or abs(now - int(timestamp)) > 300:
return False
if not re.fullmatch(r"[0-9a-f]{64}", given):
return False
signed = str(int(timestamp)).encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
raw_body is the body as bytes, exactly as received.
Complete receivers
These minimal servers verify the signature, answer at once and skip duplicates. They read the secret from FULFILLBOT_WEBHOOK_SECRET. With a framework, make sure you get the raw body: for example express.raw({ type: "application/json" }) in Express, or request.get_data() in Flask.
Node.js receiver
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.FULFILLBOT_WEBHOOK_SECRET;
// Keep processed event ids in your database in production; a Set is lost on restart.
const seenEventIds = new Set();
function isValidWebhook(secret, signatureHeader, rawBody, now = Date.now() / 1000) {
const parts = new Map(
signatureHeader.split(",").map((part) => {
const index = part.indexOf("=");
return index < 0 ? [part.trim(), ""] : [part.slice(0, index).trim(), part.slice(index + 1)];
}),
);
const rawTimestamp = parts.get("t") ?? "";
const given = parts.get("v1") ?? "";
const timestamp = Number(rawTimestamp);
if (!/^[0-9]+$/.test(rawTimestamp) || !Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;
if (!/^[0-9a-f]{64}$/.test(given)) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return timingSafeEqual(Buffer.from(given, "hex"), expected);
}
// Set a body limit: reject oversized requests before they use your memory.
const MAX_BODY_BYTES = 1_000_000;
createServer((request, response) => {
const chunks = [];
let size = 0;
request.on("data", (chunk) => {
size += chunk.length;
if (size <= MAX_BODY_BYTES) chunks.push(chunk);
});
request.on("end", () => {
if (size > MAX_BODY_BYTES) {
response.writeHead(413).end();
return;
}
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!isValidWebhook(SECRET, request.headers["x-webhook-signature"] ?? "", rawBody)) {
response.writeHead(400).end();
return;
}
let event;
try {
event = JSON.parse(rawBody);
} catch {
response.writeHead(400).end();
return;
}
// Answer within the timeout first; do slow work (such as GET calls) after answering or in a queue.
response.writeHead(200).end();
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
console.log("Received", event.type, event.data.id, event.data.status);
});
}).listen(8000);
Python receiver
import hashlib
import hmac
import re
import time
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["FULFILLBOT_WEBHOOK_SECRET"]
# Keep processed event ids in your database in production; a set is lost on restart.
seen_event_ids = set()
# raw_body: the request body exactly as received (bytes, before JSON parsing)
# now: Unix time in seconds, optional; pass 1762653600 to check the sample request in the docs
def is_valid_webhook(secret, signature_header, raw_body, now=None):
if now is None:
now = time.time()
parts = {}
for part in signature_header.split(","):
key, _, value = part.partition("=")
parts[key.strip()] = value
timestamp = parts.get("t", "")
given = parts.get("v1", "")
if not re.fullmatch(r"[0-9]{1,16}", timestamp) or abs(now - int(timestamp)) > 300:
return False
if not re.fullmatch(r"[0-9a-f]{64}", given):
return False
signed = str(int(timestamp)).encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
# Set a body limit: reject oversized requests before reading them.
MAX_BODY_BYTES = 1_000_000
class WebhookHandler(BaseHTTPRequestHandler):
def reply(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
self.end_headers()
def do_POST(self):
length_header = self.headers.get("Content-Length", "")
if not re.fullmatch(r"[0-9]{1,12}", length_header):
self.reply(400)
return
length = int(length_header)
if length > MAX_BODY_BYTES:
self.reply(413)
return
raw_body = self.rfile.read(length)
if not is_valid_webhook(SECRET, self.headers.get("X-Webhook-Signature", ""), raw_body):
self.reply(400)
return
try:
event = json.loads(raw_body)
except ValueError:
self.reply(400)
return
# Answer within the timeout first; do slow work after answering or in a queue.
self.reply(200)
if event["id"] in seen_event_ids:
return
seen_event_ids.add(event["id"])
print("Received", event["type"], event["data"]["id"], event["data"]["status"])
HTTPServer(("", 8000), WebhookHandler).serve_forever()
Rotating the secret
Create a new secret on the endpoint's page replaces the secret at once; requests signed with the old one stop at that moment. Update your receiver with the new secret straight after. Attempts that fail in between are retried (Webhook delivery and retries) and are signed with the new secret.