Webhook Subscriptions
What you get
Section titled “What you get”Tenant-level webhook subscriptions. Register one or more HTTPS URLs against a set of event types; the platform asynchronously delivers HMAC-signed POSTs through Cloudflare Queues with retries, persists every attempt in webhook_deliveries, and exposes a delivery log plus admin redelivery endpoint. Coexists with the older per-request callback_url / callback_secret on POST /v1/authorize — both fire if both are configured. Subscriptions are the canonical integration pattern for downstream systems (SIEM, dashboards, audit sinks).
Two delivery models
Section titled “Two delivery models”| Per-request callback | Subscription | |
|---|---|---|
| Where configured | callback_url + callback_secret in POST /v1/authorize body | POST /v1/webhooks/subscriptions (once) |
| Scope | That one request | All events of subscribed types for the tenant |
| Audit trail | Log-only (webhook_delivered / webhook_delivery_failed) | Persisted webhook_deliveries row per attempt |
| Retries | 3 attempts, fixed backoff | 5 attempts, exponential backoff |
| Redelivery | None | Admin-triggered via API |
| Signing headers | X-HumanAuth-Timestamp + X-HumanAuth-Signature (hex) | Unified X-HumanAuth-Signature: t=…,v1=… |
| Best for | Stateless agents, one-off integrations where the URL varies per request | SIEM ingestion, dashboards, workflow systems, audit pipelines |
Both stay supported. The per-request fields are not deprecated.
Event taxonomy (v1)
Section titled “Event taxonomy (v1)”Exactly two events ship in v1.
| Event | Fires when | Resource |
|---|---|---|
request.decided | Consensus reached: approved, denied, or expired (TTL elapsed before quorum) | request_id |
request.reported | A human flagged a request as suspicious via the mobile Report Sheet | request_id |
request.decided — sample payload (approved)
Section titled “request.decided — sample payload (approved)”{ "event_id": "evt_8c9af2b00f5d4e7a9d3e1f2a4b6c8d0e", "event_type": "request.decided", "created_at": 1717248123, "tenant_id": "ten_acme", "resource_id": "req_01HZQR3F7K8N2P5T9V1X3Y5Z7A", "data": { "request_id": "req_01HZQR3F7K8N2P5T9V1X3Y5Z7A", "action": "payments:transfer", "result": "approved", "decided_at": 1717248120, "receipt_jws": "eyJhbGciOiJFZERTQSIsImtpZCI6ImhhX2tleV8yMDI2XzAxIiwidHlwIjoiaHVtYW5hdXRoLXJlY2VpcHQranNvO3Y9MiJ9.eyJpc3MiOiJodHRwczovL2FwaS5odW1hbmF1dGguYWkiLC4uLn0.MEUCIQD..." }}receipt_jws is present in data when result ∈ {approved, denied}. When result == "expired", the field is absent (no receipt is minted on TTL expiry).
request.reported — sample payload
Section titled “request.reported — sample payload”{ "event_id": "evt_9d8e7f6a5b4c0d1e2f3a4b5c6d7e8f90", "event_type": "request.reported", "created_at": 1717250000, "tenant_id": "ten_acme", "resource_id": "req_01HZQR3F7K8N2P5T9V1X3Y5Z7A", "data": { "request_id": "req_01HZQR3F7K8N2P5T9V1X3Y5Z7A", "category": "phishing", "note": "I never initiated this transfer; agent looks compromised", "reporter_huid": "hu_karthick_7x2f", "reported_at": 1717249995 }}category is one of phishing | wrong_recipient | impersonation | excessive_scope | other. note is optional, truncated to 500 chars by the platform.
Subscribing
Section titled “Subscribing”Create a subscription. The raw secret is returned exactly once on create — store it. The DB only retains sha256(secret).
curl -X POST https://api.humanauth.ai/v1/webhooks/subscriptions \ -H "Authorization: Bearer $HA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.acme.com/humanauth", "events": ["request.decided", "request.reported"], "description": "Production approval pipeline" }'Response (201):
{ "subscription_id": "whsub_01HZQR3F7K8N2P5T9V1X3Y5Z7A", "tenant_id": "ten_acme", "url": "https://hooks.acme.com/humanauth", "events": ["request.decided", "request.reported"], "description": "Production approval pipeline", "status": "active", "secret": "whsec_b1a2c3d4e5f60718293a4b5c6d7e8f90...", "created_at": 1717248000, "updated_at": 1717248000}url MUST be https://. In production, hostnames resolving to localhost, loopback, link-local, or RFC1918 ranges are rejected (basic SSRF guard). events must be a non-empty subset of the v1 taxonomy.
Verifying the signature
Section titled “Verifying the signature”HMAC-SHA256 over <timestamp>.<body> with the raw secret returned at create-time. 300-second replay window. Receivers dedupe on X-HumanAuth-Event-Id.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyHumanAuthWebhook( headers: Record<string, string>, body: string, secret: string,): boolean { const sigHeader = headers["x-humanauth-signature"]; if (!sigHeader) return false; const parts = Object.fromEntries( sigHeader.split(",").map((p) => p.split("=")), ); const ts = parseInt(parts.t, 10); if (Number.isNaN(ts)) return false; if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5min replay window const expected = createHmac("sha256", secret) .update(`${ts}.${body}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}During a rotation overlap window (see Operations), compute the HMAC against BOTH stored secrets and accept if either matches. After prior_secret_expires_at, discard the old one.
Dedupe on the logical event, not the HTTP attempt:
if (seenEventIds.has(headers["x-humanauth-event-id"])) { return 200; // already processed; ack so we don't get retried}processEvent(body);seenEventIds.add(headers["x-humanauth-event-id"]);Headers reference
Section titled “Headers reference”| Header | Purpose |
|---|---|
X-HumanAuth-Signature | t=<unix_seconds>,v1=<hmac-sha256-hex>. Authenticity + anti-replay. |
X-HumanAuth-Event-Id | Stable per logical event. Receivers MUST dedupe on this. |
X-HumanAuth-Delivery-Id | Per-HTTP-attempt ID. Distinguishes retries from new events. |
Content-Type | application/json |
Delivery semantics
Section titled “Delivery semantics”- 5 attempts with exponential backoff at approximately
0s, 30s, 5m, 30m, 2hbetween attempts. The full retry window spans ~2.5 hours; if your endpoint is down longer than that, the delivery is markedexhaustedand waits for manual redelivery. Receivers should not depend on the exact schedule — Cloudflare Queues may add jitter and the platform may tune the curve. - Stripe-style classifier:
408,429, and5xxare retryable. Other4xxresponses are terminal (the receiver signaled “your request is malformed or I don’t want it”). - After the 5th failure, the delivery is marked
exhaustedand awebhook_exhaustedlog event fires. - After 10 consecutive exhausted deliveries for the same subscription, the platform sets
status='disabled'. Re-enable viaPATCHafter fixing your endpoint.
Operations
Section titled “Operations”| Endpoint | Purpose |
|---|---|
GET /v1/webhooks/subscriptions | List subscriptions for the calling tenant. secret never returned. |
GET /v1/webhooks/subscriptions/:id | Single subscription. 404 if not owned by the calling tenant. |
PATCH /v1/webhooks/subscriptions/:id | Mutate url, events, description, or status (active ↔ paused). |
DELETE /v1/webhooks/subscriptions/:id | Hard delete. webhook_deliveries rows are retained as audit history. |
POST /v1/webhooks/subscriptions/:id/rotate-secret | Generate a new secret. Returns it once. Old secret accepted for overlap_seconds (default 86400, max 604800). |
POST /v1/webhooks/subscriptions/:id/test | Send a synthetic _test.ping event. Returns delivery_id synchronously. |
GET /v1/webhooks/subscriptions/:id/deliveries | Paginated delivery log. Filters: status, since, event_type. |
POST /v1/webhooks/subscriptions/:id/deliveries/:delivery_id/redeliver | Re-fire an exhausted delivery. Same event_id, new delivery_id. Receivers dedupe. |
Debugging workflow
Section titled “Debugging workflow”POST …/testto fire a synthetic ping.- Read back the
delivery_id,GET …/deliveries/:delivery_id(or scrollGET …/deliveries). - Inspect
response_code,response_body(truncated to 1024 bytes),attempt,status. - Fix your endpoint, re-test. For an old failed delivery,
POST …/deliveries/:delivery_id/redeliver.
Subscription lifecycle
Section titled “Subscription lifecycle”- Create →
status='active', secret returned once. - Pause →
PATCH { "status": "paused" }. No events delivered until resumed. Existing in-flight retries continue. - Resume →
PATCH { "status": "active" }. - Auto-disable → platform sets
status='disabled'after 10 consecutive exhausted deliveries. Re-enable viaPATCH. - Rotate →
POST …/rotate-secret. The previous secret is accepted by the receiver during the overlap window (default 24h). Outbound deliveries are signed with the CURRENT secret only; the dual-acceptance is on the receiver side. - Delete →
DELETE. Hard delete. Historicalwebhook_deliveriesrows remain queryable.
PII discipline
Section titled “PII discipline”Webhook bodies carry the minimum to let a receiver decide whether to act. No emails, no raw form_data, no API keys, no approver names, no plan parameters. Notifications, not data dumps.
Receivers needing the full request — Cedar policy text, the full decisions[] array, plan parameters, per-approver assurance levels — MUST call back into the authenticated API:
GET /v1/requests/:id— full request + decisionsGET /v1/receipts/:id— receipt + verification metadata
Rationale: pushing every authorization detail into the webhook envelope means every receiver’s SIEM, logging pipeline, and S3 bucket holds a permanent copy of every approval. That’s a data-exfiltration footprint we don’t create by default.
What’s NOT in v1
Section titled “What’s NOT in v1”- Filter expressions (e.g.,
where data.action == 'payments:*' && data.amount > 10000). v2. - In-order delivery guarantees. Cloudflare Queues is best-effort; receivers sort on
created_at. - Per-subscription backpressure tiers or rate-limit budgets.
- First-party OOTB connectors for Slack, Linear, PagerDuty, Salesforce, ServiceNow. Integrators build these on top of the webhook.
- Dashboard UI for subscription management. v1 is API-only; the dashboard editor ships in a follow-up.
device.revokedevent. Trivial to add when a customer asks.
See also
Section titled “See also”- Receipts spec — the JWS rides inside
request.decided.data.receipt_jws - Verifier SDK — verify the embedded receipt JWS on your backend
- Protocol Internals — wire format and per-request callback details