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
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
| Field | Type | Rule |
|---|---|---|
variantId or variantSku | string | One is required. Must match a catalogue variant, else 404. |
quantity | integer | 1 to 100. Defaults to 1. |
shipToState | string | Two-letter US state code. Required. |
clientOrderId | string | Optional, up to 128 characters. Your id; makes the call idempotent. |
patientRef | string | Optional, up to 128 characters. Your reference, not patient identity. A generated reference is used if omitted. |
prescriber | string | Optional, up to 128 characters. |
recurring | boolean | Optional. Marks the order as a subscription. |
cadenceDays | integer | Used only with recurring. Defaults to 30. Sets the next-fill date. |
details array.What happens between request and response
- 1Idempotency
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.
- 2Variant 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.
- 3Eligibility 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.
- 4Routing
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.
- 5Pricing
The brand's rate card entry for the family gives a product rate and a fulfilment fee; value is their sum times quantity.
- 6Record 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
201 Created:| Field | Value |
|---|---|
orderId | The order's UUID. |
orderNumber | The human-readable order number (ORD-…). |
status | Received, or On Hold when the eligibility gate blocked it. |
eligibility | pass or blocked. |
routedTo, routingReason | The facility name and why it was chosen, for example nearest capable site or lowest load. |
family, value, channel | Product family, priced value from your rate card, and the literal API. |
statusUrl | /api/orders/{orderNumber} for polling. |
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).
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.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
| Event | When | Data |
|---|---|---|
order.received | An API order passed the eligibility gate | orderId, orderNumber, family, qty, shipToState, facility, clientOrderId |
order.held | The gate blocked the order at intake, or staff placed it on hold | orderId, orderNumber, and at intake the same fields as received; on a staff hold, a reason |
order.shipped | The ship station moved the order to Shipped | orderId, orderNumber, carrier, tracking, eta, sscc |
order.delivered | A carrier scan (or staff) delivered the parcel and the order cascaded to Delivered; not while a cold-chain QA hold is open | Order identifiers and delivery details |
recall.initiated | Quality initiated a recall on a lot that includes units fulfilled for your brand | recallNumber, classification, lot, productName |
test.ping | You pressed send test in portal settings | A test payload; never retried |
{ 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

The partner portal

Limits and honest notes
- ✓One line item per call: a call carries one variant and a quantity of 1 to 100. Multi-line baskets are separate calls, each with its own clientOrderId.
- ✓US ship-to only: shipToState must be a two-letter US state code. See the Europe page for what is and is not built for other markets.
- ✓The API does not link an order to a prescription record; clinical review happens in the pharmacy's workflow after intake.
- ✓A recurring order records cadence and next-fill date and shows as overdue when missed; generating the next fill is a staff action today, not an automatic job. An automated refill scheduler is on the roadmap.
- ✓Order intake is rate-limited per API key (120 requests per minute, token bucket in Postgres); a 429 carries Retry-After and should be retried with backoff.
- ✓There is no separate sandbox environment; we issue keys on a demo tenant for integration testing.
Frequently asked questions
What does POST /api/orders return?+
Is the licence check fail-open or fail-closed?+
How is the webhook signed?+
What if our endpoint is down?+
Can a partner read another partner's orders?+
Is patient identity sent to the API?+
How is the order priced?+
Do recurring orders refill themselves?+
What happens to the order after intake: waves, tasks, stations.
The most common API-driven programme: recurring, cold-chain, high volume.
How telehealth brands and clinics work with Pharmacy Flow pharmacies.
Tenancy, row-level security and key scoping.
Outbound at volume from API intake.
Every module the API writes into.