Webhooks
CloakAPI delivers real-time event notifications to your server via signed HTTPS POST. This page documents the wire format, signature verification, retry behaviour, idempotency, and event catalog.
Quick start
- Create an endpoint at /portal/webhooks or via the API:
Terminal window curl -X POST https://api.cloakapi.io/api/v1/webhooks/endpoints \-H "Authorization: Bearer $CLOAKAPI_TOKEN" \-H "Content-Type: application/json" \-d '{"url":"https://yourserver.example/hooks","events":["receipt.signed","chain.broken"]}' - Save the returned
secret. You will not see it again — rotate viaPOST /endpoints/{id}/rotate. - Verify the signature on every incoming POST (see below).
- Return HTTP 2xx within 8 seconds to acknowledge. Any 5xx / timeout triggers retry.
Signature scheme
Every outbound POST carries two signature headers. New consumers SHOULD use the v1 scheme.
v1 (Stripe-style, recommended)
X-CloakAPI-Signature: t=1716508800,v1=8d3a4f...d2b6The signed string is "{t}.{raw_body}" (literal dot). Compute on the receiver:
# Python receiver — constant-time + 5-minute replay windowimport hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str) -> bool: sig = headers["X-CloakAPI-Signature"] # "t=…,v1=…" parts = dict(p.split("=", 1) for p in sig.split(",")) t, v1 = int(parts["t"]), parts["v1"]
# Replay window: reject anything older than 5 minutes if abs(time.time() - t) > 300: return False
signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) # constant-time// Node.js receiverimport { createHmac, timingSafeEqual } from "node:crypto";
export function verify(headers, rawBody, secret) { const sig = headers["x-cloakapi-signature"]; // "t=…,v1=…" const parts = Object.fromEntries(sig.split(",").map(p => p.split("="))); const t = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-min window
const expected = createHmac("sha256", secret) .update(`${t}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(parts.v1, "hex"); return a.length === b.length && timingSafeEqual(a, b); // constant-time}v0 (legacy)
X-CloakAPI-Webhook-Signature: sha256=<hex>X-CloakAPI-Webhook-Signature-Alg: HMAC-SHA256The signed string is the canonical JSON payload itself (which already contains a timestamp
field). Equivalent security to v1 but requires re-serializing the body — prefer v1 for new code.
Idempotency
Every delivery carries an Idempotency-Key header equal to the unique event_id:
Idempotency-Key: 4f8b9e2c-1a2b-4c3d-9e8f-7a6b5c4d3e2fReceivers SHOULD store delivered event ids for at least 24 hours and treat a duplicate
Idempotency-Key as a no-op (return 2xx). A delivery that retries will arrive with the same
key, so deduping prevents double-processing.
Retry schedule
CloakAPI attempts each delivery up to 7 times with exponential backoff:
| Attempt | Delay after previous | Cumulative |
|---|---|---|
| 1 | 0 (immediate) | 0 |
| 2 | 15 s | 15 s |
| 3 | 1 m | 1 m 15 s |
| 4 | 5 m | 6 m 15 s |
| 5 | 15 m | 21 m 15 s |
| 6 | 1 h | ≈ 1 h 21 m |
| 7 | 6 h | ≈ 7 h 21 m |
| (final) | 1 d | ≈ 1 d 7 h |
Terminal outcomes:
- 2xx →
delivered(terminal success). - 410 Gone →
deadand the endpoint is automatically paused (Stripe convention — return 410 from a decommissioned endpoint). - 4xx (non-410) →
deadimmediately. We treat client errors as consumer rejection and do not retry. - 5xx / timeout / network → retry per the schedule above. After the 7th attempt, the
delivery is marked
deadand surfaces in the delivery log withattempt_count = 7.
Delivery log + replay
Every attempt is recorded in webhook_deliveries and visible at
/portal/webhooks under “Recent deliveries”. Each
row exposes its event, status, last HTTP code, attempt count, and timestamps. Use Replay to
re-fire any past delivery (audit row preserved; a new row is created with event_id prefixed
replay-).
API surface (org-scoped, requires webhooks.manage for writes):
GET /api/v1/webhooks/deliveries list recent across all endpointsGET /api/v1/webhooks/deliveries/{id} full row incl. payload + last errorPOST /api/v1/webhooks/deliveries/{id}/replay fire a replay (new row)Test event
Use the “Send test” button on any endpoint or:
POST /api/v1/webhooks/endpoints/{id}/testThis dispatches a synthetic webhook.test event whose body is:
{ "event": "webhook.test", "event_id": "test-…", "timestamp": 1716508800, "data": { "endpoint_id": 42, "sent_at": "2026-05-23T14:00:00+00:00" }}Event catalog
Subscribe to events on endpoint create/update via the events array. Use * to subscribe to
all events; use prefix.* (e.g. receipt.*) for namespace globs.
| Event | Fired when |
|---|---|
receipt.signed | A new privacy receipt is sealed into the chain. |
chain.broken | Continuous verifier detects a gap or bad signature. |
chain.repaired | A broken chain segment is reconstructed + re-attested. |
user.created | New user joins an organisation (via SSO or self-serve invite). |
user.deleted | User account erased (DSAR / right-to-be-forgotten). |
org.member_added | Existing user added to a new org (multi-org membership). |
key.created | New API key minted. |
key.revoked | Key revoked (admin action or compromise response). |
key.rotated | Key auto-rotated by scheduled rotation policy. |
billing.low_balance | Prepaid balance crossed warning threshold. |
billing.credit_added | Top-up succeeded. |
billing.invoice_paid | Monthly invoice settled (Stripe webhook → mirror). |
subscriber.bounced | Outbound email bounced (hard / soft per Mailwizz classifier). |
subscriber.unsubscribed | Recipient opted out of marketing email. |
webhook.test | Manual smoke-test from the portal / API. |
This list is the canonical source — additions appear in this table before they are fired in production.
Headers reference
| Header | Description |
|---|---|
Idempotency-Key | Equal to event_id. Dedupe on this for ≥24 h. |
X-CloakAPI-Signature | v1 scheme: t=<unix>,v1=<hmac-hex>. Prefer this. |
X-CloakAPI-Webhook-Signature | v0 scheme: sha256=<hmac-hex> of canonical payload. |
X-CloakAPI-Webhook-Signature-Alg | Always HMAC-SHA256. |
X-CloakAPI-Webhook-Timestamp | Unix seconds (also embedded inside payload.timestamp). |
X-CloakAPI-Webhook-Event | Event type (e.g. receipt.signed). |
X-CloakAPI-Webhook-Event-Id | UUID; matches Idempotency-Key. |
X-CloakAPI-Webhook-Delivery-Id | Numeric delivery row id (changes per attempt). |
X-CloakAPI-Webhook-Attempt | Attempt counter (1, 2, …, 7). |
User-Agent | CloakAPI-Webhook/1.0. |
Security notes
- Constant-time compare: always use
hmac.compare_digest/crypto.timingSafeEqual— string equality leaks bytes through timing. - 5-minute replay window: reject any delivery whose
tis more than 300 s from your wall clock. Combined withIdempotency-Keydeduping, this defeats replay even if an attacker captures the body in flight. - Rotate on suspicion:
POST /endpoints/{id}/rotateinvalidates the old secret immediately. - SSRF guard: we refuse to POST to private/reserved/metadata IPs; redirects are not followed. DNS is re-resolved just before each attempt to defeat rebinding.