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.
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:
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
}
}typeisencounter.completedorencounter.canceledfor outcomes. A third event,encounter.message, fires during review when the physician messages your user — see below. Two more,encounter.pharmacy_orderandencounter.prescription, track what happens at the pharmacy after a prescription is written — see Pharmacy status.rx_countis the number of prescriptions issued —0means 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: trueevents 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
dataalso carries adetails_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.testto that endpoint and reports its response synchronously. The sample'ssandboxflag matches the endpoint's environment (trueon a sandbox endpoint,falseon a production one — with placeholder IDs and nodetails_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.
# 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.
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.
statusis the encounter's current status. During review it readsin_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 withcompleted: the encounter is done and the message is informational, not blocking. Branch onstatus, not on the event type alone.chat_urlis 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 atGET /prescribing/encounters/{id}/detailsand your app can post the patient's replies back withPOST /prescribing/encounters/{id}/messages(text and/or up to 3images[]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.
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:
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_consent → pending_identity → in_review → completed (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:
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_enabledforever — 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. conversationis the patient↔physician chat,clinicalDecisionis the categorical outcome, andprescriptionscarries the medication, quantity, refills, and instructions.- Message text is plain text with line breaks.
conversation[].messageis exactly what was typed: no HTML, no markdown. Line breaks are\n. Render the string withwhite-space: pre-wrap(or split on\n) rather than collapsing or stripping it. - Images.
clinicalLetterImagesare the photos attached to the request, and eachconversationentry may carryimages— the patient's (including ones your app relayed) or the physician's. Each image is{ id, fileName, mimeType, url, urlExpiresAt }. Theurlis a signed link that stops working aturlExpiresAt(about 15 minutes): render it, never store it. Persistid— it is stable, and it matches theimages[]receipt you got when you uploaded — and re-read details whenever you need a fresh link. clinicalDocumentationis 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.
clinicianNamenames 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") andsyncEscalatedreport 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
conversationwith aschedulingUrl— 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 theencounter.messagedoorbell 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, wherebookingis absent after a cancel andbookingskeeps 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
bookingobject — the active visit:status,startAt/endAt(UTC),timezone(the zone the patient chose),clinicianName,bookedAt, andjoinUrl— 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.bookingsis the history (booked and canceled rows, chronological), so a reschedule reads as a canceled row followed by the new one.bookingis absent when nothing is scheduled andjoinUrlappears 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.
pharmacyOrderis the only place the dispensing pharmacy, carrier, and tracking number appear, and each prescription carriespharmacyState(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.typeandfulfillmentStatuspass 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
pharmacyOrderyou can build a fulfillment view against before any real order exists). - Also registered as
GET /developers/me/encounters/{id}/detailsfor 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.
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_pendingresponses. - 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: trueso 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.
messageis 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.
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": "..." }
]
}# 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 atpending_identity; you then applyidentity_verified,physician_message,complete(withrx_count0–5 andoutcomecomplete | incomplete) orcancelyourself. 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(optionalmessagetext; firesencounter.messageand, 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 firesencounter.pharmacy_orderwith the matchingpharmacy_order_status, and the simulated pharmacy/carrier/tracking appear in the details endpoint'spharmacyOrder. Prescription events:prescription_depleted,prescription_expired,prescription_active→encounter.prescription. - Live-visit events (accounts with live visits enabled; offered from the review states):
video_invite(posts the scheduling ask — theschedulingInvitemessage withschedulingUrl— and firesencounter.message),visit_booked(a simulated booking about an hour out; the detailsbookingobject now carriesstartAtandjoinUrl, and the confirmation message firesencounter.message),visit_canceled,video_call_completed/phone_call_completed(the physician records a completed call:visitTypeflips 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) answers409 event_not_availablewith the list. Production keys get404— 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_ordercarriesdata.pharmacy_order_status:placed,rerouted,filling,received,ready,shipped,delivered,picked_up,completed, orcanceled.encounter.prescriptioncarriesdata.prescription_status:depleted(no fills remain),expired, oractive(fillable again, e.g. after an order was canceled). Adepletedprescription 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_urland read thepharmacyOrderobject. - 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.