Verify webhook signatures and handle delivery reliably
Updated
Every webhook delivery CompliAPI sends includes an X-CompliAPI-Signature header. Verifying this header before acting on a payload ensures the request genuinely came from CompliAPI and hasn't been tampered with. Always verify signatures — skip this step and your endpoint is open to spoofed events.
Signature format
The header looks like this:
X-CompliAPI-Signature: t=1756640000,v1=5f8c2a...
t is a Unix timestamp and v1 is a hex-encoded HMAC-SHA256. The MAC is computed over the string "{t}." + <raw request body> using your endpoint's signing secret (whsec_...), which you can find (and rotate) on the Webhooks page.
Verify in Python
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) timestamp, signature = parts.get("t", "0"), parts.get("v1", "") if abs(time.time() - int(timestamp)) > tolerance: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)
Verify in Node
const crypto = require("crypto");
function verify(secret, rawBody, header, tolerance = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const timestamp = Number(parts.t); if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false; const expected = crypto .createHmac("sha256", secret) .update(`${parts.t}.`) .update(rawBody) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));}
Two important details: use the raw request body bytes, not re-serialized JSON (parsing and re-serializing changes the bytes and breaks the MAC); and use a constant-time comparison to prevent timing attacks.
Handle delivery reliably
Webhook delivery is at-least-once and unordered, which means the same event can occasionally arrive more than once, and two events for the same entity may arrive out of order. Handle this by:
- Deduplicating on
event.id— this field is stable across retries, so storing it lets you safely discard duplicates. - Ordering by
created_at— use the ingestion timestamp to establish the correct sequence if order matters to your workflow.
Respond with any 2xx status within 10 seconds to acknowledge delivery. Anything else triggers retries on a backoff schedule of roughly 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. After 5 consecutive failed deliveries (each exhausting all retries), the endpoint is automatically disabled and the organization owner is emailed. Re-enable it from the Webhooks page once the issue is resolved — events that occurred while the endpoint was disabled are not replayed.
If you rotate the signing secret from the dashboard, the previous secret is invalidated immediately. Update your consumer to use the new secret before rotating to avoid a verification gap.