Verifying signatures
Verify the HMAC signature on every delivery before trusting the body.
Every delivery is signed with your webhook's secret, shown once when the webhook is created (or rotated). Verify before processing: recompute the HMAC over the exact raw bytes you received and compare in constant time. Re-serializing parsed JSON will break the signature.
The X-ROASForm-Signature header has the form:
t=1753712000,v1=5f8a3c…where t is a Unix timestamp (seconds) and v1 is HMAC-SHA256(secret, "<t>.<rawBody>") hex-encoded. Rejecting old timestamps blunts replay attacks; 5 minutes is a reasonable tolerance.
import crypto from "crypto";
// rawBody MUST be the exact bytes received (do not re-serialize parsed JSON).
export function verifyRoasformWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=")),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Optional replay window.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Rotating a secret
Rotate from the webhook editor in the dashboard. The new secret is shown once; update your endpoint immediately, since deliveries sign with the new secret from that point on.
Endpoint requirements
Endpoints must be public HTTPS URLs. Private and internal addresses (localhost, internal IP ranges) are rejected, and redirects are never followed.
