Signature verification
Verify that every webhook delivery came from Rehabify and wasn't altered, using HMAC-SHA256 over the timestamp and raw body. Includes samples for Node.js, Python, PHP and Go.
Every delivery is signed with your endpoint's signing secret: whsec_ followed by 40 alphanumeric characters. Each environment has its own secret, and you can reveal it in the Partner Portal under Developers → Webhooks. Always verify the signature before you trust a payload.
The signature header
X-Rehabify-Signature: t=1791884113,v1=94cb1b4033aa43ffa747a13629a033766fe033d8f82f8c17664ef1bf075dae4c| Part | Meaning |
|---|---|
t | When the delivery was signed, in Unix seconds. It's also sent as X-Rehabify-Timestamp. |
v1 | Hex-encoded HMAC-SHA256 signature. |
Algorithm
v1 = hex( HMAC_SHA256( key = signing secret, message = "<t>.<raw request body>" ) )- Read the raw request body as bytes, exactly as received. Don't parse and re-serialize the JSON: any change in whitespace or key order breaks the signature.
- Split
X-Rehabify-Signatureon,, and each part on the first=, to gettandv1. - Reject the delivery if
tis more than 300 seconds away from your current time, in either direction. This blocks replayed requests. - Build the signed message: the value of
t, a literal., then the raw body. - Compute HMAC-SHA256 of that message, using the full signing secret (including
whsec_) as the key, and hex-encode the result. - Compare your result with
v1in constant time. If they don't match, reject the delivery with a4xxstatus.
Use the secret of the environment the event came from. Sandbox events are signed with your sandbox secret, and live events with your live secret. Each delivery attempt, including retries, is signed when it's sent, so t is always recent.
Test vector
Use these values to unit-test your implementation. The body is shortened for readability, and the secret is an example, not a real one.
| Input | Value |
|---|---|
| Secret | whsec_ExampleSecretForDocsOnly0000000000000000 |
t | 1791884113 |
| Raw body | {"id":"evt_01JA4T7Q9Y2V3W4X5Z6A7B8C9D","object":"event","type":"ping"} |
| Signed message | 1791884113.{"id":"evt_01JA4T7Q9Y2V3W4X5Z6A7B8C9D","object":"event","type":"ping"} |
Expected v1 | 94cb1b4033aa43ffa747a13629a033766fe033d8f82f8c17664ef1bf075dae4c |
When you test with this vector, skip the 300-second timestamp check, because t is in the past.
Code samples
// webhook-server.mjs (Node.js 18+). Install with: npm install express
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.REHABIFY_WEBHOOK_SECRET; // whsec_… for this environment
const TOLERANCE_SECONDS = 300;
export function verifyRehabifySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
if (!header || !Buffer.isBuffer(rawBody)) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") timestamp = value;
else if (key === "v1") signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest(); // 32-byte Buffer
return signatures.some((sig) => {
const received = Buffer.from(sig, "hex");
// timingSafeEqual throws a RangeError if the lengths differ, so check them first.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
// express.raw() keeps the exact bytes that were signed.
// Register this route BEFORE any app.use(express.json()), or the raw body is lost.
app.post("/webhooks/rehabify", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyRehabifySignature(req.body, req.get("X-Rehabify-Signature"), SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// 1. Skip events whose id you've already processed.
// 2. Queue the work, and ignore stale events using data.sequence.
console.log("Verified", event.id, event.type);
res.sendStatus(200); // respond within 10 seconds
});
app.listen(3000);Common mistakes
| Symptom | Likely cause |
|---|---|
| Every signature fails | The body was parsed and re-serialized before hashing. Hash the raw bytes. In Express, use express.raw() on this route, before express.json(). |
| Sandbox works, live fails (or the other way round) | You're using the wrong environment's secret. Sandbox and live have different secrets. |
| Fails after rotating the secret | Your server still has the old secret. Deploy the new one, or temporarily accept either. |
| Intermittent timestamp failures | Your server clock is off. Sync it with NTP. The tolerance is 300 seconds. |
RangeError in Node.js | You called crypto.timingSafeEqual with buffers of different lengths. Check the lengths first, as the sample does. |
Signature compared with === or == | Use a constant-time comparison (timingSafeEqual, hmac.compare_digest, hash_equals or hmac.Equal). |