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. HTTP 408, 429, 5xx, timeouts, and network errors trigger 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 makes up to 8 attempts total (the initial attempt plus 7 retries):
| 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 |
| 8 | 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). - 408 / 429 → retry per the schedule above.
- Other 4xx (excluding 410) →
deadimmediately. A 410 also pauses the endpoint. - 5xx / timeout / network → retry per the schedule above. After the 8th attempt
(the seventh retry), the delivery is marked
deadand surfaces withattempt_count = 8.
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 | Reserved, not emitted: erasing an account does not send it. |
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.