Integrator Cookbook
Polyglot · Six recipes · Production-shaped
The complete integrator journey — authorize, verify, observe, export, investigate — written against the live API at api.humanauth.ai. Every recipe ships in the four languages most backends are written in. If you’re on TypeScript, the @humanauth/sdk and @humanauth/verifier packages are the higher-leverage door; everything else is plain HTTP.
- cURL
- TypeScript
- Python
- Java
The journey · click any node
Conventions
Section titled “Conventions”- HA_API_KEY
- Tenant API key. Dashboard → Settings → API Keys. Sent as
Authorization: Bearer $HA_API_KEY. - HA_TENANT_ID
- The receipt
audclaim your verifier expects. FromGET /v1/auth/meor the dashboard. - HA_BASE
https://api.humanauth.aiin prod,http://localhost:8787againstwrangler dev.- Timestamps
- Seconds since epoch unless suffixed
_ms.
1 — Send your first authorization request
Section titled “1 — Send your first authorization request”Outcome A pending request_id and a push notification on the human’s phone, within milliseconds.
POST /v1/authorize is the agent → human handoff. The platform fans the request out to all registered devices for the target human (or group), waits for the response, and (on approval) mints a Receipt v2 JWS. This call returns immediately with a request_id; the receipt arrives later via webhook or GET /v1/receipts/{id}.
curl -X POST "$HA_BASE/v1/authorize" \ -H "Authorization: Bearer $HA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "human_id": "hu_a1b2c3d4e5f6a7b8", "action": "github:delete_repo", "description": "Delete acme/legacy-api", "intent": "authorize", "severity": "high", "ttl": 300, "plan": { "repo": "acme/legacy-api", "confirm_string": "legacy-api" } }'const res = await fetch(`${process.env.HA_BASE}/v1/authorize`, { method: "POST", headers: { Authorization: `Bearer ${process.env.HA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ human_id: "hu_a1b2c3d4e5f6a7b8", action: "github:delete_repo", description: "Delete acme/legacy-api", intent: "authorize", severity: "high", ttl: 300, plan: { repo: "acme/legacy-api", confirm_string: "legacy-api" }, }),});if (!res.ok) throw new Error(`authorize failed: ${res.status} ${await res.text()}`);const { request_id, expires_at, plan_hash } = await res.json();console.log({ request_id, expires_at, plan_hash });import os, httpx
res = httpx.post( f"{os.environ['HA_BASE']}/v1/authorize", headers={ "Authorization": f"Bearer {os.environ['HA_API_KEY']}", "Content-Type": "application/json", }, json={ "human_id": "hu_a1b2c3d4e5f6a7b8", "action": "github:delete_repo", "description": "Delete acme/legacy-api", "intent": "authorize", "severity": "high", "ttl": 300, "plan": {"repo": "acme/legacy-api", "confirm_string": "legacy-api"}, }, timeout=10.0,)res.raise_for_status()body = res.json()print(body["request_id"], body["expires_at"], body["plan_hash"])// Java 17 + HttpClient. JSON shown inline for clarity; use Jackson/Gson in real code.var body = """ { "human_id": "hu_a1b2c3d4e5f6a7b8", "action": "github:delete_repo", "description": "Delete acme/legacy-api", "intent": "authorize", "severity": "high", "ttl": 300, "plan": {"repo": "acme/legacy-api", "confirm_string": "legacy-api"} } """;
HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(System.getenv("HA_BASE") + "/v1/authorize")) .header("Authorization", "Bearer " + System.getenv("HA_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build();
HttpResponse<String> res = HttpClient.newHttpClient() .send(req, HttpResponse.BodyHandlers.ofString());if (res.statusCode() != 202) { throw new RuntimeException("authorize failed: " + res.statusCode() + " " + res.body());}System.out.println(res.body()); // { "request_id": "req_…", "expires_at": …, "plan_hash": "…" }The response is 202 Accepted because the human hasn’t decided yet. Pick one of:
- Subscribe to
request.decidedwebhooks (Recipe 3) — the recommended path. - Poll
GET /v1/requests/{request_id}— fine for short-lived scripts; not for production agents. - Wait on the SSE stream (Recipe 6) — useful for dashboards.
2 — Verify a receipt on your backend
Section titled “2 — Verify a receipt on your backend”Outcome A typed verified receipt — or a typed error. The action does not execute unless five invariants hold.
This is the trust boundary that makes “no receipt, no execution” real. Verification is offline — no platform round-trip on the hot path, only a cached JWKS fetch (default 1h). The recipe enforces:
- JWS signature against a key in the platform’s JWKS.
audequals your tenant ID,issequalshttps://api.humanauth.ai,expis in the future.ha.plan_hashbyte-matchessha256(rfc8785(your_plan))— guarantees the human approved exactly this plan.actionmatches what you’re about to execute.(jti, idempotency_key)claimed atomically against your replay store.
Use the first-party verifier — it bakes the plan-hash check, JWKS caching, and atomic replay reservation into one call.
import { HumanAuthVerifier } from "@humanauth/verifier";import { RedisReplayStore } from "@humanauth/verifier/stores/redis";import Redis from "ioredis";
const verifier = new HumanAuthVerifier({ audience: process.env.HA_TENANT_ID!, jwksUri: `${process.env.HA_BASE}/.well-known/jwks.json`, replayStore: new RedisReplayStore(new Redis(process.env.REDIS_URL!)),});
const verified = await verifier.requireReceipt(receiptJws, { action: "github:delete_repo", plan: { repo: "acme/legacy-api", confirm_string: "legacy-api" }, idempotencyKey: req.headers["idempotency-key"] as string,});
// verified.subject — huid of the approver// verified.approvers — [{ device_id, assurance, decided_at }]// verified.replay — true on idempotent retry of the same (jti, idem)See the Verifier guide for typed errors, Express middleware, MCP tool wrapper, and replay-store options.
Python verifier — PyJWT, cryptography, and rfc8785 for canonical JSON. Add a Redis-backed replay store in production.
# pip install pyjwt[crypto] cryptography rfc8785 httpx redisimport hashlib, json, time, osimport httpx, jwt, rfc8785, redisfrom jwt import PyJWKClient
HA_BASE = os.environ["HA_BASE"]HA_TENANT_ID = os.environ["HA_TENANT_ID"]jwks = PyJWKClient(f"{HA_BASE}/.well-known/jwks.json", cache_keys=True, lifespan=3600)rds = redis.Redis.from_url(os.environ["REDIS_URL"])
def plan_hash(plan: dict) -> str: canonical = rfc8785.dumps(plan) # bytes, RFC 8785 canonical JSON return hashlib.sha256(canonical).hexdigest()
class VerificationError(Exception): ...
def require_receipt(receipt_jws: str, *, expected_action: str, plan: dict, idempotency_key: str) -> dict: # 1. Decode + verify the JWS signature against JWKS. signing_key = jwks.get_signing_key_from_jwt(receipt_jws).key claims = jwt.decode( receipt_jws, signing_key, algorithms=["EdDSA"], audience=HA_TENANT_ID, issuer="https://api.humanauth.ai", leeway=60, # match verifier default clock skew ) ha = claims.get("ha") or {}
# 2. Plan hash must byte-match. if ha.get("plan_hash") != plan_hash(plan): raise VerificationError("PLAN_HASH_MISMATCH")
# 3. Action must match. if claims.get("action") != expected_action: raise VerificationError("ACTION_MISMATCH")
# 4. Result must be `approved`. if ha.get("result") != "approved": raise VerificationError("NOT_APPROVED")
# 5. Atomic replay reservation: SET NX EX, scoped by (jti, idempotency_key). jti = claims["jti"] slot = f"ha:claim:{jti}" ok = rds.set(slot, idempotency_key, nx=True, ex=int(claims["exp"] - time.time()) + 60) if not ok: stored = rds.get(slot) if stored is not None and stored.decode() == idempotency_key: # Same key replaying — success, idempotent retry. return {"claims": claims, "replay": True} raise VerificationError("REPLAY_CONFLICT")
return {"claims": claims, "replay": False}Per-approver device cosignatures (ha.approvers[].device_sig) are an additional layer enforced by the TS verifier; mirror that check by Ed25519-verifying each device_sig against the canonical cosig message if your tenant requires it. The platform-side signature plus plan_hash is enough for most surfaces.
Java verifier — Nimbus JOSE + JWT for the JWS half, erdtman/java-json-canonicalization for RFC 8785.
<dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.40</version></dependency><dependency> <groupId>io.github.erdtman</groupId> <artifactId>java-json-canonicalization</artifactId> <version>1.1</version></dependency>import com.nimbusds.jose.*;import com.nimbusds.jose.crypto.Ed25519Verifier;import com.nimbusds.jose.jwk.*;import com.nimbusds.jose.jwk.source.*;import com.nimbusds.jose.proc.*;import com.nimbusds.jwt.*;import com.nimbusds.jwt.proc.*;import org.erdtman.jcs.JsonCanonicalizer;import java.net.URL;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;
public class HumanAuthVerifier { private final ConfigurableJWTProcessor<SecurityContext> processor; private final String audience;
public HumanAuthVerifier(String haBase, String audience) throws Exception { this.audience = audience; var jwkSource = JWKSourceBuilder .create(new URL(haBase + "/.well-known/jwks.json")) .cache(3600_000L, 30_000L) .build(); var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.EdDSA, jwkSource); var p = new DefaultJWTProcessor<SecurityContext>(); p.setJWSKeySelector(keySelector); p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>( audience, new JWTClaimsSet.Builder().issuer("https://api.humanauth.ai").build(), java.util.Set.of("iss", "aud", "exp", "jti", "action"))); this.processor = p; }
public JWTClaimsSet require(String receiptJws, String expectedAction, String planJson) throws Exception { JWTClaimsSet claims = processor.process(receiptJws, null);
// Action match. if (!expectedAction.equals(claims.getStringClaim("action"))) { throw new SecurityException("ACTION_MISMATCH"); }
// Plan hash byte-compare. byte[] canonical = new JsonCanonicalizer(planJson).getEncodedUTF8(); byte[] sha = MessageDigest.getInstance("SHA-256").digest(canonical); String expected = HexFormat.of().formatHex(sha); var ha = (java.util.Map<String, Object>) claims.getClaim("ha"); if (!expected.equals(ha.get("plan_hash"))) { throw new SecurityException("PLAN_HASH_MISMATCH"); } if (!"approved".equals(ha.get("result"))) { throw new SecurityException("NOT_APPROVED"); }
// TODO: atomically reserve (jti, idempotencyKey) in your replay store // (Redis SET NX EX, Postgres INSERT ... ON CONFLICT DO NOTHING, etc.). return claims; }}Replay storage is intentionally pluggable — Redis SET NX EX is the simplest atomic primitive. The (jti, idempotency_key) slot must be claimed before you execute the action.
No cURL recipe — verification is a code path, not an HTTP call. The only network operation is the (cacheable) JWKS fetch:
curl "$HA_BASE/.well-known/jwks.json"Use it once to confirm reachability, then pin the JWKS URI into your verifier.
3 — Subscribe to webhooks and verify the signature
Section titled “3 — Subscribe to webhooks and verify the signature”Outcome Every decision and every report arrives at your endpoint, HMAC-signed, deduped, retried.
Subscriptions are the canonical integration pattern for downstream systems — SIEM, dashboards, audit sinks. Two events ship in v1: request.decided (approved/denied/expired) and request.reported (a user flagged the request as suspicious).
Subscribe
Section titled “Subscribe”curl -X POST "$HA_BASE/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 includes "secret": "whsec_…" — store it now, it is returned only once.const res = await fetch(`${process.env.HA_BASE}/v1/webhooks/subscriptions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.HA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://hooks.acme.com/humanauth", events: ["request.decided", "request.reported"], description: "Production approval pipeline", }),});const sub = await res.json();// sub.secret is the raw HMAC secret — persist it in your secret manager NOW.import httpx, osr = httpx.post( f"{os.environ['HA_BASE']}/v1/webhooks/subscriptions", headers={"Authorization": f"Bearer {os.environ['HA_API_KEY']}"}, json={ "url": "https://hooks.acme.com/humanauth", "events": ["request.decided", "request.reported"], "description": "Production approval pipeline", },)r.raise_for_status()sub = r.json()# sub["secret"] is shown exactly once — persist immediately.var body = """ { "url": "https://hooks.acme.com/humanauth", "events": ["request.decided", "request.reported"], "description": "Production approval pipeline" } """;var res = HttpClient.newHttpClient().send( HttpRequest.newBuilder() .uri(URI.create(System.getenv("HA_BASE") + "/v1/webhooks/subscriptions")) .header("Authorization", "Bearer " + System.getenv("HA_API_KEY")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(), HttpResponse.BodyHandlers.ofString());// Parse res.body() and store the "secret" field — returned exactly once.Verify the signature
Section titled “Verify the signature”HMAC-SHA256 over <timestamp>.<body> with the raw secret. 300-second replay window. Dedupe on X-HumanAuth-Event-Id.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyHumanAuthWebhook( headers: Record<string, string>, rawBody: 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) || Math.abs(Date.now() / 1000 - ts) > 300) return false; const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b);}import hmac, hashlib, time
def verify_humanauth_webhook(headers: dict, raw_body: bytes, secret: str) -> bool: sig_header = headers.get("x-humanauth-signature", "") parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p) try: ts = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - ts) > 300: return False expected = hmac.new( secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;import java.util.Map;
public static boolean verifyHumanAuthWebhook( Map<String, String> headers, byte[] rawBody, String secret) throws Exception { String sigHeader = headers.get("x-humanauth-signature"); if (sigHeader == null) return false; long ts = 0; String v1 = null; for (String p : sigHeader.split(",")) { String[] kv = p.split("=", 2); if (kv.length != 2) continue; if (kv[0].equals("t")) ts = Long.parseLong(kv[1]); else if (kv[0].equals("v1")) v1 = kv[1]; } if (v1 == null || Math.abs(Instant.now().getEpochSecond() - ts) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((ts + ".").getBytes(StandardCharsets.UTF_8)); mac.update(rawBody); String expected = HexFormat.of().formatHex(mac.doFinal()); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));}Not applicable — signature verification runs in your handler, not the shell. For local development, POST /v1/webhooks/subscriptions/:id/test emits a synthetic _test.ping event.
After verifying, dedupe on X-HumanAuth-Event-Id (the same logical event may retry under a new X-HumanAuth-Delivery-Id). See the Webhooks guide for retry semantics and the rotate-secret overlap window.
4 — Export the audit log as CSV
Section titled “4 — Export the audit log as CSV”Outcome An RFC 4180 CSV stream of every audit event matching your filter — up to 100 000 rows per pull.
GET /v1/audit with Accept: text/csv streams every audit row matching your filter (up to 100 000) as RFC 4180 CSV. Useful for compliance handoffs, quarterly reviews, and offline analysis. Auth is the WorkOS dashboard session, not the tenant API key.
# The session cookie or bearer is from dashboard sign-in (WorkOS).curl "$HA_BASE/v1/audit?event_type=api_key.created&since=2026-01-01" \ -H "Accept: text/csv" \ -H "Authorization: Bearer $HA_SESSION_JWT" \ -o audit-2026-q1.csv# If the file ends with "# truncated at 100000 rows", split your filter# (e.g. by month) and re-run for each window.import { createWriteStream } from "node:fs";import { Readable } from "node:stream";
const res = await fetch(`${process.env.HA_BASE}/v1/audit?event_type=api_key.created`, { headers: { Accept: "text/csv", Authorization: `Bearer ${process.env.HA_SESSION_JWT}`, },});if (!res.ok || !res.body) throw new Error(`audit export failed: ${res.status}`);await new Promise((resolve, reject) => { Readable.fromWeb(res.body as any) .pipe(createWriteStream("audit-2026-q1.csv")) .on("finish", resolve) .on("error", reject);});import httpx, os
with httpx.stream( "GET", f"{os.environ['HA_BASE']}/v1/audit", params={"event_type": "api_key.created", "since": "2026-01-01"}, headers={ "Accept": "text/csv", "Authorization": f"Bearer {os.environ['HA_SESSION_JWT']}", }, timeout=None,) as r: r.raise_for_status() with open("audit-2026-q1.csv", "wb") as f: for chunk in r.iter_bytes(): f.write(chunk)var res = HttpClient.newHttpClient().send( HttpRequest.newBuilder() .uri(URI.create(System.getenv("HA_BASE") + "/v1/audit?event_type=api_key.created")) .header("Accept", "text/csv") .header("Authorization", "Bearer " + System.getenv("HA_SESSION_JWT")) .build(), HttpResponse.BodyHandlers.ofFile(Paths.get("audit-2026-q1.csv")));if (res.statusCode() != 200) { throw new RuntimeException("audit export failed: " + res.statusCode());}For paginated JSON instead of CSV — when you want to ingest into a SIEM rather than archive — drop the Accept: text/csv header and walk next_cursor:
curl "$HA_BASE/v1/audit?limit=100&cursor=$NEXT_CURSOR" \ -H "Authorization: Bearer $HA_SESSION_JWT"CSV header is created_at,event_id,event_type,human_id,api_key_id,resource_type,resource_id,outcome,metadata. Fields with ,, ", CR, or LF are RFC 4180 quoted.
5 — Investigate an authorization incident
Section titled “5 — Investigate an authorization incident”Outcome The full forensic picture for a single request_id — request shape, decisions, receipt, webhook delivery history.
Something looks wrong — a payment went through that shouldn’t have, or a user reported a request as phishing. This recipe fans out from a single request_id to the full forensic picture: request shape, per-approver decisions, the signed receipt, and webhook delivery history.
REQ=req_01HZQR3F7K8N2P5T9V1X3Y5Z7AAUTH="-H Authorization:Bearer $HA_API_KEY"
# 1. The request itself — current state, decisions array, target, plan_hash.curl "$HA_BASE/v1/requests/$REQ" $AUTH
# 2. The receipt JWS — if the request was approved/denied.curl "$HA_BASE/v1/receipts/$REQ" $AUTH
# 3. Webhook deliveries that touched this request.SUB=whsub_01HZQR3F7K8N2P5T9V1X3Y5Z7Acurl "$HA_BASE/v1/webhooks/subscriptions/$SUB/deliveries?event_type=request.decided" $AUTH
# 4. (Dashboard-auth) Audit events around the request time window.curl "$HA_BASE/v1/audit?resource_id=$REQ" \ -H "Authorization: Bearer $HA_SESSION_JWT"async function getJSON(path: string) { const r = await fetch(`${process.env.HA_BASE}${path}`, { headers: { Authorization: `Bearer ${process.env.HA_API_KEY}` }, }); if (!r.ok) throw new Error(`${path} -> ${r.status}`); return r.json();}
export async function investigate(requestId: string, subscriptionId: string) { const [request, receipt, deliveries] = await Promise.all([ getJSON(`/v1/requests/${requestId}`), getJSON(`/v1/receipts/${requestId}`).catch(() => null), // 404 if not decided getJSON( `/v1/webhooks/subscriptions/${subscriptionId}/deliveries?event_type=request.decided`, ), ]); return { request, // status, decisions[], plan_hash receipt, // JWS + approvers + result webhookDeliveries: deliveries.items, // attempts, response codes, latencies };}import asyncio, os, httpx
async def get_json(client: httpx.AsyncClient, path: str): r = await client.get(path) r.raise_for_status() return r.json()
async def investigate(request_id: str, subscription_id: str) -> dict: async with httpx.AsyncClient( base_url=os.environ["HA_BASE"], headers={"Authorization": f"Bearer {os.environ['HA_API_KEY']}"}, ) as client: request, receipt_resp, deliveries = await asyncio.gather( get_json(client, f"/v1/requests/{request_id}"), client.get(f"/v1/receipts/{request_id}"), get_json( client, f"/v1/webhooks/subscriptions/{subscription_id}/deliveries" "?event_type=request.decided", ), ) receipt = receipt_resp.json() if receipt_resp.status_code == 200 else None return { "request": request, "receipt": receipt, "webhook_deliveries": deliveries["items"], }
# asyncio.run(investigate("req_…", "whsub_…"))HttpClient http = HttpClient.newHttpClient();String base = System.getenv("HA_BASE");String auth = "Bearer " + System.getenv("HA_API_KEY");
java.util.function.Function<String, HttpResponse<String>> get = path -> { try { return http.send( HttpRequest.newBuilder() .uri(URI.create(base + path)) .header("Authorization", auth) .build(), HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { throw new RuntimeException(e); }};
String requestId = "req_01HZQR3F7K8N2P5T9V1X3Y5Z7A";String subId = "whsub_01HZQR3F7K8N2P5T9V1X3Y5Z7A";
var request = get.apply("/v1/requests/" + requestId);var receipt = get.apply("/v1/receipts/" + requestId); // may be 404 if not decidedvar deliveries = get.apply( "/v1/webhooks/subscriptions/" + subId + "/deliveries?event_type=request.decided");
System.out.println("request: " + request.body());if (receipt.statusCode() == 200) System.out.println("receipt: " + receipt.body());System.out.println("deliveries: " + deliveries.body());What to look for in each:
GET /v1/requests/{id}—status(approved/denied/expired/cancelled),decisions[](per-human, withassurancelevel),plan_hash,policy_id,rule_satisfied. Ifplan_hashdoesn’t match what your backend hashed, the approval was for a different plan than what executed — that’s the incident root cause.GET /v1/receipts/{id}— the JWS your backend should have received. Decode it (any JWT library) and compareha.approvers[].device_idagainst the human’s registered devices.- Webhook deliveries —
attempt,response_code,response_body(1024-byte truncated). Confirms whether your receiver actually got the event or whether it stalled. - Audit events filtered by
resource_id— every read, write, redelivery, and policy decision the platform recorded against this request.
If the user reported the request as phishing, also pull GET /v1/requests/{id} for the report block and check whether the reporter_huid matches the request’s target_id.
6 — Tail the live event stream
Section titled “6 — Tail the live event stream”Outcome A live SSE stream of every tenant-scoped event, with a documented catch-up path for reconnects.
GET /v1/events is a Server-Sent Events stream of audit-significant events for the active tenant. Use it for live dashboards, on-call incident triage, or piping into stdout-tailing log shippers. Heartbeat every 25s, max lifetime 1h — reconnect and re-fetch missed events via GET /v1/audit?cursor=….
# Stream all event types. Filter with ?stream=request.decided,policy.createdcurl -N "$HA_BASE/v1/events" \ -H "Authorization: Bearer $HA_SESSION_JWT" \ -H "Accept: text/event-stream"Each frame is a standard SSE block:
event: request.decidedid: evt_8c9af2…data: {"request_id":"req_…","action":"payments:transfer","result":"approved",…}// npm i eventsourceimport { EventSource } from "eventsource";
const es = new EventSource(`${process.env.HA_BASE}/v1/events?stream=request.decided`, { fetch: (input, init) => fetch(input, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${process.env.HA_SESSION_JWT}`, }, }),});
es.addEventListener("request.decided", (e) => { const evt = JSON.parse(e.data); console.log("decided", evt.request_id, evt.result);});es.onerror = (err) => { console.error("SSE error, reconnecting in 1s", err); // The library auto-reconnects. On reconnect, replay missed events: // GET /v1/audit?cursor=<last seen event_id> to catch up.};# pip install httpx-sseimport os, json, httpxfrom httpx_sse import connect_sse
headers = { "Authorization": f"Bearer {os.environ['HA_SESSION_JWT']}", "Accept": "text/event-stream",}url = f"{os.environ['HA_BASE']}/v1/events?stream=request.decided"
with httpx.Client(timeout=None) as client: with connect_sse(client, "GET", url, headers=headers) as event_source: for sse in event_source.iter_sse(): evt = json.loads(sse.data) print(sse.event, evt.get("request_id"), evt.get("result"))// Java 11 HttpClient — line-by-line SSE parse. Lightweight; for production// resilience use a library such as OkHttp-EventSource.var req = HttpRequest.newBuilder() .uri(URI.create(System.getenv("HA_BASE") + "/v1/events?stream=request.decided")) .header("Authorization", "Bearer " + System.getenv("HA_SESSION_JWT")) .header("Accept", "text/event-stream") .GET() .build();
HttpResponse<java.util.stream.Stream<String>> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines());
String event = null;var data = new StringBuilder();var iter = res.body().iterator();while (iter.hasNext()) { String line = iter.next(); if (line.isEmpty()) { if (event != null && data.length() > 0) { System.out.println(event + ": " + data); } event = null; data.setLength(0); } else if (line.startsWith("event:")) { event = line.substring(6).trim(); } else if (line.startsWith("data:")) { data.append(line.substring(5).trim()); } // Lines starting with ":" are heartbeat comments — ignore.}On reconnect, the dashboard pattern is: remember the last event_id you processed, then catch up via GET /v1/audit?cursor=<base64url of {created_at, event_id}> before resuming the SSE stream.
See also
Section titled “See also”- Verifier guide — typed errors, Express middleware, MCP tool wrapper, replay-store adapters.
- Webhooks guide — retry semantics, signature rotation, debugging deliveries.
- OpenAPI spec — every endpoint, every parameter, every response shape. Codegen typed clients in Go, Ruby, Rust, C#, etc.
- Security model — threat model and cryptographic primitives behind these recipes.