Securing your endpoint
Every webhook delivery is signed with an HMAC-SHA256 signature over the raw request body, keyed with your subscription's signingSecret. Your endpoint must verify this signature before trusting the payload; a request without a valid signature is either a bug in your integration or an attacker attempting to inject fake events.
Signature format
Cirrus sets one signature header on every delivery:
The <lowercase-hex> value is the HMAC-SHA256 of the raw request body bytes (exactly as received, before any parsing), keyed with the subscription's signingSecret (the value that starts with whsec_...).
X-Cirrus-Signature: sha256=<lowercase-hex>Verification recipe
Every language ecosystem ships an HMAC-SHA256 primitive; the pattern is the same everywhere:
- Read the raw request body before deserializing. JSON parsing may re-serialize with different whitespace and invalidate the signature.
- Compute
HMAC_SHA256(rawBody, signingSecret)and hex-encode the result. - Strip the
sha256=prefix fromX-Cirrus-Signatureand compare with your computed hex. - Use a constant-time comparison —
==on strings is vulnerable to timing attacks. Every language's crypto library provides asecureCompareorconstantTimeEqualfor exactly this purpose. - Match → trust the payload. Mismatch → reject with
401and drop the request.
Pick your language:
const crypto = require('crypto')
function verifyCirrusSignature(rawBody, signatureHeader, signingSecret) {
if (!signatureHeader?.startsWith('sha256=')) return false
const received = signatureHeader.slice('sha256='.length)
const expected = crypto
.createHmac('sha256', signingSecret)
.update(rawBody) // Buffer, not string — raw body bytes as received
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(received, 'hex'),
Buffer.from(expected, 'hex'),
)
}import hmac
import hashlib
def verify_cirrus_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
received = signature_header[len("sha256="):]
expected = hmac.new(
signing_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(received, expected)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verifyCirrusSignature(rawBody []byte, signatureHeader, signingSecret string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
received, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, "sha256="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write(rawBody)
return hmac.Equal(received, mac.Sum(nil))
}using System;
using System.Security.Cryptography;
using System.Text;
public static bool VerifyCirrusSignature(byte[] rawBody, string signatureHeader, string signingSecret)
{
const string prefix = "sha256=";
if (string.IsNullOrEmpty(signatureHeader) ||
!signatureHeader.StartsWith(prefix, StringComparison.Ordinal))
{
return false;
}
byte[] received;
try
{
received = Convert.FromHexString(signatureHeader.AsSpan(prefix.Length));
}
catch (FormatException)
{
return false;
}
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var expected = hmac.ComputeHash(rawBody);
// FixedTimeEquals is the constant-time comparison — do not use SequenceEqual
// or byte-by-byte loops that short-circuit on the first mismatch.
return CryptographicOperations.FixedTimeEquals(received, expected);
}Signing-secret hygiene
Keep the signing secret secret
Your whsec_... value is what makes the signature meaningful. Anyone who has it can forge deliveries that look authentic. Compromise implications:
- Attacker can inject fake events into your integration — e.g., simulate a "meeting canceled" and cause your CRM to lose data.
- Attacker can replay old captured deliveries.
Never expose the signing secret in client-side code, browser-visible JavaScript, mobile apps, or committed source control. It's a server-only credential.
Recommended practices:
- Store the secret in a dedicated secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler, 1Password Secrets Automation, etc.), not in a
.envfile committed to git. - Rotate the secret by creating a new subscription and deleting the old one. There is no rotation endpoint; the model is delete-and-recreate.
- Use separate subscriptions per service / environment — e.g., one for production, one for staging. A leaked staging secret doesn't cross into production.
Replay protection
The signature verifies the payload came from Cirrus intact. It does not by itself prevent replay — a captured delivery could be resent by an attacker and would pass signature verification.
Two lines of defense against replay:
- Idempotency on
eventId. Every delivery carries a stableeventId; retries share the same value. Track which ids you've processed and skip re-processing. This makes replay a no-op from your business logic's perspective. - TLS. Cirrus only delivers over HTTPS, and captured TLS-protected traffic can't be replayed by an eavesdropper without breaking the TLS session — a substantially higher bar than reading an unencrypted request off the wire.
If your integration handles particularly sensitive operations and you want stronger replay protection, you can additionally reject deliveries whose deliveryTimestamp is more than a few minutes old — but for most partners, eventId deduplication is sufficient.
What happens on a signature failure
Cirrus does not retry deliveries whose signatures your endpoint rejected. From Cirrus's perspective a 401 looks like "the endpoint intentionally refused" — the same as any other non-2xx response. Cirrus's retry counter still increments, but only up to the normal retry budget documented in Lifecycle.
If you're seeing failing signature verifications in production, likely causes:
- Your endpoint parsed the JSON body before verifying — the recomputed signature won't match. Read raw bytes first.
- You're using the wrong secret. Confirm the subscription id in the payload matches the subscription whose secret you're checking against.
- You concatenated a timestamp or other data into the signed content. Cirrus signs the raw body only — nothing else.
- A middleware or reverse proxy is modifying the body in transit. Ensure your webhook route is exempt from body rewriting.