Handle webhooks reliably
A production webhook handler — verify signatures, respond fast, dedupe on event id, and reconcile with the event log after downtime.
Delivery is at-least-once and unordered, so a correct receiver does four things: verifies the signature, returns 2xx fast while doing work async, dedupes on the event id, and reconciles against the event log after any outage. This guide builds a handler that does all four. See Webhooks & events for the endpoint API and event catalog.
Register an endpoint#
Point a webhook endpoint at a route you control. You get a signing secret (whsec_…) exactly once — store it as a secret, one per endpoint.
curl -X POST https://api.neolife.health/v1/developer/webhooks \
-H "Authorization: Bearer $NEOLIFE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8a1f0b2c-3d4e-5f60-7a8b-9c0d1e2f3a4b" \
-d '{ "url": "https://example.com/neolife/webhooks", "events": [] }'
An empty events array subscribes to everything. Every delivery carries three headers you need on the receiving side:
| Header | What it is |
|---|---|
webhook-id |
Stable event id, unchanged across retries — your dedupe key. |
webhook-timestamp |
Unix seconds the event was signed — reject if too old. |
webhook-signature |
v1,<base64> HMAC of id.timestamp.body — may list several during key rotation. |
Verify the signature, then respond fast#
Two rules dominate everything else:
- Verify against the raw request body before you parse or trust it. The signature covers the exact bytes sent. If your framework re-serializes JSON, the HMAC won't match — capture
rawBody. - Return
2xxwithin a couple of seconds and do the real work afterward. neolife treats a slow or non-2xxresponse as a failed delivery and retries with backoff (1m → 5m → 30m → 2h → 8h → 24h), so blocking on downstream work turns one event into a storm of duplicates.
import express from "express";
import { verifyWebhook } from "@neolifehealth/sdk";
const app = express();
// Preserve the raw bytes — the signature is over exactly what was sent.
app.use("/neolife/webhooks", express.raw({ type: "application/json" }));
app.post("/neolife/webhooks", async (req, res) => {
// 1. Verify. Throws on a bad signature or a timestamp outside the ±5 min window.
let event;
try {
event = verifyWebhook({
payload: req.body, // the raw Buffer
headers: req.headers, // webhook-id / webhook-timestamp / webhook-signature
secret: process.env.NEOLIFE_WEBHOOK_SECRET!, // whsec_…
});
} catch {
return res.sendStatus(400); // reject anything that doesn't match — do NOT process it
}
// 2. Dedupe on the event id. At-least-once delivery means you WILL see repeats.
const eventId = req.header("webhook-id")!;
if (await alreadyProcessed(eventId)) {
return res.sendStatus(200); // already handled — ack and stop
}
// 3. Persist the event and ack immediately. Slow work happens off the request path.
await enqueue(eventId, event);
res.sendStatus(200);
});
Reject, don't ignore, a bad signature. An unverified body is untrusted input. Return
4xxand never act on it. neolife will not "fix" a rejected delivery by resending a different one — a4xxmeans the payload was tampered with or your secret is wrong.
If you can't verify without a raw body in your framework, the SDKs expose the same verifyWebhook primitive so you can wire it into any router.
Process the work asynchronously#
Once the event is persisted and acked, do the real work from a queue or worker — never inside the request handler. Payloads are PHI-free (ids and status only), so hydrate anything you need over the authenticated API:
async function handleEvent(event: WebhookEvent) {
switch (event.type) {
case "order.shipped": {
// Fetch tracking / detail over an authenticated GET when you need it.
const order = await neolife.orders.get(event.data.orderId);
await notifyCustomerShipped(order);
break;
}
case "order.rejected":
await flagForReview(event.data.orderId, event.data.reasonCode);
break;
default:
break; // ignore types you don't handle yet — new ones can appear
}
}
Handling must be idempotent by event id. A retry after a worker crash, or an operator replay, can hand you the same event twice — keying side effects on webhook-id (a unique insert, an upsert) makes a second delivery a no-op.
Reconcile after downtime#
Webhooks are best-effort push; the event log is the durable source of truth. If your endpoint was down, deploying, or rate-limited, don't wait for retries to catch you up — pull the log and process anything you missed:
# Everything since the last event id you fully processed.
curl "https://api.neolife.health/v1/developer/events?starting_after=evt_1a2b3c&limit=100" \
-H "Authorization: Bearer $NEOLIFE_API_KEY"
Page with starting_after until the list is empty, run each event through the same deduped handler, and record the last id you finished. Because your handler dedupes, replaying overlap is harmless — reconcile liberally. You can also re-push a specific event to your subscribed endpoints:
curl -X POST https://api.neolife.health/v1/developer/events/evt_1a2b3c/replay \
-H "Authorization: Bearer $NEOLIFE_API_KEY"
A durable cursor (the last processed event id, stored where your worker can read it) is what makes recovery a routine catch-up instead of an incident.
Test locally with the CLI#
You don't need a public URL to build a real handler. neolife listen streams your live, PHI-free events to a local receiver, re-signed for your local secret — so your signature-verification path runs for real:
neolife listen --forward http://localhost:3000/neolife/webhooks
Drive events by moving a sandbox order through its lifecycle, and confirm the full loop: a valid signature verifies and returns 2xx, a tampered body is rejected 4xx, and a duplicate webhook-id is a no-op. When all three hold, you're ready to go live.
Checklist#
- Verify
webhook-signatureover the raw body; reject on failure. - Reject deliveries with a
webhook-timestampoutside ±5 minutes. - Return
2xxin seconds; do downstream work from a queue. - Dedupe every side effect on
webhook-id. - Keep a durable cursor and reconcile against the event log after any outage.