Guides

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:

  1. 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.
  2. Return 2xx within a couple of seconds and do the real work afterward. neolife treats a slow or non-2xx response 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 4xx and never act on it. neolife will not "fix" a rejected delivery by resending a different one — a 4xx means 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-signature over the raw body; reject on failure.
  • Reject deliveries with a webhook-timestamp outside ±5 minutes.
  • Return 2xx in 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.