Natzar Partner API Reference - v1.0.0
    Preparing search index...

    Webhooks

    Configure a webhookUrl on your partner account and Natzar POSTs you an event for every consult lifecycle transition and every transcript message — no polling required. Every event is also durably stored before any delivery attempt and replayable via GET /v1/events, so a missed webhook is never a lost event.

    Each delivery is an HTTPS POST to your webhookUrl with a JSON body of shape WebhookEvent:

    {
    "id": "evt_9fK2mQ7wN2pT",
    "type": "async_consult.assigned",
    "createdAt": "2026-08-13T09:31:02.000Z",
    "partnerId": "pa_X8yL3kQ9",
    "data": {
    "consult": { "id": "kQ7wN2pT9xLm", "status": "active", "...": "..." },
    "physicianId": "e5f0c8aa-…"
    }
    }

    Two headers accompany every delivery:

    Header Content
    X-Natzar-Event The event type — route without parsing the body.
    X-Natzar-Signature HMAC signature — see below.

    Rules of engagement:

    • Respond 2xx within 10 seconds. Do your real work asynchronously — enqueue and ack. Anything else (including a timeout) counts as a failed delivery.
    • Retries with backoff. Failed deliveries are retried several times; deliveries that keep failing are eventually parked — the event remains in the replay log (GET /v1/events, with attempts/lastStatus on the stored event showing what happened).
    • Ordering is per consult. Deliveries for the SAME consult are strictly ordered — a failing event blocks later events for that consult until it succeeds or exhausts retries. Different consults deliver independently. Don't let one consult's handler failure back up the rest: ack fast.
    • Deduplicate on id. Retries mean you MAY receive a delivery twice; the envelope id is stable across retries.

    The header has the form:

    X-Natzar-Signature: t=1755077462,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
    

    where v1 is HMAC-SHA256(secret, "<t>" + "." + rawBody) — rawBody being the exact bytes of the request body. Sign-then-parse: verify against the raw body string, never against re-serialized JSON (key order or whitespace differences would break the digest). secret is your webhook signing secret, shown when the webhook is configured.

    Reject the delivery when the digest mismatches OR the timestamp is older than your tolerance (5 minutes is a good default — this bounds replay of captured deliveries).

    Runnable Node verification (Express with a raw-body parser):

    import {createHmac, timingSafeEqual} from 'node:crypto';
    import express from 'express';

    function verifyNatzarSignature(header, rawBody, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=', 2)),
    );
    const t = Number(parts.t);
    if (!Number.isFinite(t) || !parts.v1) return false;
    if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
    const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
    const a = Buffer.from(parts.v1, 'hex');
    const b = Buffer.from(expected, 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
    }

    const app = express();
    // Raw body is REQUIRED for verification — parse JSON only after verifying.
    app.post('/natzar-webhooks', express.raw({type: 'application/json'}), (req, res) => {
    const ok = verifyNatzarSignature(
    req.get('X-Natzar-Signature') ?? '',
    req.body.toString('utf8'),
    process.env.NATZAR_WEBHOOK_SECRET,
    );
    if (!ok) return res.status(400).send('bad signature');
    const event = JSON.parse(req.body.toString('utf8'));
    // Ack first, work later (idempotently, deduplicating on event.id).
    res.sendStatus(200);
    handleEvent(event);
    });

    data payload shapes are documented on WebhookEvent (a discriminated union on type — switch on it and TypeScript narrows the payload). Consult-snapshot events carry the full consult resource as of the event, so handlers are stateless — you never need the previous event to interpret the current one.

    Event When Payload highlights
    async_consult.created Consult created via the API. consult snapshot
    async_consult.consented Patient accepted the consent prompt. consult
    async_consult.declined Patient declined; the consult closed without starting. consult
    async_consult.queued Consented, waiting for physician capacity. consult
    async_consult.assigned A physician took the thread. consult, physicianId
    async_consult.reassigned Thread moved to another physician (SLA breach or takeover). consult, physicianId, previousPhysicianId
    async_consult.message A message landed on the transcript — any author, including system notices. asyncConsultId, message
    async_consult.message_rejected A 202-accepted patient message couldn't be routed because the thread closed underneath it before processing. This is the only rejection cause: messages sent while the thread is still queued (no physician yet) are persisted to the transcript and emit async_consult.message like any other. asyncConsultId, patientId, messageId, reason
    async_consult.resolve_requested Physician marked it resolved; acceptance window running. consult
    async_consult.reopened Patient replied inside the acceptance window; back to active. consult
    async_consult.closed Terminal state reached. consult, closedReason
    async_consult.rated Patient rated the consult. consult, rating
    Event When Payload highlights
    telehealth.created Consult created via the API. consult snapshot
    telehealth.waiting Patient entered the waiting room. consult
    telehealth.ringing A physician was reserved for the waiting patient; the patient's client is confirming it is still there. Not yet a call — an unanswered ring returns the consult to waiting with no event of its own. consult, practitionerId
    telehealth.in_progress A physician joined; the call is live. consult
    telehealth.completed The call ended. consult
    telehealth.cancelled Cancelled before/without a call. consult
    telehealth.recording_ready Composite recording (and transcript, when produced) available. consult
    telehealth.rated Patient rated the call. consult, rating

    Notes:

    • Webhook payloads omit presigned URLs (they are stored durably): attachment urls are absent on async_consult.message, and recordingUrl is absent on telehealth events. React to telehealth.recording_ready by calling GET /v1/telehealth-consults/{id} for a fresh link; re-read GET /v1/async-consults/{id}/messages for fresh attachment links.
    • New event types may be added within /v1 — ignore types you don't recognize.

    The durable event log, oldest first. Every event is written here before any delivery attempt, so polling it is a complete, ordered substitute for (or reconciliation against) webhooks:

    curl -s "$NATZAR_API/v1/events?since=2026-08-13T00:00:00Z&limit=100" \
    -H "Authorization: Bearer $NATZAR_KEY"

    Each stored event carries the same id/type/createdAt/data as the webhook envelope, plus delivery bookkeeping: deliveredAt (last 2xx), attempts (0 when no webhookUrl is configured), lastStatus. Use since to start from a timestamp, then follow nextCursor.

    Typical reconciliation loop after downtime: page /v1/events from your last processed createdAt, apply anything whose id you haven't seen, resume normal webhook consumption.

    That's fine — webhookUrl is optional. Headless integrations can run entirely on polling GET /v1/events; every event appears there regardless of delivery configuration.