Home / Telehealth order API
Telehealth order API

The order API, documented from the code that runs it

One endpoint to submit an order, one to poll it, and six signed webhook events so your platform never has to poll. Every rule below is a line in the route handler, not a slide.

Telehealth platforms integrate with a pharmacy by API or not at all. Pharmacy Flow gives each partner brand its own key, a single POST /api/orders endpoint, a GET for status, and a webhook that tells the partner when an order is received, held, shipped, delivered or recalled. This page documents the request, the response, the rules applied between them and the delivery guarantees, as they exist in the route handlers today.

Authentication and scope

Every partner brand has an API key. Send it as x-api-key or as Authorization: Bearer. The key resolves to the brand, its tenant, its channel and its rate card; a missing or unknown key returns 401. Everything the key can do is scoped to that brand: it can create orders for the brand and read only the brand's own orders. The brand belongs to one pharmacy tenant, so the order is attributed to that tenant explicitly on the service path.

The request

POST /api/orders body. Invalid JSON returns 400; failed validation returns 400 with a details array listing every problem.
FieldTypeRule
variantId or variantSkustringOne is required. Must match a catalogue variant, else 404.
quantityinteger1 to 100. Defaults to 1.
shipToStatestringTwo-letter US state code. Required.
clientOrderIdstringOptional, up to 128 characters. Your id; makes the call idempotent.
patientRefstringOptional, up to 128 characters. Your reference, not patient identity. A generated reference is used if omitted.
prescriberstringOptional, up to 128 characters.
recurringbooleanOptional. Marks the order as a subscription.
cadenceDaysintegerUsed only with recurring. Defaults to 30. Sets the next-fill date.
Validation is collected, not short-circuited: a body with a bad quantity and a bad state gets both messages back in one details array.

What happens between request and response

  1. 1
    Idempotency

    If clientOrderId is present and this brand has already used it, the existing order is returned with status 200 and idempotent: true. No second order is ever created for a retried call.

  2. 2
    Variant resolution

    The variant id or SKU is looked up in the catalogue and its formulation's product family is read. The family drives routing and pricing.

  3. 3
    Eligibility gate, fail closed

    The bill of materials is checked for any controlled component. The tenant's licence for the ship-to state is read. No licence on file, an expired or non-active licence, or a controlled product into a state that does not permit compounded controlled substances, all put the order On Hold with eligibility blocked and a written note. The order is still created, so you can see it and we can resolve it.

  4. 4
    Routing

    Only facilities that compound the family qualify. A brand pinned to a capable facility wins; otherwise a capable facility in the ship-to state; otherwise the least-utilised capable facility on trailing three-day load. The reason is returned to you.

  5. 5
    Pricing

    The brand's rate card entry for the family gives a product rate and a fulfilment fee; value is their sum times quantity.

  6. 6
    Record and notify

    The order is inserted with channel API, a sequential order number, and, if recurring, a subscription id, cadence, refill number 1 and next-fill date. An event is written to the tenant's log and an order.received (or order.held) webhook is queued and sent.

The response

A new order returns 201 Created:
FieldValue
orderIdThe order's UUID.
orderNumberThe human-readable order number (ORD-…).
statusReceived, or On Hold when the eligibility gate blocked it.
eligibilitypass or blocked.
routedTo, routingReasonThe facility name and why it was chosen, for example nearest capable site or lowest load.
family, value, channelProduct family, priced value from your rate card, and the literal API.
statusUrl/api/orders/{orderNumber} for polling.
A repeated clientOrderId returns 200 with idempotent: true and the existing order's id, number, status and eligibility. Errors are 400 (JSON or validation, with details), 401 (key), 404 (variant) and 500 (insert failed; safe to retry with the same clientOrderId).
Order detail in the pharmacy app showing an API-channel order with eligibility result, routing reason and status history
What the pharmacy sees for an API order: channel, eligibility note, routing reason and the status history your webhooks are derived from.

Polling status

GET /api/orders/{ref} accepts the order number or the UUID, authenticated with the same key, and only finds orders belonging to the key's brand. It returns orderId, orderNumber, status, eligibility, channel, shipToState, quantity, value, tracking (null until shipped), facility, sku and createdAt. Status values follow the pharmacy's order lifecycle: Received, Eligibility, Clinical Review, In Production, Packed, Shipped, Delivered, On Hold, Cancelled. The lifecycle is enforced by a database trigger, so a status you read has been through the allowed graph.
Get a sandbox key

We will issue a key on a demo tenant, walk one order from POST to the order.shipped webhook, and show you the delivery log on our side.

Webhook events and signature

EventWhenData
order.receivedAn API order passed the eligibility gateorderId, orderNumber, family, qty, shipToState, facility, clientOrderId
order.heldThe gate blocked the order at intake, or staff placed it on holdorderId, orderNumber, and at intake the same fields as received; on a staff hold, a reason
order.shippedThe ship station moved the order to ShippedorderId, orderNumber, carrier, tracking, eta, sscc
order.deliveredA carrier scan (or staff) delivered the parcel and the order cascaded to Delivered; not while a cold-chain QA hold is openOrder identifiers and delivery details
recall.initiatedQuality initiated a recall on a lot that includes units fulfilled for your brandrecallNumber, classification, lot, productName
test.pingYou pressed send test in portal settingsA test payload; never retried
The body is { event, data, sentAt, deliveryId }. Headers: X-PharmacyFlow-Event, X-PharmacyFlow-Delivery (the outbox id, for deduplication on your side), X-PharmacyFlow-Signature and a User-Agent of PharmacyFlow-Webhooks/1.1. The signature is sha256= followed by an HMAC-SHA256 of the raw request body under your shared secret, the same scheme Stripe and GitHub use. Verify it against the raw bytes before parsing. Secrets are prefixed whsec_, are generated by us, and can be rotated from the portal at any time.

Delivery, retries and health

Every event is written to an outbox row first, then one delivery attempt runs immediately with an eight-second timeout, so the happy path is as fast as a direct call. A non-2xx response or a timeout leaves the row queued with a backoff of one minute, then five, fifteen, one hour, then six hours four times; after nine attempts the row is marked dead. A scheduled worker delivers due rows oldest-first. If you disable webhooks while events are queued, they are retired rather than delivered later. Every attempt, successful or not, is stored with its status code and error, and a per-brand health view shows queued, dead, sent in the last 24 hours, last sent time and last error; you see it in your portal settings and the pharmacy sees it in theirs.
Partner portal home with order counts, recent shipments and webhook delivery health
The partner portal: the brand's own orders, shipments, subscriptions, billing and webhook delivery health.

The partner portal

Not every partner starts with an integration. The portal gives a brand's users the same orders through a browser: submit an order (the same eligibility gate and routing run), track it, see shipments and subscriptions, browse the catalogue they are contracted for, read billing and performance, open a support case and manage the webhook endpoint (URL, enable, rotate secret, send test, delivery health). Portal users hold the Partner role, and row-level security limits them to their brand. Many integrations begin with the portal and move to the API once volume justifies it. See the partner programme and, for the fulfilment side, GLP-1 fulfilment and mail-order pharmacy.
Partner portal orders list with status, facility, tracking and next fill for recurring orders
Portal orders: status, facility, tracking and next-fill date, scoped to the brand.

Limits and honest notes

How orders become tasks on the floor is on the fulfilment automation page; how a shipped unit is traced is on the compounding software page and in the serialisation guide; the tenancy model behind key scoping is on security & compliance. Growth advice for the intake bottleneck is in how to scale a compounding pharmacy.

Frequently asked questions

What does POST /api/orders return?+
201 with orderId, orderNumber, status (Received or On Hold), eligibility (pass or blocked), routedTo, routingReason, family, value, channel and a statusUrl. A repeated clientOrderId returns 200 with idempotent: true and the existing order.
Is the licence check fail-open or fail-closed?+
Fail-closed. No licence on file for the ship-to state, an expired or non-active licence, or a controlled product into a state that does not allow it, all create the order On Hold with eligibility blocked and a written reason. An order.held webhook is sent.
How is the webhook signed?+
X-PharmacyFlow-Signature carries sha256= followed by an HMAC-SHA256 of the raw body under the brand's whsec_ secret. Verify against the raw bytes before JSON parsing. Rotate the secret from the portal.
What if our endpoint is down?+
The event stays in the outbox and is retried at 1 minute, 5, 15, 1 hour, then 6 hours four times, then marked dead. X-PharmacyFlow-Delivery carries a stable id so you can deduplicate retries.
Can a partner read another partner's orders?+
No. GET /api/orders/{ref} filters by the key's brand, so an order number from another brand returns 404. Portal users are limited to their brand by row-level security.
Is patient identity sent to the API?+
No field for it exists. patientRef is your reference, up to 128 characters, and is stored as given. Prescriber is a free-text name.
How is the order priced?+
From the brand's rate card entry for the product family: product rate plus fulfilment fee, times quantity. The value is returned in the response and visible in the portal's billing view.
Do recurring orders refill themselves?+
Not yet. The order records cadence, refill number and next-fill date and appears in Subscriptions with an overdue flag; staff generate the next fill. An automated scheduler is on the roadmap.
Keep reading

See Pharmacy Flow on your own workflow

A working platform, not a slide deck. Book a walkthrough and we'll run a real order from intake to carrier lane — or explore the public directory first, no account needed.

Free 14-day trial on signup · Free to claim a directory listing · Log in