Docs/Webhooks

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
PartMeaning
tWhen the delivery was signed, in Unix seconds. It's also sent as X-Rehabify-Timestamp.
v1Hex-encoded HMAC-SHA256 signature.

Algorithm

v1 = hex( HMAC_SHA256( key = signing secret, message = "<t>.<raw request body>" ) )
  1. 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.
  2. Split X-Rehabify-Signature on ,, and each part on the first =, to get t and v1.
  3. Reject the delivery if t is more than 300 seconds away from your current time, in either direction. This blocks replayed requests.
  4. Build the signed message: the value of t, a literal ., then the raw body.
  5. Compute HMAC-SHA256 of that message, using the full signing secret (including whsec_) as the key, and hex-encode the result.
  6. Compare your result with v1 in constant time. If they don't match, reject the delivery with a 4xx status.

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.

InputValue
Secretwhsec_ExampleSecretForDocsOnly0000000000000000
t1791884113
Raw body{"id":"evt_01JA4T7Q9Y2V3W4X5Z6A7B8C9D","object":"event","type":"ping"}
Signed message1791884113.{"id":"evt_01JA4T7Q9Y2V3W4X5Z6A7B8C9D","object":"event","type":"ping"}
Expected v194cb1b4033aa43ffa747a13629a033766fe033d8f82f8c17664ef1bf075dae4c

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

SymptomLikely cause
Every signature failsThe 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 secretYour server still has the old secret. Deploy the new one, or temporarily accept either.
Intermittent timestamp failuresYour server clock is off. Sync it with NTP. The tolerance is 300 seconds.
RangeError in Node.jsYou 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).
Signature verification | Rehabify Docs | Physio Around Me by Rehabify Health