Niah Docs
Core APIs

Webhooks

Get notified the moment long-running work finishes, instead of polling. Subscribe an HTTPS endpoint to consultation.* and batch.* events.

Events

EventFires when
consultation.completedA consultation finishes successfully
consultation.failedA consultation fails
batch.completedAll items in a batch complete
batch.failedA batch fails entirely
batch.partial_failureA batch completes with some failed items

Two delivery modes

  • Registered configs via POST /v1/webhooks — fan out to every enabled config whose events list includes the event. Signed with that config's secret (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 stored key_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

bash
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"
  }'
The response includes a 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

HeaderValue
Content-Typeapplication/json
x-niah-signaturesha256=<hex> HMAC of the exact raw body
x-niah-eventEvent name, e.g. consultation.completed
x-niah-deliveryUnique 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.

node
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);
}
python
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 "")
elixir
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 || "")
end

Payload shape

The body is a flat JSON object. consultation.* events send:

json
{
  "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:

json
{
  "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/webhooksList configs
put/v1/webhooks/{id}Update (URL, events, enabled)
delete/v1/webhooks/{id}Delete