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.
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.
| Event | Meaning | Your app |
|---|---|---|
encounter.created | Intake accepted; queued for physician pickup (status pending_review) | “Submitted — a physician will review shortly” |
encounter.in_review | A physician has the case | “Under review” |
encounter.escalated | Review paused; read escalation_reason | If patient_information_required, prompt the patient; if other, Lithos ops resolves it |
encounter.approved | Physician prescribed; orders[] populated | Show the decision; fetch the encounter for prescriptions and orders |
encounter.rejected | Physician declined | Show what happened and the options, with the same care as an approval |
encounter.canceled | You pulled the case before review completed | Close it out on your side |
order.created | Lithos created a fulfillment order for one pharmacy (status pending) | “Your pharmacy has your order” |
order.processing | Handed to the pharmacy | “Being prepared” |
order.completed | Fulfilled | Confirm; start the adherence and follow-up clock from days_supply |
order.canceled | Fulfillment stopped | Tell the patient and route to support or a new encounter |
prior_authorization.* | created · approved · denied · expired · canceled | Update coverage status; a denial may mean a new PA or a cash-pay path |
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.
- Verify the signature.
X-Lithos-Signaturecarries a Unix timestamptand a hex HMACv1. Compute HMAC-SHA256 with your signing secret over{t}.{raw_body}, compare in constant time, and reject if it differs ortis more than five minutes old. The signing secret is shown once in the ops console when the endpoint is created. - Acknowledge immediately. Return a 2xx within ten seconds — anything else, or a timeout, counts as a failure and will be retried.
- Enqueue. Persist the raw envelope and put the event id on a queue or job table.
- Dedupe on the event id. The
idfield (also in theX-Lithos-Event-Idheader) is your idempotency key. Delivery is at-least-once. - Fetch the resource. GET the encounter, order, or prior authorization by
resource_id. Act on what comes back, not on the event type alone. - 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 the Journal by email
Our best guides on building compliant telehealth programs — a couple a week, unsubscribe any time.
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.