FulfillbotDocs
Menu
MarkdownDownload

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

  • t is when we sent this attempt, as Unix time in seconds. Each retry is signed again with a new t.
  • v1 is the HMAC-SHA256 of the string <t>.<raw request body>, keyed with the endpoint's signing secret (the whole whsec_... string, as UTF-8), written in lowercase hex.

To verify a request:

  1. Read the raw request body exactly as received, before parsing the JSON. JSON that you parse and serialise again won't match.
  2. Split the header on , and = to get t and v1.
  3. Reject the request if t is more than 5 minutes (300 seconds) away from your clock, so old requests can't be replayed. Keep your server clock in sync.
  4. Compute the HMAC-SHA256 of t, a dot and the raw body with your secret, and compare it with v1 in constant time.
  5. If it doesn't match, answer 400 and 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.