Skip to content

Webhook Subscriptions

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).

Per-request callbackSubscription
Where configuredcallback_url + callback_secret in POST /v1/authorize bodyPOST /v1/webhooks/subscriptions (once)
ScopeThat one requestAll events of subscribed types for the tenant
Audit trailLog-only (webhook_delivered / webhook_delivery_failed)Persisted webhook_deliveries row per attempt
Retries3 attempts, fixed backoff5 attempts, exponential backoff
RedeliveryNoneAdmin-triggered via API
Signing headersX-HumanAuth-Timestamp + X-HumanAuth-Signature (hex)Unified X-HumanAuth-Signature: t=…,v1=…
Best forStateless agents, one-off integrations where the URL varies per requestSIEM ingestion, dashboards, workflow systems, audit pipelines

Both stay supported. The per-request fields are not deprecated.

Exactly two events ship in v1.

EventFires whenResource
request.decidedConsensus reached: approved, denied, or expired (TTL elapsed before quorum)request_id
request.reportedA human flagged a request as suspicious via the mobile Report Sheetrequest_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).

{
"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.

Create a subscription. The raw secret is returned exactly once on create — store it. The DB only retains sha256(secret).

Terminal window
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.

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"]);
HeaderPurpose
X-HumanAuth-Signaturet=<unix_seconds>,v1=<hmac-sha256-hex>. Authenticity + anti-replay.
X-HumanAuth-Event-IdStable per logical event. Receivers MUST dedupe on this.
X-HumanAuth-Delivery-IdPer-HTTP-attempt ID. Distinguishes retries from new events.
Content-Typeapplication/json
  • 5 attempts with exponential backoff at approximately 0s, 30s, 5m, 30m, 2h between attempts. The full retry window spans ~2.5 hours; if your endpoint is down longer than that, the delivery is marked exhausted and 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, and 5xx are retryable. Other 4xx responses are terminal (the receiver signaled “your request is malformed or I don’t want it”).
  • After the 5th failure, the delivery is marked exhausted and a webhook_exhausted log event fires.
  • After 10 consecutive exhausted deliveries for the same subscription, the platform sets status='disabled'. Re-enable via PATCH after fixing your endpoint.
EndpointPurpose
GET /v1/webhooks/subscriptionsList subscriptions for the calling tenant. secret never returned.
GET /v1/webhooks/subscriptions/:idSingle subscription. 404 if not owned by the calling tenant.
PATCH /v1/webhooks/subscriptions/:idMutate url, events, description, or status (activepaused).
DELETE /v1/webhooks/subscriptions/:idHard delete. webhook_deliveries rows are retained as audit history.
POST /v1/webhooks/subscriptions/:id/rotate-secretGenerate a new secret. Returns it once. Old secret accepted for overlap_seconds (default 86400, max 604800).
POST /v1/webhooks/subscriptions/:id/testSend a synthetic _test.ping event. Returns delivery_id synchronously.
GET /v1/webhooks/subscriptions/:id/deliveriesPaginated delivery log. Filters: status, since, event_type.
POST /v1/webhooks/subscriptions/:id/deliveries/:delivery_id/redeliverRe-fire an exhausted delivery. Same event_id, new delivery_id. Receivers dedupe.
  1. POST …/test to fire a synthetic ping.
  2. Read back the delivery_id, GET …/deliveries/:delivery_id (or scroll GET …/deliveries).
  3. Inspect response_code, response_body (truncated to 1024 bytes), attempt, status.
  4. Fix your endpoint, re-test. For an old failed delivery, POST …/deliveries/:delivery_id/redeliver.
  • Createstatus='active', secret returned once.
  • PausePATCH { "status": "paused" }. No events delivered until resumed. Existing in-flight retries continue.
  • ResumePATCH { "status": "active" }.
  • Auto-disable → platform sets status='disabled' after 10 consecutive exhausted deliveries. Re-enable via PATCH.
  • RotatePOST …/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.
  • DeleteDELETE. Hard delete. Historical webhook_deliveries rows remain queryable.

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 + decisions
  • GET /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.

  • 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.revoked event. Trivial to add when a customer asks.