Skip to content

Verify a webhook signature

Every webhook delivery from RolesAPI carries a signature so your endpoint can prove the request came from us and was not replayed. This recipe gives you drop-in verifiers for Node and Python.

The header looks like:

x-rolesapi-signature: t=1751362361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex, where secret is the whsec_... value returned once when you created the webhook (reference).

Node

const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, signatureHeader, secret) {
// rawBody must be the exact bytes received -- use express.raw() or
// equivalent, never JSON.stringify(req.body).
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject stale or future-dated timestamps.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express usage:
// app.post("/hooks/rolesapi", express.raw({ type: "application/json" }), (req, res) => {
// const ok = verifySignature(req.body, req.get("x-rolesapi-signature"), process.env.ROLESAPI_WEBHOOK_SECRET);
// if (!ok) return res.status(400).send("bad signature");
// const event = JSON.parse(req.body);
// res.sendStatus(200);
// });

Python

import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""raw_body must be the exact request bytes, before JSON parsing."""
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask usage:
# @app.post("/hooks/rolesapi")
# def hook():
# ok = verify_signature(
# request.get_data(), # raw bytes
# request.headers.get("x-rolesapi-signature", ""),
# os.environ["ROLESAPI_WEBHOOK_SECRET"],
# )
# if not ok:
# abort(400)
# event = request.get_json()
# return "", 200

Why the 300-second tolerance on t?

The t timestamp is part of what gets signed, so an attacker cannot change it without breaking the signature. Rejecting deliveries where t is more than 300 seconds from your clock means a captured request cannot be replayed later. After five minutes it is dead, even with a valid signature. Five minutes is generous enough to absorb ordinary clock drift and retry delay; if you see valid deliveries failing the window, check your server’s clock synchronization before widening it.

Why a timing-safe compare?

A plain === or == string comparison returns as soon as the first character differs, so how long it takes leaks how much of an attacker’s guess was correct. With enough tries, that leak can be walked into a full forgery. crypto.timingSafeEqual and hmac.compare_digest take the same time whether the guess is wrong at the first byte or the last, closing that channel. Always use them for signature checks.

Common mistakes

  • Re-serializing the body. Sign checks must use the raw received bytes. Parsing to JSON and re-stringifying can reorder keys or change whitespace, and the HMAC will not match.
  • Comparing against the whole header. Parse out v1; the header also contains t=.
  • Returning 200 before verifying. Verify first, then acknowledge, then process, ideally asynchronously, so slow processing does not cause a retry and a duplicate.
RolesAPI is not affiliated with, endorsed by, or sponsored by Indeed, Inc. Indeed is a registered trademark of Indeed, Inc. RolesAPI provides access to publicly available data via a stable REST API.