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, 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:

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.
  • 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 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 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:the "Send test event" button on the dashboard (or POST /api/v1/developers/me/webhook/test) immediately delivers one signed sample event of type webhook.test with sandbox: true and 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.

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.
  • 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.

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.
  • 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. 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...",
  "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_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.
  • 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.
  • 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}/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.
  • 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.