Guides
Verify every webhook
Every event NezerCare sends carries a signature over the raw request body. Check it before you parse anything: an unverified webhook is just a request from the internet.
Add an endpoint in the dashboard under Integrations. Its signing secret is shown once, when you create it. Sandbox and production have separate endpoints and secrets.
Headers
| Header | What it holds |
|---|---|
x-nezer-event-id | A UUID for this event. Retries reuse it, so deduplicate on it. |
x-nezer-event-type | The event, such as clinical_case.opened. |
x-nezer-timestamp | When the request was signed, in seconds since the epoch. |
x-nezer-signature | v1= and the HMAC-SHA256, in lowercase hex. |
idempotency-key | The same value as x-nezer-event-id. |
Check the signature
The signature is the HMAC-SHA256 of <timestamp>.<raw body>, keyed with the endpoint's signing secret. Sign the raw bytes of the body, not JSON you parsed and serialized again, which can reorder keys or change spacing. Reject a timestamp more than five minutes from now.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromNezer(
rawBody: string,
headers: Record<string, string>,
signingSecret: string,
): boolean {
const timestamp = headers["x-nezer-timestamp"] ?? "";
const provided = headers["x-nezer-signature"] ?? ""; // "v1=<hex>"
// Seconds since the epoch; refuse anything more than 5 minutes off.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!/^\d+$/.test(timestamp) || age > 300) return false;
const hmac = createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const expected = Buffer.from(`v1=${hmac}`);
const given = Buffer.from(provided);
return given.length === expected.length && timingSafeEqual(given, expected);
}Compare in constant time
Use timingSafeEqual or your language's equivalent, never ===. A plain comparison leaks timing that lets an attacker guess the signature byte by byte.
Deduplicate
Once the signature checks out, look up x-nezer-event-id among the events you've already handled. NezerCare retries a delivery that doesn't get a 2xx, with backoff, so the same event can arrive more than once. Your handler must be safe to run twice.
Respond quickly
Return 2xx as soon as the event is stored durably (a queue row, a database write), then do the slow work (creating a ticket, notifying a clinician) afterwards. A handler that waits on slow work is treated as failing and retried.
Event bodies can contain protected health information. Don't write them to ordinary logs.
Events
| Event | Sent when |
|---|---|
clinical_case.opened | A check-in, a patient's concern, or a request your team made of @Nezer in Slack needs a clinician. Carries the answers, the matched red flags, the priority and the source (staff_slack when your team raised it, with no patient notes). |
clinical_case.patient_message_created | The patient wrote in a case conversation. Holds the patient's words. |
clinical_case.acknowledged | Your team acknowledged the case in NezerCare. Not the same as your processed update. |
clinical_case.resolved | A case reached its final resolved state. |
billing.usage_threshold_reached | Use reached 80%, 95% or 100% of the month's credits. No patient data. |
subscription.updated | Your subscription changed state. No patient data. |
Until your organization signs its BAA, clinical-case events are held, not dropped, and go out once it's signed. Account events are never held. Payloads are in the webhook events reference.