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
- Secret format. Each webhook has a signing secret in the format
whsec_.... - String to sign. Flipswitch builds the signed payload as
<timestamp>:<raw_json_body>, where the timestamp is the Unix epoch in seconds from theX-Flipswitch-Timestampheader. - HMAC-SHA256. The signed payload is hashed using the full secret string bytes (UTF-8, including the
whsec_prefix) and hex-encoded. - Header format. The signature and timestamp are sent as two separate headers:
X-Flipswitch-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
X-Flipswitch-Timestamp: 1705312242During 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: 1705312242Verification Steps
- Parse the headers. Extract the timestamp from
X-Flipswitch-Timestampand allsha256=signature values fromX-Flipswitch-Signature. - Build the signed payload. Concatenate
<timestamp>:<raw_request_body>(use the raw bytes, not re-serialized JSON). - 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. - Compare. Check if any
sha256=value matches your computed signature using a constant-time comparison. - 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: 1705312242The 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.