Webhooks
Get notified the moment long-running work finishes, instead of polling.
Subscribe an HTTPS endpoint to consultation.* and
batch.* events.
Events
| Event | Fires when |
|---|---|
consultation.completed | A consultation finishes successfully |
consultation.failed | A consultation fails |
batch.completed | All items in a batch complete |
batch.failed | A batch fails entirely |
batch.partial_failure | A batch completes with some failed items |
Two delivery modes
-
Registered configs via
POST /v1/webhooks— fan out to every enabled config whoseeventslist includes the event. Signed with that config'ssecret(returned once at create time; 64 lowercase hex characters). This is what you want in almost every case. -
One-off URLs on a consultation or batch
(
webhook_url) — preserved for compatibility, and scoped to that single object's terminal event. Signed with the SHA-256 hex digest of the creating API key (the storedkey_hash), not a webhook config secret.
The two modes fire independently. An object created with
webhook_url whose event also matches a registered config
produces two deliveries — each signed with its own secret.
Register an endpoint
post /v1/webhooks
curl -X POST https://api.niah.si/v1/webhooks \
-H "Authorization: Bearer $NIAH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/niah",
"events": ["consultation.completed", "batch.completed"],
"description": "Prod pipeline"
}'secret once. Store it —
you'll use it to verify signatures and it can't be retrieved later.
Secrets are raw hex strings (no whsec_ prefix).
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
x-niah-signature | sha256=<hex> HMAC of the exact raw body |
x-niah-event | Event name, e.g. consultation.completed |
x-niah-delivery | Unique delivery UUID |
Verify signatures
Signatures use HMAC-SHA256 over the exact raw request body bytes
(do not re-serialize JSON). The header value includes the
sha256= prefix — compare the full header string, not bare hex.
There is no timestamp component in this revision.
Capture the body before your framework parses it:
express.raw({ type: "application/json" }) in Express,
request.get_data() in Flask, or a custom
body_reader that stashes the body in Phoenix. Verifying
against a re-encoded payload will not match.
import crypto from "node:crypto";
export function valid(secret, rawBody, headerSig) {
const digest = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const expected = `sha256=${digest}`;
const a = Buffer.from(expected);
const b = Buffer.from(headerSig || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac
def valid(secret: str, raw_body: bytes, header_sig: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
return hmac.compare_digest(expected, header_sig or "")def valid?(secret, raw_body, header_sig) when is_binary(secret) do
expected =
"sha256=" <>
(:crypto.mac(:hmac, :sha256, secret, raw_body) |> Base.encode16(case: :lower))
byte_size(expected) == byte_size(header_sig || "") and
Plug.Crypto.secure_compare(expected, header_sig || "")
endPayload shape
The body is a flat JSON object. consultation.* events send:
{
"consultation_id": "0f2c7d1e-8a4b-4c2f-9d33-6b1e5a7c9f04",
"status": "completed",
"title": "Fraud review",
"response_count": 3,
"target_agent_count": 3,
"completed_at": "2026-07-30T12:00:00Z"
}batch.* events send:
{
"batch_id": "b7a1c9e2-4f60-4a18-9c7d-2e5f8b3a1d06",
"status": "partial_failure",
"total_items": 100,
"completed_items": 97,
"failed_items": 3,
"completed_at": "2026-07-30T12:04:31Z"
}completed_at is null when no completion timestamp
was recorded. Treat unknown top-level fields as additive — more may be added
over time.
Delivery & retries
Respond with a 2xx quickly (do heavy work asynchronously) — we
wait up to 30 seconds for a response. Any non-2xx status,
timeout, or connection error is retried up to 4 attempts total,
with backoff of 10s → 60s → 300s between them. After the
fourth failure the delivery is abandoned, though its attempt history stays
queryable.
Every attempt carries the same body and signature but a fresh
x-niah-delivery value, so deduplicate on the payload's
consultation_id or batch_id and keep handlers
idempotent.
Inspect delivery history — status codes, errors, and timing — via the API or the workspace debugger:
get /v1/webhooks/deliveries
Deep-link a specific delivery in the product debugger with
?tab=webhooks&delivery_id=<id> on
API Platform.
Manage
| get | /v1/webhooks | List configs |
| put | /v1/webhooks/{id} | Update (URL, events, enabled) |
| delete | /v1/webhooks/{id} | Delete |