Verifying Signatures

Validate webhook authenticity with HMAC-SHA256

Every webhook delivery is signed so you can verify it came from Flipswitch and hasn't been tampered with.

How Signing Works

  1. Secret format. Each webhook has a signing secret in the format whsec_....
  2. String to sign. Flipswitch builds the signed payload as <timestamp>:<raw_json_body>, where the timestamp is the Unix epoch in seconds from the X-Flipswitch-Timestamp header.
  3. HMAC-SHA256. The signed payload is hashed using the full secret string bytes (UTF-8, including the whsec_ prefix) and hex-encoded.
  4. Header format. The signature and timestamp are sent as two separate headers:
X-Flipswitch-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
X-Flipswitch-Timestamp: 1705312242

During secret rotation, the X-Flipswitch-Signature header contains two sha256= entries:

X-Flipswitch-Signature: sha256=<hmac_new_secret>,sha256=<hmac_old_secret>
X-Flipswitch-Timestamp: 1705312242

Verification Steps

  1. Parse the headers. Extract the timestamp from X-Flipswitch-Timestamp and all sha256= signature values from X-Flipswitch-Signature.
  2. Build the signed payload. Concatenate <timestamp>:<raw_request_body> (use the raw bytes, not re-serialized JSON).
  3. Compute the expected HMAC. Use the full secret string as the HMAC key bytes (UTF-8, including whsec_), then compute HMAC-SHA256 over the signed payload and hex-encode the result.
  4. Compare. Check if any sha256= value matches your computed signature using a constant-time comparison.
  5. Optional: check timestamp. Reject deliveries where the timestamp is too far from the current time (e.g., more than 5 minutes) to prevent replay attacks.

Code Examples

import crypto from 'node:crypto';

function verifyWebhookSignature(rawBody, signatureHeader, timestampHeader, secret) {
  // Use full secret as UTF-8 bytes (including whsec_ prefix)
  const key = Buffer.from(secret, 'utf8');

  // Parse headers
  const timestamp = timestampHeader;
  const signatures = signatureHeader
    .split(',')
    .filter(p => p.startsWith('sha256='))
    .map(p => p.slice(7));

  // Compute expected signature
  const signedPayload = `${timestamp}:${rawBody}`;
  const expected = crypto
    .createHmac('sha256', key)
    .update(signedPayload)
    .digest('hex');

  // Constant-time compare against any sha256 value
  return signatures.some(sig =>
    crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
  );
}

Always verify against the raw request body bytes. Parsing the JSON and re-serializing it may change whitespace or key ordering, which will invalidate the signature.

Handling Secret Rotation

During secret rotation, Flipswitch signs each delivery with both the new and old secrets. The X-Flipswitch-Signature header contains two sha256= entries:

X-Flipswitch-Signature: sha256=<hmac_new_secret>,sha256=<hmac_old_secret>
X-Flipswitch-Timestamp: 1705312242

The code examples above already handle this — they iterate over all sha256= values and accept the delivery if any one matches. This means your endpoint continues to verify successfully during the rotation grace period without any code changes. Once rotation completes, only the new secret is used.

On this page