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'))
  );
}
import hashlib
import hmac

def verify_webhook_signature(raw_body: bytes, signature_header: str, timestamp_header: str, secret: str) -> bool:
    # Use full secret as UTF-8 bytes (including whsec_ prefix)
    key = secret.encode("utf-8")

    # Parse headers
    timestamp = timestamp_header
    signatures = [p[7:] for p in signature_header.split(",") if p.startswith("sha256=")]

    # Compute expected signature
    signed_payload = f"{timestamp}:".encode() + raw_body
    expected = hmac.new(key, signed_payload, hashlib.sha256).hexdigest()

    # Constant-time compare against any sha256 value
    return any(hmac.compare_digest(sig, expected) for sig in signatures)
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strings"
)

func VerifyWebhookSignature(rawBody []byte, signatureHeader, timestampHeader, secret string) bool {
	// Use full secret as UTF-8 bytes (including whsec_ prefix)
	key := []byte(secret)

	// Parse headers
	timestamp := timestampHeader
	var signatures []string
	for _, part := range strings.Split(signatureHeader, ",") {
		if strings.HasPrefix(part, "sha256=") {
			signatures = append(signatures, part[7:])
		}
	}

	// Compute expected signature
	signedPayload := fmt.Sprintf("%s:%s", timestamp, rawBody)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(signedPayload))
	expected := hex.EncodeToString(mac.Sum(nil))

	// Constant-time compare against any sha256 value
	for _, sig := range signatures {
		if hmac.Equal([]byte(sig), []byte(expected)) {
			return true
		}
	}
	return false
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class WebhookVerifier {

    public static boolean verifyWebhookSignature(
            byte[] rawBody, String signatureHeader, String timestampHeader, String secret) throws Exception {
        // Use full secret as UTF-8 bytes (including whsec_ prefix)
        byte[] key = secret.getBytes(StandardCharsets.UTF_8);

        // Parse headers
        String timestamp = timestampHeader;
        List<String> signatures = new ArrayList<>();
        for (String part : signatureHeader.split(",")) {
            if (part.startsWith("sha256=")) {
                signatures.add(part.substring(7));
            }
        }

        // Compute expected signature
        byte[] signedPayload = (timestamp + ":").getBytes();
        byte[] fullPayload = new byte[signedPayload.length + rawBody.length];
        System.arraycopy(signedPayload, 0, fullPayload, 0, signedPayload.length);
        System.arraycopy(rawBody, 0, fullPayload, signedPayload.length, rawBody.length);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        String expected = bytesToHex(mac.doFinal(fullPayload));

        // Constant-time compare against any sha256 value
        for (String sig : signatures) {
            if (MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) {
                return true;
            }
        }
        return false;
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    }
}

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