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, set your endpoint URL under Outcome webhook. We mint a signing secret (whsec_…) you use to verify deliveries. HTTPS endpoints only.
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.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 consent-flow sandbox fires one so you can verify your handler end-to-end). 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:the "Send test event" button on the dashboard (or
POST /api/v1/developers/me/webhook/test) immediately delivers one signed sample event of typewebhook.testwithsandbox: trueand reports your endpoint's response synchronously. Use it to verify reachability and your signature check; ignore the type in your outcome logic. - Respond with any 2xx within 10 seconds. We retry twice (after ~30s and ~2m) on failure, then give up — polling is the fallback.
- Every delivery's final result (success/failure, HTTP status, attempt count) is visible under Submissions & Webhooks in your dashboard for 30 days.
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.
# 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.- Register the tunnel URL in the dashboard, copy the
whsec_…secret it returns into your local environment, then press Send test event to confirm the round trip before you run a real request. - Free tunnels usually issue a new hostname on every restart — update the webhook URL in the dashboard when it changes, or your 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.
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. 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...",
"conversation": [
{ "author": "clinician", "clinicianName": "Sarah Chen, MD", "message": "Any fever or back pain?", "createdAt": "..." },
{ "author": "patient", "message": "No, neither.", "createdAt": "...", "viaApp": true }
],
"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" }
],
"rxCount": 1
}- 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.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. - 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).
- 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.
- 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.
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.