ROOKDocs
5 min readUpdated August 2026

Webhook Signature Verification

All webhook event deliveries from Rook are cryptographically signed using HMAC-SHA256 to ensure authenticity, payload integrity, and replay protection.

Open Interactive Verifier →

Webhook Headers

Every webhook POST request delivered to your registered endpoint includes two verification headers:

HeaderDescriptionExample Value
Rook-TimestampUnix epoch timestamp (seconds) when the event signature was generated.1756832940
Rook-SignatureHex-encoded HMAC-SHA256 digest prefixed by v1=. May contain multiple comma-separated signatures during secret rotation.v1=9f8a3c2e1b4d5e6f7a8b...

Verification Algorithm

  1. Extract Headers: Read Rook-Timestamp and Rook-Signature. Reject the delivery (HTTP 400/401) if either is missing.
  2. Replay Window Defense: Compare the timestamp to the current clock. Reject if |currentTime - timestamp| > 300 seconds (5 minutes).
  3. Prepare Signed Payload: Concatenate timestamp + "." + rawRequestBody. Do not parse, trim, or re-encode the raw JSON payload bytes.
  4. Compute HMAC-SHA256: Calculate HMAC using your webhook secret (starts with whsec_...) and hex-encode in lowercase.
  5. Constant-Time Comparison: Compare the computed hex digest against each v1= token in Rook-Signature using constant-time equality. Accept if any matches.

Verification Code Example (TypeScript)

import crypto from 'node:crypto';

export function verifyRookWebhook(
  rawBody: string,
  timestampHeader: string,
  signatureHeader: string,
  webhookSecret: string
): boolean {
  // 1. Validate replay window (300 seconds)
  const timestamp = parseInt(timestampHeader, 10);
  const now = Math.floor(Date.now() / 1000);
  if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) {
    return false;
  }

  // 2. Prepare signed material: timestamp.body
  const signedPayload = `{timestamp}.{rawBody}`;

  // 3. Compute HMAC-SHA256
  const computedDigest = crypto
    .createHmac('sha256', webhookSecret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 4. Split comma-separated signatures for rotation support
  const signatures = signatureHeader
    .split(',')
    .map((s) => s.trim())
    .filter((s) => s.startsWith('v1='))
    .map((s) => s.slice(3));

  // 5. Constant-time match
  return signatures.some((sig) => {
    if (sig.length !== computedDigest.length) return false;
    return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(computedDigest, 'hex'));
  });
}

Secret Rotation Handling

During secret rotation in the Rook Dashboard, deliveries temporarily contain two signatures separated by commas (the previous secret and the newly provisioned secret). Your endpoint should accept the request if either signature verifies cleanly.

Was this page helpful?