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:
GET /v1/events, with attempts/lastStatus on the
stored event showing what happened).id. Retries mean you MAY receive a delivery twice;
the envelope id is stable across retries.X-Natzar-SignatureThe 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:
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./v1 — ignore types you don't
recognize.GET /v1/eventsThe 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.