RevoplyAIDocs
Webhooks

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=3f96ce90286a8c1479a3ae099a34b3b2db87392c627ffbe8e8c7b3956aa878a0

How it is computed

  1. t is the time of signing, in Unix seconds.
  2. The signed payload is t, a full stop, and the request body exactly as sent (UTF-8 bytes): 1790847005.{"id":"evt_…",…}.
  3. v1 is 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 after whsec_.

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

  1. 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.
  2. Split the header on ,, and each part on its first =. Take t, and every v1. Ignore keys you do not know: other schemes may be added.
  3. Refuse the request if there is no t or no v1, or if t is 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.
  4. Compute the HMAC-SHA256 of {t}.{raw body} with your secret. While you move to a rotated secret, compute it with both.
  5. 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.

On this page