Skip to main content

Webhooks & Outcome Polling

Find out when a request reaches its final outcome — push (signed webhook) or pull (polling). Both return the same privacy-safe projection: status, prescription count, and your own identifier — never the patient's identity or medical details. Apps that opt into full data visibility retrieve the interaction separately through the details endpoint (section 5); the webhook stays thin in every mode.

1. Tag the request with your own ID

Pass external_id on the /prescribing/query call — an opaque identifier from your system (an order ID, a row key). It's echoed back verbatim in the webhook and the polling endpoint so you can correlate the outcome without storing our encounter IDs.

Submit with your correlation ID
curl -X POST https://api.appendix.com/api/v1/prescribing/query \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "query": "34F, 3 days of dysuria ...",
    "patient_email": "patient@example.com",
    "patient_phone": "+14155550123",
    "patient_state": "PA",
    "patient_zip": "19103",
    "external_id": "order_8842"
  }'

Never put PII in external_id — no names, emails, conditions, or anything derived from them. It leaves Appendix in webhook payloads; use an opaque key.

2. Configure your webhook

In the developer dashboard, add endpoint URLs under Outcome webhooks. Each endpoint belongs to one environment and gets its own signing secret (whsec_…) you use to verify its deliveries. HTTPS endpoints only.

Environments. Sandbox events — from test keys, the sandbox consent flow, and the lifecycle simulator — are delivered only to your sandbox endpoints. Production events are delivered only to your productionendpoints. They never cross: a simulated outcome can't reach your live receiver, and going live never leaves you without a place to send test traffic. Register up to 5 endpoints per environment; every endpoint in the event's environment receives every event, signed with that endpoint's own secret.

You can add a production endpoint before your account is approved and verify it with a test event; production events start once your production keys are live.

When one of your requests reaches a terminal outcome, we POST:

encounter.completed delivery
POST <your webhook URL>
Content-Type: application/json
X-Appendix-Event: encounter.completed
X-Appendix-Signature: sha256=4f1c...

{
  "id": "evt_7c2e...",
  "type": "encounter.completed",
  "created": "2026-06-10T18:02:11Z",
  "sandbox": false,
  "data": {
    "encounter_id": "b1a2...",
    "external_id": "order_8842",
    "status": "completed",
    "rx_count": 1
  }
}
  • type is encounter.completed or encounter.canceled for outcomes. A third event, encounter.message, fires during review when the physician messages your user — see below. Two more, encounter.pharmacy_order and encounter.prescription, track what happens at the pharmacy after a prescription is written — see Pharmacy status.
  • rx_count is the number of prescriptions issued — 0 means the physician completed the request without prescribing (which can still be a complete review — advice, labs, or imaging). The review fee itself shows up in your credit ledger as a complete or incomplete review.
  • sandbox: true events come from test keys — the sandbox consent flow fires one, and the lifecycle simulator can fire every other type on demand. Never treat them as real outcomes.
  • If your app uses full data visibility, every event's data also carries a details_url — the exact URL to GET (with your API key) for the full interaction once the patient has consented. See section 5.
  • Test it without a full flow:each endpoint on the dashboard has a "Send test event" button that immediately delivers one signed sample event of type webhook.test to that endpoint and reports its response synchronously. The sample's sandbox flag matches the endpoint's environment (true on a sandbox endpoint, false on a production one — with placeholder IDs and no details_url), so a receiver that branches on it sees what real traffic from that endpoint carries. Use it to verify reachability and your signature check — on a production endpoint it is your go-live check; ignore the type in your outcome logic. To test your handling of each real event type, drive a sandbox request through the lifecycle simulator.
  • Respond with any 2xx within 10 seconds. We retry twice (after ~30s and ~2m) on failure, then give up — polling is the fallback.
  • If a production endpoint keeps failing, we email you.Two failed deliveries in a row at least 30 minutes apart (so a single deploy doesn't trigger it) send one email to your account, listing the undelivered events by external_id; one reminder follows if a further delivery fails more than 24 hours later, and one more email when deliveries succeed again. Sandbox endpoints never email — they show a failing badge on the dashboard instead. Endpoints are never disabled automatically.
  • Every delivery's final result (success/failure, HTTP status, attempt count, endpoint) is visible under Submissions & Webhooks in your dashboard for 30 days, filterable by environment.

Testing webhooks from your laptop

Webhook URLs must be https and publicly resolvable — Appendix refuses to deliver to localhost or a private address. To develop your receiver locally, put a tunnel in front of it and register the tunnel's public URL as a sandbox endpoint.

Local development with a tunnel
# 1. run your receiver locally
node server.js                 # listening on :4000

# 2. expose it (any tunnel works; ngrok shown)
ngrok http 4000
#   Forwarding  https://a1b2c3d4.ngrok.app -> http://localhost:4000

# 3. register the PUBLIC url in the dashboard, e.g.
#   https://a1b2c3d4.ngrok.app/webhooks/appendix
# 4. copy the whsec_... secret it returns into your env, then
#    press "Send test event" to confirm the round trip.
  • Add the tunnel URL as a sandbox endpoint in the dashboard, copy the whsec_… secret it returns into your local environment, then press Send test event on that endpoint to confirm the round trip before you run a real request. Your production endpoint is untouched throughout.
  • Free tunnels usually issue a new hostname on every restart — delete and re-add the sandbox endpoint in the dashboard when it changes, or your sandbox events will fail delivery.
  • Verify the signature against the raw request body. Some frameworks parse and re-serialize JSON before your handler sees it, which changes the bytes and breaks the HMAC.
  • Any tunnel works (ngrok, Cloudflare Tunnel, Tailscale Funnel) — nothing about the integration depends on which you pick.

Physician messages: encounter.message

During review, the physician may need to ask your user a question. When that happens we email the patient a secure chat link directly, and we also send your webhook an encounter.message event so your app can surface the conversation in your own UI:

Exception — developer-consented patients. Patients you create yourself under an enterprise agreement (see Developer-Consented Patients) are never emailed by Appendix — the address you supply is a record-keeping label, not a verified way to reach a person. For those encounters the webhook is the only signal that a physician is waiting on an answer, and your app has to carry the message to the patient itself. Plan for that before you build on this flow.

encounter.message delivery
POST <your webhook URL>
Content-Type: application/json
X-Appendix-Event: encounter.message
X-Appendix-Signature: sha256=9a2b...

{
  "id": "evt_3f8a...",
  "type": "encounter.message",
  "created": "2026-06-10T17:21:45Z",
  "sandbox": false,
  "data": {
    "encounter_id": "b1a2...",
    "external_id": "order_8842",
    "status": "in_review",
    "rx_count": 0,
    "chat_url": "https://appendix.com/c/b1a2..."
  }
}
  • The event is a doorbell, not the message. It tells you a physician message exists — never its content, author, or anything clinical. Physician chat is part of the Appendix↔patient care relationship.
  • status is the encounter's current status. During review it reads in_review — the physician is typically waiting on your user's answer to continue. The chat stays open for a short follow-up window after completion, so a message event can also arrive with completed: the encounter is done and the message is informational, not blocking. Branch on status, not on the event type alone.
  • chat_url is the Appendix-hosted secure chat page for your user. Show it to them ("Your physician sent you a message — open secure chat"). The link carries no credential: the patient signs in with their Appendix account (created at consent) or uses the magic link from their email. Opening it yourself shows a sign-in wall, not the chat.
    The field is absent entirely on developer-consented encounters: those patients have no Appendix account to sign in with and get no magic link, so there is no link to give them. Relay the conversation through your own app instead (see Developer-Consented Patients).
  • Your API keys are not accepted on the patient chat endpoints, and the prescribing API never returns a chat credential — so under the default outcome-only visibility your systems cannot read the conversation.
    Apps on full data visibility are the exception: once the patient accepts the data-sharing consent, the conversation is readable at GET /prescribing/encounters/{id}/details and your app can post the patient's replies back with POST /prescribing/encounters/{id}/messages (text and/or up to 3 images[] in the same { url }/{ base64 } form as the query endpoint). The chat credential is still never issued — access runs through your API key on those two endpoints, gated on the patient's consent. See Consent & Identity.

3. Verify the signature

Every delivery is signed: X-Appendix-Signature = "sha256=" + hex(HMAC-SHA256(secret, raw body)). Verify before trusting the payload; rotate the secret from the dashboard if it ever leaks.

Verify the signature (Node.js)
import crypto from "crypto";

function verifyAppendixSignature(rawBody, signatureHeader, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected)
  );
}

4. Or poll

The same outcome is always available by polling — webhooks are the doorbell, polling is the source of truth. Filter by your external_id:

Poll by external_id
curl "https://api.appendix.com/api/v1/prescribing/encounters?external_id=order_8842" \
  -H "Authorization: Bearer ak_live_..."

{
  "encounters": [
    {
      "encounterId": "b1a2...",
      "externalId": "order_8842",
      "status": "completed",
      "rxCount": 1,
      "sandbox": false,
      "createdAt": "2026-06-10T16:40:03Z",
      "submittedAt": "2026-06-10T16:55:41Z",
      "hasClinicianMessage": true,
      "lastMessageAt": "2026-06-10T17:21:45Z",
      "chatUrl": "https://appendix.com/c/b1a2..."
    }
  ]
}

status values: awaiting_consentpending_identity in_reviewcompleted (terminal), or canceled (terminal).

hasClinicianMessage / lastMessageAt / chatUrl mirror the encounter.message doorbell for pollers: a physician message exists for your user, and here's the credential-free hosted chat page to send them to. Message content is never included.

5. Full visibility: retrieving the interaction

By default your app sees outcomes only (see the next section). If your use case requires reading the interaction back — care coordination, records your user expects inside your app — switch Patient data visibility to Full visibility on the dashboard's consent-modal card. The webhook and polling payloads stay exactly as thin as shown above — but each full-visibility event includes a details_url pointing here, so your receiver doesn't need to construct the URL itself. When the doorbell rings, fetch the interaction:

Fetch the full interaction
curl "https://api.appendix.com/api/v1/prescribing/encounters/b1a2.../details" \
  -H "Authorization: Bearer ak_live_..."

{
  "encounterId": "b1a2...",
  "externalId": "order_8842",
  "status": "completed",
  "dataVisibility": "full",
  "dataSharingConsentAt": "2026-06-10T16:52:18Z",
  "patient": { "firstName": "Ada", "lastName": "Lovelace", "dateOfBirth": "1992-03-14", "sex": "female", "state": "PA" },
  "clinicalLetter": "## Chief complaint\n\nDysuria...",
  "clinicalLetterImages": [
    { "id": "3f9c...", "fileName": "rash.jpg", "mimeType": "image/jpeg",
      "url": "https://api.appendix.com/api/v1/images/3f9c...?exp=...&sig=...", "urlExpiresAt": "2026-06-10T17:07:18Z" }
  ],
  "conversation": [
    { "author": "clinician", "clinicianName": "Sarah Chen, MD", "message": "Two quick questions:\n\n1. Any fever?\n2. Any back pain?", "createdAt": "..." },
    { "author": "patient", "message": "No, neither. Here is the rash today.", "createdAt": "...", "viaApp": true,
      "images": [ { "id": "8a12...", "mimeType": "image/jpeg", "url": "https://api.appendix.com/api/v1/images/8a12...?exp=...&sig=...", "urlExpiresAt": "..." } ] },
    { "author": "clinician", "clinicianName": "Sarah Chen, MD",
      "message": "Your physician would like to speak with you by video call...",
      "schedulingUrl": "https://appendix.com/schedule/b1a2...?token=...", "createdAt": "..." }
  ],
  "clinicalDecision": "rx_as_requested",
  "clinicalDocumentation": [
    { "note": "Uncomplicated cystitis. No fever, flank pain, or red flags...", "clinicianName": "Sarah Chen, MD", "createdAt": "..." }
  ],
  "prescriptions": [
    { "medication": "Nitrofurantoin 100mg", "quantity": "10 capsules", "refills": 0, "clinicianName": "Sarah Chen, MD", "instructions": "One capsule twice daily for 5 days", "pharmacyState": "depleted" }
  ],
  "rxCount": 1,
  "booking": {
    "status": "booked", "startAt": "2026-06-12T15:00:00Z", "endAt": "2026-06-12T15:15:00Z",
    "timezone": "America/New_York", "clinicianName": "Sarah Chen, MD",
    "joinUrl": "https://…/join", "bookedAt": "2026-06-11T14:20:31Z"
  },
  "bookings": [ { "status": "booked", "startAt": "...", "endAt": "...", "joinUrl": "...", "bookedAt": "..." } ],
  "pharmacyOrder": {
    "status": "placed",
    "updatedAt": "2026-06-11T14:08:52Z",
    "type": "MAIL_ORDER",
    "fulfillmentStatus": "SHIPPED",
    "carrier": "USPS",
    "trackingNumber": "9400...",
    "pharmacy": { "name": "Example Pharmacy", "street1": "1617 Market St", "city": "Philadelphia", "state": "PA", "postalCode": "19103" }
  }
}
  • The mode is fixed per request, at creation. Flipping the dashboard setting applies to new requests only. A request created under outcome-only visibility answers 403 data_visibility_not_enabled forever — the patient consented to those terms, and they don't change retroactively. Likewise, requests created under full visibility stay readable even if you later switch back.
  • The patient consents to it explicitly.Under full visibility, the consent modal discloses exactly what your app can see — request details, demographics, the physician conversation, the physician's clinical documentation, prescription details — and requires a dedicated acknowledgment naming your app. Until the patient accepts, the endpoint answers 403 consent_pending.
  • conversation is the patient↔physician chat, clinicalDecision is the categorical outcome, and prescriptions carries the medication, quantity, refills, and instructions.
  • Message text is plain text with line breaks. conversation[].message is exactly what was typed: no HTML, no markdown. Line breaks are \n. Render the string with white-space: pre-wrap (or split on \n) rather than collapsing or stripping it.
  • Images. clinicalLetterImages are the photos attached to the request, and each conversation entry may carry images— the patient's (including ones your app relayed) or the physician's. Each image is { id, fileName, mimeType, url, urlExpiresAt }. The url is a signed link that stops working at urlExpiresAt (about 15 minutes): render it, never store it. Persist id — it is stable, and it matches the images[] receipt you got when you uploaded — and re-read details whenever you need a fresh link.
  • clinicalDocumentation is the reviewing physician's own documentation of this request — their assessment, reasoning, and plan, in their words. This is the clinical record of what Appendix did for the patient and why, which is what you want if you hold the patient's broader record. It is a list because a physician may document more than once on a request (an interim entry when they ask the patient a follow-up question, then a final entry at completion); entries are ordered oldest first. Expect it to be empty until a physician has reviewed the request.
  • Every clinical record is attributed. clinicianName names the reviewing physician on each conversation message they sent, each documentation entry they wrote, and each prescription they issued ("Sarah Chen, MD"). It is per-item rather than per-request because a request can be reopened and finished by a second physician. The field is omitted rather than guessed when the author isn't known — patient messages never carry it, and prescriptions issued before Appendix recorded the prescriber per-prescription have none.
  • Live visits. For certain enterprise applications, Appendix can support escalating a request to a synchronous phone or video conversation with the patient. For those accounts, visitType ("async" / "video" / "phone") and syncEscalated report whether the physician took the request to a live visit, which bills at its own rate. These two appear on polling rows as well, and only on accounts with live visits enabled — see Pricing. Everyone else's reviews are async, so the fields are omitted rather than sent as a constant. They are deliberately absent from webhook payloads too: the webhook is a doorbell, so read modality here or from polling.
  • Scheduling a live visit. When the physician invites the patient to a video call, the invite appears in conversation with a schedulingUrl— a short-lived, login-free booking page where the patient picks a time (name and date of birth are shown there; no clinical content). For developer-consented patients your app is the patient's only channel, so your integration must surface this link to the patient (open it in a browser view or send it through your own messaging). The field rides only the invite message and is omitted once the link expires, so never cache it — re-read the details when the encounter.message doorbell rings. Booking confirmations and call reminders arrive as further conversation messages. The same link is where the patient reschedules or cancels: either action posts a conversation message (so the doorbell rings) — re-read the details, where booking is absent after a cancel and bookings keeps the canceled row. There is no API to book, reschedule, or cancel on the patient's behalf; surfacing the link is the whole integration.
  • The scheduled visit, structured. Once the patient books, the details carry a booking object — the active visit: status, startAt/endAt (UTC), timezone (the zone the patient chose), clinicianName, bookedAt, and joinUrl— the link the patient uses to join the call at that time. So your app can render “your video visit is at 11:00 AM” and, when the time comes, “tap to join” — without parsing chat text. bookings is the history (booked and canceled rows, chronological), so a reschedule reads as a canceled row followed by the new one. booking is absent when nothing is scheduled and joinUrl appears only on an active booking. Remind the patient of the load-bearing rule: they must join the call — they will not receive a phone call. Both fields appear only on accounts with live visits enabled.
  • Where the prescription went. pharmacyOrder is the only place the dispensing pharmacy, carrier, and tracking number appear, and each prescription carries pharmacyState (depleted, expired, active). Every field is optional and the whole object is absent until the pharmacy reports something, so treat it as progressively filled rather than guaranteed. type and fulfillmentStatus pass through from the pharmacy network verbatim — read them as opaque strings, not a fixed enum.
  • Available from consent onward — you can also poll it mid-review; it reflects the current state. Sandbox requests from a test key work end to end (the sandbox consent stamps the acknowledgment and returns simulated content, including a simulated pharmacyOrder you can build a fulfillment view against before any real order exists).
  • Also registered as GET /developers/me/encounters/{id}/details for the dashboard. Identity-verification artifacts (verified identity documents and their extracted fields) are never included, in either mode.

6. Full visibility: replying on the patient's behalf

Reading the conversation is only half a channel. When the physician asks your user a question, full visibility also lets your app send the answer back, so the whole exchange can happen inside your product instead of sending the user to an Appendix-hosted page. This is the only supported reply path for developer-consented patients, who cannot sign in to the hosted chat at all.

Relay a patient reply
curl -X POST \
  "https://api.appendix.com/api/v1/prescribing/encounters/b1a2.../messages" \
  -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "message": "No fever, and the pain is only when urinating." }'

{
  "messageId": "d7f4...",
  "createdAt": "2026-06-10T17:40:12Z",
  "author": "patient",
  "viaApp": true
}
  • The gate is identical to the details read: you must own the request, it must have been created under a full visibility snapshot, and the patient must have accepted the data-sharing consent. Otherwise you get the same 404 / data_visibility_not_enabled / consent_pending responses.
  • The message is recorded as the patient's reply — you are relaying for them, not joining the conversation as a third party. It is labelled for the physician as having come through your app, and comes back from the details endpoint with viaApp: true so you can tell your own posts from messages the patient sent directly.
  • Only send what your user actually said. The physician is making clinical decisions on these words, and the patient consented to you relaying their replies, not to your software answering medical questions for them.
  • message is plain text (up to 4,000 characters) and may contain line breaks.
  • Replying moves the request back to the physician's queue, the same as a patient replying in the hosted chat. Closed requests return 409 encounter_closed; start a new request for a new clinical issue.
  • Set expectations about response time. Physician chat is asynchronous — a physician reads and replies between reviews, not live. Wherever your app renders the conversation or a reply box, show the patient a notice such as “Chat is not real-time. Please allow 24 hours for a response.” If you have agreed a specific response-time commitment with Appendix, state that time frame instead. Pair it with the standing emergency guidance: this channel is not for urgent problems — if something may be an emergency, call 911.

Testing every event: the sandbox lifecycle simulator

A sandbox request can be driven through each stage of its life — and fire the matching webhook at every step — from the Submissions page (expand a test request) or with your test key against the endpoints below. Each event does to the sandbox request exactly what production would: the state changes, prescriptions and pharmacy-routing details are recorded, and then the webhook goes out through the normal delivery path. So after every step the webhook you received, the polling projection, and the details endpoint agree — which is what you are actually testing. Nothing reaches a physician or a pharmacy.

What can happen next?
curl "https://api.appendix.com/api/v1/prescribing/sandbox/encounters/b1a2..." \
  -H "Authorization: Bearer ak_test_..."

# 200 OK
{
  "encounterId": "b1a2...",
  "status": "in_review",
  "fullDataVisible": true,
  "webhookUrlSet": true,
  "rxCount": 0,
  "availableEvents": [
    { "name": "physician_message", "group": "lifecycle", "label": "Physician sends a message", "webhook": "encounter.message", "description": "..." },
    { "name": "complete",          "group": "lifecycle", "label": "Physician completes the review", "webhook": "encounter.completed", "description": "..." },
    { "name": "cancel",            "group": "lifecycle", "label": "Cancel the request", "webhook": "encounter.canceled", "description": "..." }
  ]
}
Advance a request (test key)
# complete the review with two simulated prescriptions …
curl -X POST "https://api.appendix.com/api/v1/prescribing/sandbox/encounters/b1a2.../advance" \
  -H "Authorization: Bearer ak_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "event": "complete", "rx_count": 2, "outcome": "complete" }'

# … then walk the order to the patient's door
for ev in order_placed order_filling order_shipped order_delivered prescription_depleted; do
  curl -s -X POST "https://api.appendix.com/api/v1/prescribing/sandbox/encounters/b1a2.../advance" \
    -H "Authorization: Bearer ak_test_..." -H "Content-Type: application/json" \
    -d "{"event": "$ev"}"
done

# each 200 response reports what happened and what's possible next
{
  "encounterId": "b1a2...",
  "applied": "order_delivered",
  "status": "completed",
  "rxCount": 2,
  "webhook": { "type": "encounter.pharmacy_order", "fired": true, "note": "Delivery is asynchronous — check the delivery log in a few seconds." },
  "availableEvents": [ ... ]
}
  • Two sandbox behaviors (dashboard → API Keys → Sandbox behavior). By default a test request auto-completes the moment its consent is accepted — one simulated prescription, one encounter.completed — which is the simplest first test and what every existing integration expects. Switch to manual progression and the accept stops at pending_identity; you then apply identity_verified, physician_message, complete (with rx_count 0–5 and outcome complete | incomplete) or cancel yourself. Pharmacy and prescription stages work on any completed test request under either setting.
  • Lifecycle events: consent_accepted (also available programmatically, so a test suite need not open the consent page), identity_verified, physician_message (optional message text; fires encounter.message and, from review, moves the request to waiting-for-reply so a relayed reply via the messages endpoint exercises that transition too), complete, cancel.
  • Pharmacy events (completed requests with at least one prescription): order_placed, order_rerouted, order_filling, order_shipped, order_delivered (mail order, with a simulated carrier and tracking number), order_received, order_ready, order_picked_up (retail pickup), order_completed, order_canceled. Each fires encounter.pharmacy_order with the matching pharmacy_order_status, and the simulated pharmacy/carrier/tracking appear in the details endpoint's pharmacyOrder. Prescription events: prescription_depleted, prescription_expired, prescription_activeencounter.prescription.
  • Live-visit events (accounts with live visits enabled; offered from the review states): video_invite (posts the scheduling ask — the schedulingInvite message with schedulingUrl — and fires encounter.message), visit_booked (a simulated booking about an hour out; the details booking object now carries startAt and joinUrl, and the confirmation message fires encounter.message), visit_canceled, video_call_completed / phone_call_completed (the physician records a completed call: visitType flips in the polling and details projections; no webhook, as in production; a video call releases a booked visit). Simulated bookings are invisible to clinicians and never send calendar invites or reminders.
  • The same visibility rule as production applies: pharmacy and prescription webhooks fire only for requests created under full data visibility. On an outcome-only test request the stage is still recorded and the response says why nothing was sent — set Data visibility to “full” and create a new test request to see them.
  • GET …/sandbox/encounters/{id}returns the events that make sense from the current status; an event that doesn't (delivering a prescription on a request that was never completed) answers 409 event_not_available with the list. Production keys get 404 — the simulator is a test-key surface, and it only ever touches your own sandbox requests.
  • Also registered as /developers/me/sandbox/encounters/{id}[/advance] for the dashboard. Webhook delivery is asynchronous (with the usual retries); the delivery log on the Submissions page shows the result a few seconds later.

Pharmacy status: encounter.pharmacy_order and encounter.prescription

A written prescription still has to reach a pharmacy and get filled. These two events track that, and fire only on requests created under full data visibility whose patient acknowledged data sharing — the outcome-only disclosure covers status, prescription count, and the message doorbell, and pharmacy routing sits outside it.

  • encounter.pharmacy_order carries data.pharmacy_order_status: placed, rerouted, filling, received, ready, shipped, delivered, picked_up, completed, or canceled.
  • encounter.prescription carries data.prescription_status: depleted (no fills remain), expired, or active (fillable again, e.g. after an order was canceled). A depleted prescription has no fills left to dispense; it does not mean the member has finished the medication. On a prescription written with no refills, it may arrive within seconds of the prescription itself.
  • The pharmacy's name and address, the carrier, and the tracking number are never in the webhook payload. They live behind the details endpoint instead. Follow details_url and read the pharmacyOrder object.
  • Expect duplicates and out-of-order arrival. Pharmacy status reaches us from the pharmacy network and we pass it through as it arrives, without reordering or deduplicating on your behalf. Treat the polling projection and the details endpoint as the source of truth and these events as a doorbell, exactly as with outcomes.

Not every pharmacy reports every stage. Retail pickup and mail order expose different milestones, and some report none at all beyond "sent". Build for the stages you receive rather than expecting a fixed sequence — and exercise each one against a test key with the lifecycle simulator.

What you receive under outcome-only visibility (the default)

Appendix — not your app — holds the patient relationship. Under the default outcome-only mode, webhook and polling responses never include the patient's verified identity, diagnosis, clinical decision detail, medication names, physician messages, or chat content — and the details endpoint is not available. The patient is told exactly this in the consent modal: your app learns the outcome status, the prescription count, and whether a physician message is waiting — nothing more. Under full visibility the disclosure changes accordingly, the patient acknowledges it explicitly, and your app reads the interaction through the details endpoint above; the webhook itself stays thin in both modes.