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.