Signatures
How X-Revoply-Signature is computed and exactly how to verify it, with test vectors and verifiers in four languages.
Every request we send carries an X-Revoply-Signature header. Verify it before you trust
the body: it proves the request came from us, was not changed on the way, and is recent.
X-Revoply-Signature: t=1790847005,v1=3f96ce90286a8c1479a3ae099a34b3b2db87392c627ffbe8e8c7b3956aa878a0How it is computed
tis the time of signing, in Unix seconds.- The signed payload is
t, a full stop, and the request body exactly as sent (UTF-8 bytes):1790847005.{"id":"evt_…",…}. v1is the HMAC-SHA256 of the signed payload, in lower-case hexadecimal. The key is the endpoint's whole secret as a string,whsec_included, as UTF-8 bytes. Do not decode the part afterwhsec_.
While a rotated secret still signs (for 24 hours after a rotation), the header carries two
v1 values, one per secret:
X-Revoply-Signature: t=1790847005,v1=3f96ce90…,v1=20fd3acd…How to verify it
- Read the raw body before any JSON parsing. A body that was parsed and serialised again is not the bytes we signed, even when it looks the same. See the raw-body pitfalls for your framework.
- Split the header on
,, and each part on its first=. Taket, and everyv1. Ignore keys you do not know: other schemes may be added. - Refuse the request if there is no
tor nov1, or iftis more than 300 seconds away from your clock in either direction. This stops a captured request being replayed later. Keep your server's clock synchronised. - Compute the HMAC-SHA256 of
{t}.{raw body}with your secret. While you move to a rotated secret, compute it with both. - Accept the request if any computed value equals any
v1. Compare in constant time.
Answer a request that fails verification with 400 and do not act on it. It is recorded
as a failed delivery and retried, which helps if the cause was a secret you had not
deployed yet.
Verifiers
Each of these passes every test vector. Copy it into your project as it is.
// Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Node.js 18+, no packages).
//
// X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs]
//
// v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included.
// Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/.
import { createHmac, timingSafeEqual } from 'node:crypto';
/** How far the signed time may be from your clock before the request is refused. */
export const TOLERANCE_SECONDS = 300;
/**
* @param {Buffer | string} rawBody The body exactly as it arrived, before any JSON parsing.
* @param {string | undefined} header The X-Revoply-Signature header.
* @param {string | string[]} secrets Your whsec_… secret. While you switch to a rotated
* secret, pass both.
* @param {{ now?: number, toleranceSeconds?: number }} [options] `now` in Unix seconds.
* @returns {boolean}
*/
export function verifyRevoplySignature(rawBody, header, secrets, options = {}) {
if (typeof header !== 'string' || header.length === 0) return false;
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const index = part.indexOf('=');
if (index === -1) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1') signatures.push(value);
}
if (timestamp === undefined || signatures.length === 0) return false;
const now = options.now ?? Math.floor(Date.now() / 1000);
const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS;
if (Math.abs(now - timestamp) > tolerance) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
for (const secret of [secrets].flat()) {
if (!secret) continue;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
);
for (const signature of signatures) {
const given = Buffer.from(signature);
if (given.length === expected.length && timingSafeEqual(given, expected)) return true;
}
}
return false;
}
For a whole receiver, see Receive messages with webhooks.
Test vectors
webhook-signature-vectors.json holds signed
requests generated by the code that signs our deliveries: an ASCII body, an Arabic body,
a rotation with two v1 values, and three that must fail (a changed body, a stale
timestamp, the wrong secret).
For each vector, verify header against body with each secret in verifyWith, taking
now as the current time. The result must equal valid.
Rotation
When you rotate a secret, deliveries are signed with both the old and the new secret for 24 hours. Deploy the new secret within that time; during the switch, accept either. After a leak, rotate twice to retire the leaked secret at once. See Security.