Building on Lithos · Part 3 of 6

Building on Lithos, Part 3: event-driven integration with webhooks

A care pipeline runs on human time: a physician decides in an hour, a pharmacy fills tomorrow. Products that poll for that state are slow, expensive, and wrong at the moments that matter. Part 3 of the integration series is about building on events instead — the Lithos event sequence, the envelope, the handler pattern, and what to do with them.

TL;DR

Telehealth state changes on human time across systems you do not control, so integrate on events, not polling. Lithos posts every event for your organization to one HTTPS URL per environment: encounter.created, in_review, escalated, approved, rejected, canceled; order.created, processing, completed, canceled; and prior_authorization.created, approved, denied, expired, canceled. Payloads are thin — event id, type, timestamp, and the affected resource id, never the resource — so you fetch the current record with a GET and protected health information never rides the webhook. Handle every event the same way: verify the X-Lithos-Signature HMAC, return a 2xx within ten seconds, enqueue, dedupe on the event id, fetch the resource, apply it to your own state machine, then notify the user. Delivery is at-least-once with exponential retries for 72 hours and no ordering guarantee, so use created_at and the resource’s own status rather than arrival order, and reconcile daily against the list endpoints.

Why polling breaks care products

Three things are true of a care pipeline that are not true of most APIs. The interesting state changes are rare and unpredictable — a physician signs when they sign. They happen in systems you do not control — the clinical team, the pharmacy. And the user is waiting on them with a level of attention no e-commerce order gets. Polling handles none of this well: poll often and you burn requests to learn nothing; poll rarely and the patient learns their treatment was approved an hour after it happened. Events fix all three. The change arrives when it occurs, from the system that owns it, and your product reacts immediately.

The event sequence

Lithos posts every event for your organization to one HTTPS URL per environment, registered during onboarding. Event types are dotted, lower-snake-case names and the set only grows; renames or removals would ship as a new API version.

EventMeaningYour app
encounter.createdIntake accepted; queued for physician pickup (status pending_review)“Submitted — a physician will review shortly”
encounter.in_reviewA physician has the case“Under review”
encounter.escalatedReview paused; read escalation_reasonIf patient_information_required, prompt the patient; if other, Lithos ops resolves it
encounter.approvedPhysician prescribed; orders[] populatedShow the decision; fetch the encounter for prescriptions and orders
encounter.rejectedPhysician declinedShow what happened and the options, with the same care as an approval
encounter.canceledYou pulled the case before review completedClose it out on your side
order.createdLithos created a fulfillment order for one pharmacy (status pending)“Your pharmacy has your order”
order.processingHanded to the pharmacy“Being prepared”
order.completedFulfilledConfirm; start the adherence and follow-up clock from days_supply
order.canceledFulfillment stoppedTell the patient and route to support or a new encounter
prior_authorization.*created · approved · denied · expired · canceledUpdate coverage status; a denial may mean a new PA or a cash-pay path
encounter.createdqueuedencounter.in_reviewphysicianencounter.escalatedinfo neededencounter.completeddecisionorder.createdpharmacyorder.processingorder.completedsupply clockverify signature → ack → enqueue → dedupe on id → GET resource → state machine → notify
The Lithos event sequence a patient-facing product reflects (escalated is the pause for missing information). Handle each the same way — verify the signature, ack, enqueue, dedupe on the event id, fetch the resource, update state, notify — and assume duplicates and out-of-order delivery.

Thin payloads, fetch on receipt

Every delivery is a POST with the same envelope and nothing else:

{
  "id": "evt_test_01HXEV1A2B3C4D5E6F7G8H9JKM",
  "type": "encounter.approved",
  "created_at": "2026-04-30T15:30:00Z",
  "resource_id": "enc_test_01HXEN1A2B3C4D5E6F7G8H9JKM"
}

The resource itself never rides the webhook. To act, read resource_id and call the matching GET — /v1/encounters/{id} for encounter.*, /v1/orders/{id} for order.*, /v1/prior_authorizations/{id} for prior_authorization.* — over your authenticated channel. This is a deliberate design: protected health information stays off the webhook transport, a leaked or replayed delivery reveals only an opaque id, and there is no such thing as a stale snapshot because you always fetch the current record. The resource id prefix tells you the family (enc_, ord_, pa_), and sandbox ids carry a _test_ segment so they are easy to spot in logs.

One handler pattern

Every event goes through the same steps, in this order. Acknowledge before you do work, so a slow database or a downstream outage never causes a retry of a delivery you already have.

  1. Verify the signature. X-Lithos-Signature carries a Unix timestamp t and a hex HMAC v1. Compute HMAC-SHA256 with your signing secret over {t}.{raw_body}, compare in constant time, and reject if it differs or t is more than five minutes old. The signing secret is shown once in the ops console when the endpoint is created.
  2. Acknowledge immediately. Return a 2xx within ten seconds — anything else, or a timeout, counts as a failure and will be retried.
  3. Enqueue. Persist the raw envelope and put the event id on a queue or job table.
  4. Dedupe on the event id. The id field (also in the X-Lithos-Event-Id header) is your idempotency key. Delivery is at-least-once.
  5. Fetch the resource. GET the encounter, order, or prior authorization by resource_id. Act on what comes back, not on the event type alone.
  6. Apply to your state machine, then notify. Accept legal transitions, ignore stale ones, and only message the user after your own state is updated.
import crypto from "node:crypto";

app.post("/webhooks/lithos", async (req, res) => {
  const sig = Object.fromEntries(req.get("X-Lithos-Signature").split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", process.env.LITHOS_SIGNING_SECRET)
    .update(`${sig.t}.${req.rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  if (!fresh || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))) return res.status(400).end();

  const event = JSON.parse(req.rawBody);                        // { id, type, created_at, resource_id }
  await db.events.insertIgnore({ id: event.id, type: event.type, resource_id: event.resource_id });
  res.status(200).end();                                        // ack first, within 10 s
  await queue.enqueue("lithos-event", { id: event.id });        // work later
});

async function handle({ id }) {
  const e = await db.events.get(id);
  if (e.processed_at) return;                                   // at-least-once → dedupe
  const [family] = e.type.split(".");
  const path = { encounter: "encounters", order: "orders", prior_authorization: "prior_authorizations" }[family];
  const resource = await lithosGet(`/v1/${path}/${e.resource_id}`);   // Bearer token from the client-credentials flow
  await applyToStateMachine(family, resource);                  // decide from resource.status, not arrival order
  await db.events.markProcessed(id);
}

Retries, ordering, and reconciliation

Assume every event can arrive twice and some can arrive out of order — both are true. Lithos retries failed deliveries with exponential backoff for 72 hours, and after seven consecutive days in which every attempt fails the endpoint is disabled and Lithos operations is paged. Ordering is not guaranteed. So: dedupe on id, and decide transitions from the fetched resource’s own status and the event’s created_at, never from the order deliveries landed in. An order.processing that arrives after you already saw the order completed is ignored, not applied.

Then reconcile. Once a day, page through GET /v1/encounters and GET /v1/orders for the last few days — list endpoints return next_cursor and has_more — and compare with your state. In a healthy integration the job finds nothing; the day something upstream misbehaves, it is the difference between a quiet fix and a patient asking why the app still says “under review.” The idempotency and webhooks glossary entries cover the mechanics.

Modeling state on your side

Keep your model smaller than the pipeline’s. Your database needs the patient mapping (your external_id to the Lithos patient id), the current encounter status and, on approval, the prescriptions’ days_supply, and the current order status with the fulfilling pharmacy’s name. It does not need the intake, the physician’s decision detail, or the full prescription record — that is protected health information that belongs on Lithos, available by GET when a screen genuinely needs it. Thin webhooks make this the natural design rather than a discipline.

Fan out to operations

The same events that drive the patient’s screen drive your team. Route them by type:

  • Slack or Teams: encounter.rejected, encounter.escalated, order.canceled, prior_authorization.denied — the events that need a human today.
  • CRM: lifecycle transitions — first approval, first completed order, lapsed follow-up — for retention and messaging.
  • Support desk: attach encounter and order status to the user’s profile so “where is my medication” is answered from data, and clinical questions route to the care team rather than support. See telehealth patient support.
  • Analytics: funnel and cohort events with health details stripped — the approval happened; what was prescribed stays out of the warehouse.

Testing in the sandbox

Sandbox lives at api.sandbox.lithoshealth.com, uses its own client credentials and its own webhook URL, and never reaches a real patient, physician, or pharmacy. Before your production credentials are issued, run every path: approved, rejected, escalated with patient_information_required, canceled; order created through completed, and canceled; the same event delivered twice; events delivered in the wrong order; your handler down for ten minutes and catching up on retries. Integrations that test only the approve-and-fulfill path find the rest with real patients.

Lithos delivers every encounter, order, and prior-authorization state change as a signed, thin webhook to the URL you register per environment, with the resource one authenticated GET away. The API reference at docs.lithoshealth.com lists every event and endpoint. Next in the integration series: giving an AI assistant the ability to order care through the MCP server.

Frequently asked questions

What webhook events does Lithos send?

Encounter lifecycle (created, in_review, escalated, approved, rejected, canceled), order lifecycle (created, processing, completed, canceled), and prior authorization lifecycle (created, approved, denied, expired, canceled). Every event for your organization goes to the one HTTPS URL you register per environment during onboarding.

Why are Lithos webhook payloads so small?

They carry only the event id, type, timestamp, and the affected resource id. You fetch the current resource with an authenticated GET. That keeps protected health information off the webhook transport, means a replayed delivery reveals only an opaque id, and guarantees you never act on a stale snapshot.

How do I verify a Lithos webhook signature?

Read the X-Lithos-Signature header, which carries a Unix timestamp t and a hex HMAC v1. Compute HMAC-SHA256 over the string t, a period, and the raw request body using your signing secret, compare in constant time, and reject the delivery if it differs or if t is more than five minutes old.

What if webhooks arrive twice or out of order?

Both happen by design: delivery is at-least-once and ordering is not guaranteed. Store each event id and skip repeats. Decide state transitions from the resource you fetch and its own status, not from arrival order.

How long does Lithos retry a failed webhook?

Any non-2xx response or a timeout past ten seconds counts as a failure. Lithos retries with exponential backoff for 72 hours; after seven consecutive days of failures the endpoint is disabled and Lithos operations is paged. Acknowledge fast and do the work afterward.

Get started

Put a medical practice behind your product

Book a call and we'll scope your program together, or get free sandbox access and start building.

Book a call Get sandbox access

Free to startNo setup or platform fees