Skip to content
Preview. The endpoints on this page are illustrative and are likely to change as they move toward general availability. Documentation is published in advance so you can start shaping your integration; treat request and response details as subject to revision.

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:

  1. Read the raw request body before deserializing. JSON parsing may re-serialize with different whitespace and invalidate the signature.
  2. Compute HMAC_SHA256(rawBody, signingSecret) and hex-encode the result.
  3. Strip the sha256= prefix from X-Cirrus-Signature and compare with your computed hex.
  4. Use a constant-time comparison== on strings is vulnerable to timing attacks. Every language's crypto library provides a secureCompare or constantTimeEqual for exactly this purpose.
  5. Match → trust the payload. Mismatch → reject with 401 and drop the request.

Pick your language:

javascript
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'),
  )
}
python
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)
go
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))
}
csharp
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 .env file 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:

  1. Idempotency on eventId. Every delivery carries a stable eventId; 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.
  2. 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.

Raleigh, NC — a Cirruspath, Inc. company