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.

← All articles

Powered by Shipstar