Send patient-specific compounded prescriptions from your telehealth platform or practice system to licensed U.S. compounding pharmacies that list their clinic prices: search listings, place and cancel orders, follow their status and receive signed webhooks. Build against test mode first, a sandbox with the built-in Compounding Finder Test Pharmacy. Nothing reaches a real pharmacy until your account is live.
A JSON REST API over HTTPS at https://www.compoundingfinder.com/api/v1.
Server to server only. Your keys and webhook secrets must stay on your servers, never in a browser or a mobile app.
Money is in integer cents (USD): 3900 is $39.00. Times are ISO 8601 in UTC.
IDs carry a prefix: co_ orders, lst_ listings, whe_ webhook endpoints, evt_ events. Order numbers such as CO-7K3M-Q9T2 are for people.
Every order is a 503A prescription for one named patient. Controlled substances are refused, and GLP-1 orders need a documented medical necessity reason and our review.
Quick start
Sign in to your clinic dashboard and add your practice details (API orders print your practice name, phone and address on the prescription). Then open Developers, accept the API terms and create a test key.
Check the key, search, place a test order and move it along:
Shell
# A test key from your clinic dashboard (Developers)
export CF_API_KEY="cfk_test_..."
# 1. Check the key
curl https://www.compoundingfinder.com/api/v1/account \
-H "Authorization: Bearer $CF_API_KEY"
# 2. Find a listing that ships to your patient's state
curl "https://www.compoundingfinder.com/api/v1/medications?query=naltrexone%204.5&state=TX" \
-H "Authorization: Bearer $CF_API_KEY"
# 3. Place a test order (order.json holds the body under "Create an order")
curl https://www.compoundingfinder.com/api/v1/orders \
-H "Authorization: Bearer $CF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e8a-4b7d-4e8a-9c5f-1d2e3f4a5b6c" \
--data @order.json
# 4. Move it along as the pharmacy would (test keys only)
curl https://www.compoundingfinder.com/api/v1/test/orders/co_4n7cq2m8w1ht6r9x3kd5/advance \
-H "Authorization: Bearer $CF_API_KEY" \
-H "Content-Type: application/json" \
--data '{}'
Then add a webhook endpoint and watch the events arrive as you advance the order. When you are ready for real orders, ask for live access from your dashboard and create a live key.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer cfk_test_...
Test keys start cfk_test_ and live keys cfk_live_, followed by 43 characters. The key decides the mode of every request made with it.
Create and revoke keys on the Developers page of your clinic dashboard, after accepting the API terms. A key is shown once, when you create it. An account can have 10 active keys.
Live keys can be created once live ordering is turned on for your account.
A missing, malformed, unknown or revoked key gets 401 unauthorized. If a key leaks, revoke it and create a new one.
Test and live mode
Test mode and live mode compared
What differs
Test mode
Live mode
Keys
cfk_test_
cfk_live_
Pharmacies
The Compounding Finder Test Pharmacy only
Pharmacies that list for clinics
Orders reach a pharmacy
Never
Yes, by email and in its portal
Emails
None
To the pharmacy and to your practice
Webhooks
To your test endpoints
To your live endpoints
Who moves orders along
You, from the advance endpoint or your dashboard
The pharmacy
Patient details
Made up only
Real
Live orders need all of these. Until then, live requests get the matching error:
An approved prescriber profile (prescriber_not_verified).
Your practice's signed Business Associate Agreement, at the current version (baa_required).
Live access, which you ask for from your dashboard and we turn on (live_not_enabled).
Live ordering not paused across Compounding Finder (live_paused).
While any of these is missing, a live key can still read your account, list and read orders, cancel an order and list or delete webhook endpoints, so you can always see and stop what you have. Retrying an order you already placed, with the same Idempotency-Key and body within 24 hours, still returns that order. Everything else, a new live order included, answers 403 with the first missing step as the code and all of them in error.data.blockers. A key only ever sees its own mode: a test key gets 404 not_found for a live order or listing, and the other way round.
Requests and responses
Send request bodies as a JSON object with Content-Type: application/json, up to 1 MB. Fields we do not use are ignored.
Every response is JSON and carries Cache-Control: no-store and an X-Request-Id header. Include the request id when you contact us about a request.
Lists come in one envelope, paged by cursor (see Pagination):
Errors share one shape. Match on code: message is written for people and may change. issues lists each problem field, and type links here: https://www.compoundingfinder.com/for-clinics/api#errors.
Response: 422 Unprocessable Entity
{
"error": {
"type": "https://www.compoundingfinder.com/for-clinics/api#errors",
"code": "invalid_order",
"message": "Check the order details.",
"issues": [
{
"field": "patient.dob",
"code": "invalid",
"message": "Enter the date of birth as YYYY-MM-DD."
},
{
"field": "items[0].sig",
"code": "required",
"message": "Enter the directions for use (sig)."
}
],
"data": {}
}
}
data is empty unless the code carries detail: orderId for order_already_exists; itemsCents, shippingCents, totalCents, expectedTotalCents and lines (each item's listingId, unitPriceCents, priceCents and shippingCents, priced now) for price_changed; reason, listingId and state for pharmacy_cannot_fill; reasons for necessity_reason_rejected; listingId for listing_unavailable; blockers for the live-mode codes.
Error codes
Status and code
What it means
invalid_request400
The request could not be read: malformed JSON, a parameter of the wrong type, or a value we do not accept.
unauthorized401
The API key is missing, malformed, unknown or revoked.
forbidden403
Your account or key is not allowed to do this.
live_not_enabled403
Live ordering is not turned on for your account yet. Ask for live access from your clinic dashboard.
live_paused403
Live ordering is paused for every clinic for now. Try again later.
prescriber_not_verified403
Your prescriber profile has not been approved yet.
baa_required403
Your practice has not accepted the current Business Associate Agreement.
api_terms_required403
Accept the current API terms on the Developers page of your clinic dashboard.
test_mode_only403
This endpoint only works with a test key.
not_found404
There is no such object on your account in this key's mode.
idempotency_key_reused409
This Idempotency-Key was used in the last 24 hours with a different request body.
order_already_exists409
You already placed an order with this externalOrderId. error.data.orderId is that order.
order_in_progress409
Another request with this externalOrderId is still being placed. Nothing was placed, and no order is named because that request may still fail: try again in a few seconds.
price_changed409
The order total is no longer expectedTotalCents. error.data has the current totals and each item's current prices.
invalid_transition409
The order cannot move to that status from the one it is in, or it changed while you were changing it. Read it again before retrying.
cannot_cancel409
The order has shipped or is already finished, so it cannot be cancelled.
invalid_order422
Fields are missing or invalid. error.issues lists each one.
controlled_substance422
A medication is a controlled substance. Controlled substances are not accepted.
not_compoundable422
A medication is on the list of drugs that may not be compounded.
necessity_reason_rejected422
The medical necessity reason does not describe a patient-specific clinical need. The error says why.
pharmacy_cannot_fill422
The pharmacy cannot fill this order for this patient, for example because it does not ship to the patient's state.
listing_unavailable422
A listing is no longer available. Search again.
mixed_pharmacies422
The items come from more than one pharmacy. Place one order per pharmacy.
invalid_webhook_url422
The webhook URL breaks one of the rules under Webhooks, or is already an endpoint in this mode.
too_many_webhook_endpoints422
You already have 5 webhook endpoints in this mode.
rate_limited429
Too many requests. Wait for the number of seconds in Retry-After.
internal_error500
Something went wrong on our side. Retry with the same Idempotency-Key.
service_unavailable503
A service we rely on did not answer, and nothing was changed. Retry with the same Idempotency-Key.
Rate limits
300 requests a minute for each key.
30 requests a minute from one IP address with a missing or invalid key.
Adding webhook endpoints: 20 an hour for each account, counted together with the Developers page. Refused attempts count.
Order requests for each account: 500 a day in live mode and 200 a day in test mode. Each day starts at midnight UTC. The limit counts requests to place an order, not orders placed: a request refused for a problem with the order, such as invalid_order or price_changed, still counts. A replay (the same Idempotency-Key and body) does not.
Past a limit you get 429 rate_limited, with a Retry-After header giving the seconds to wait before trying again.
Pagination
List endpoints take limit (1 to 100, default 25) and startingAfter. When hasMore is true, pass the response's nextCursor as startingAfter to get the next page. Cursors are opaque: do not build or change them.
Networks fail, and a retried order must never become two prescriptions. Two things protect you, and you can use both:
Idempotency-Key header on POST /api/v1/orders: 1 to 255 visible ASCII characters, unique to the order, such as a UUID. For 24 hours, the same key with the same body returns the original order (200 OK, Idempotent-Replayed: true) instead of placing a new one. The same key with a different body gets 409 idempotency_key_reused. Keys are kept per account and mode.
externalOrderId in the body: your own ID, at most one order per ID in each mode, with no time limit. A second order with it gets 409 order_already_exists, and error.data.orderId is the order you already placed. While the first request with that ID is still being placed, a second one gets 409 order_in_progress instead. It names no order, because the first request may still fail: try again in a few seconds. A request that was cut off before it finished holds its ID for up to 2 minutes.
To guard against a price change between showing a total and placing the order, send expectedTotalCents. If the total has moved, nothing is placed and you get 409 price_changed with the current totals in error.data, and each item's current prices in error.data.lines:
Response: 409 Conflict
{
"error": {
"type": "https://www.compoundingfinder.com/for-clinics/api#errors",
"code": "price_changed",
"message": "The total for this order is now $129.00. Review it and place the order again.",
"issues": [],
"data": {
"itemsCents": 11700,
"shippingCents": 1200,
"totalCents": 12900,
"expectedTotalCents": 12000,
"lines": [
{
"listingId": "lst_sbx_ldn45",
"unitPriceCents": 3900,
"priceCents": 11700,
"shippingCents": 1200
}
]
}
}
}
Listings you can order: one medication, strength, form and package from one pharmacy each. Test keys see only the test pharmacy; live keys see every pharmacy that lists for clinics. Listings whose name starts with your query come first, then they are sorted by name, strength and price.
Query parameter
Description
querystring, optional
Words to search for, up to 200 characters. Every word must appear in the medication name, strength, form or pharmacy name.
statestring, optional
The patient's two-letter state. Only listings from pharmacies that ship there are returned.
Places a patient-specific order with one pharmacy. The mode comes from your key, never the body. Send an Idempotency-Key header so a retry cannot place a second order. A new order returns 201 Created with a Location header, in status submitted, or needs_review when it needs our review first (any GLP-1 order, for example). A replayed request returns 200 OK with the same order and Idempotent-Replayed: true.
Body field
Description
externalOrderIdstring, optional
Your own ID for the order, up to 128 letters, digits and . _ : - characters. One order per ID in each mode: another order with it gets 409 order_already_exists, or 409 order_in_progress while the first request with it is still being placed.
patientobject, required
firstName, lastName, dob (YYYY-MM-DD), sex (female, male, other, unspecified), email, phone (10-digit US), address1, address2, city, state (two letters: the 50 states and DC), zip (5 digits), allergies and weight. All are required except sex (default unspecified), address2 and weight. Names are full names, not initials. A ZIP code outside the 50 states and DC is refused; one usually in another state holds the order for our review (a few ZIP codes cross state lines). Send allergies as the drug allergies the patient has, or "NKDA" when there are none: a blank value is refused, so a field your system never fills cannot print as no known allergies. One allergy per line is kept as "Penicillin; Sulfa". The prescription prints English letters (accents are fine), digits and common punctuation: text in another script, or a symbol such as an emoji, is refused with unsupported_characters on that field, never printed blank.
itemsarray, required
1 to 3 items, all listings from one pharmacy, each listing once.
items[].listingIdstring, required
The id of a medication from Search medications.
items[].countinteger, optional
Packages of that listing, 1 to 12. Default 1. The prescription quantity is count times the package, for example 3 x 30 capsules = 90 capsules.
items[].sigstring, required
Directions for use, up to 600 characters.
items[].refillsinteger, optional
0 to 11. Default 0.
items[].daysSupplyinteger, optional
1 to 365.
items[].notesstring, optional
Compounding notes for this item, up to 1,200 characters.
items[].substitutionAllowedboolean, optional
Default false.
medicalNecessityReasonstring
Required, at least 20 characters, when any item has requiresMedicalNecessityReason: true (GLP-1 medications): the documented, patient-specific clinical reason. Those orders start in needs_review.
shipTostring, optional
patient (default) or clinic. A clinic shipment goes to your practice address.
billTostring, optional
clinic (default) or patient: who the pharmacy bills.
billingEmailstring, optional
Where the pharmacy sends the bill when your clinic pays (billTo clinic). Defaults to your practice email. When the patient pays it is not used, and the order returns it empty.
notesToPharmacystring, optional
Up to 1,000 characters.
expectedTotalCentsinteger, optional
The total you showed or approved, in cents. If the current total differs, nothing is placed and you get 409 price_changed.
prescriberobject, required
The prescriber who signed this prescription: npi (10 digits), firstName and lastName (full names, A to Z), credentials (optional; one of MD, DO, NP, PA, DNP, APRN, CNM, PharmD, DDS, DMD, DPM, OD, ND, DVM, Other), licenseState, licenseNumber, phone (optional; defaults to your practice phone) and email (optional; defaults to your practice email).
signatureAttestationtrue, required
Under the API terms, true confirms that the named prescriber personally reviewed and signed this prescription in your system, holds the license given, and has determined it may be sent this way under their state's e-prescribing rules.
Your orders in the key's mode, newest first, as full order objects. With a filter, a page can hold fewer orders than limit while hasMore is still true: keep following nextCursor until it is null.
Query parameter
Description
statusstring, optional
One or more statuses, separated by commas, such as submitted,processing.
externalOrderIdstring, optional
The order you placed with this ID.
updatedAfterstring, optional
An ISO 8601 time with a time zone, such as 2026-10-26T15:00:00Z: only orders changed after it.
Before the pharmacy accepts the order (needs_review or submitted), it is cancelled at once: 200 OK, status cancelled. Once the pharmacy has accepted it (processing), this sends a cancel request instead: 202 Accepted, the status stays processing and cancelRequestedAt is set. The pharmacy then confirms it (order.cancelled) or refuses it (order.updated, with cancelRequestedAt cleared). A shipped or finished order returns 409 cannot_cancel.
Body field
Description
notestring, optional
Why you are cancelling, up to 1,000 characters. The pharmacy sees it.
Adds an endpoint in the key's mode. The response is the only time you see its signing secret: store it where your receiver can read it. See Webhooks for the URL rules.
Test keys only (403 test_mode_only with a live key). Moves a test order the way the pharmacy would, and sends the same webhooks. With no to, the order takes the next step on the usual path. See Testing.
Body field
Description
tostring, optional
processing, shipped, delivered, failed or cancelled.
What every order endpoint returns. It holds the patient's details, so treat API responses as protected health information. Webhooks never carry them.
Field
Description
id, orderIdstring
The order id (co_...). Both fields hold the same value.
orderNumberstring
The number people see, such as CO-7K3M-Q9T2. The pharmacy quotes it too.
versioninteger
Goes up by one with every change.
modestring
test or live.
sourcestring
api or portal.
externalOrderIdstring or null
Your own ID, if you sent one.
slugstring
Your account id.
practiceobject
Your practice as it was when the order was placed.
prescriberobject
The signing prescriber. npiCheck.status is our NPI registry check: verified, mismatch, not_found, invalid, unavailable or skipped.
patientobject
The patient, as stored from your request.
pharmacyobject
id, name, phone, city and state of the pharmacy filling it.
itemsarray
Each item with its medication, packageQuantity, count, rxQuantity (what the prescription says to dispense), sig, refills, daysSupply, notes, substitutionAllowed, restricted (GLP-1), and its prices: unitPriceCents, priceCents and shippingCents.
medicalNecessityReasonstring
The reason you gave, or an empty string.
shipTo, shipToAddressstring, object
Who it ships to, and the address.
billTo, billingEmailstring
Who the pharmacy bills, and where.
notesToPharmacystring
Your notes to the pharmacy.
totalsobject
itemsCents, shippingCents (null when the pharmacy confirms shipping) and totalCents, priced when the order was placed.
feeCents, feeScheduleinteger, string
Compounding Finder's fee for this order (0 today) and the fee schedule it was placed under.
status, statusLabelstring
See Statuses. statusLabel is the wording we show people.
statusHistoryarray
Every change: status, at, actor (system, clinic, pharmacy, admin or test_simulator) and note. Entries that record something other than a status change carry an action, such as cancel_requested or shipment_added.
failureReasonstring or null
Why a failed order failed. See Statuses.
declineReason, declineNotestring or null, string
The pharmacy's reason and note when it declined.
cancelRequestedAt, cancelNotestring or null, string
Set while a cancel request waits for the pharmacy.
reviewobject
required, the reasons the order needed review, and when it was decided.
shipmentsarray
carrier, trackingNumber, trackingUrl and shippedAt for each shipment.
acceptedAt, shippedAt, deliveredAt, cancelledAt, failedAtstring or null
When the order reached each status.
pharmacyNotifiedAt, reminderSentAtstring or null
When we emailed the pharmacy about the order, and reminded it. Always null for test orders.
rxobject
The signed prescription: fileName, renderedAt, stateNotice and the signature (signedName, attested, signedAt, via).
pdfUrlnull
Always null in the API. Download the prescription PDF from the order page in your clinic dashboard.
We are reviewing the order, for example a GLP-1 order's medical necessity reason. A new order in review has not gone to the pharmacy; one we took back for review may already have been seen by it.
submittedSent to pharmacy
The pharmacy has the order and has not accepted it yet.
processingAccepted by pharmacy
The pharmacy accepted the order and is preparing it.
shippedShipped
The pharmacy added a shipment with a tracking number. It can add more.
deliveredDelivered, final
The pharmacy, or your practice in the portal, marked the order delivered.
cancelledCancelled, final
You cancelled it before the pharmacy accepted it, or the pharmacy confirmed your cancel request.
failedNot filled, final
The order will not be filled. failureReason says why.
How an order moves
Status changes
From and to
When
needs_review to submitted
Compounding Finder approves the order.
needs_review to failed
Compounding Finder does not approve it (failureReason review_rejected).
needs_review to cancelled
You cancel it.
submitted to processing
The pharmacy accepts it.
submitted to failed
The pharmacy declines it (failureReason pharmacy_declined, with a declineReason).
submitted to cancelled
You cancel it. The pharmacy is told.
processing to shipped
The pharmacy adds the first shipment.
processing to failed
The pharmacy cannot fill it (failureReason pharmacy_unable_to_fill).
processing to cancelled
The pharmacy confirms your cancel request.
shipped to delivered
The pharmacy, or your practice in the portal, marks it delivered.
delivered, cancelled and failed are final. Compounding Finder can also correct an order's status, always with a note; you get the event for the new status.
Failure and decline reasons
failureReason
Description
review_rejected
We did not approve the order in review.
pharmacy_declined
The pharmacy declined it before accepting it. See declineReason and declineNote.
pharmacy_unable_to_fill
The pharmacy accepted it but could not fill it.
admin
Compounding Finder closed the order, with a note.
declineReason
Description
out_of_stock
Out of stock or can't source it
cannot_ship_to_state
Can't ship to the patient's state
prescription_issue
Problem with the prescription
price_changed
Price has changed
other
Other
Webhooks
We POST an event to each of your endpoints in the order's mode whenever an order changes. Add endpoints on the Developers page or with the API. Events carry no patient details: fetch the order with your key when you need them.
Endpoint rules
An https:// URL on the standard port (443), up to 2,048 characters.
A public hostname. Addresses on private, loopback, link-local or reserved networks are refused, when you add the endpoint and again on every delivery.
No username, password or # part in the URL. Redirects are not followed.
Up to 5 endpoints in each mode, each URL once.
Events
type
Description
order.needs_review
An order is waiting for our review: a new one, or one we took back from the pharmacy.
order.submitted
The order went to the pharmacy: a new order, or one we approved in review.
order.processing
The pharmacy accepted the order.
order.shipped
The pharmacy added the first shipment.
order.delivered
The order was marked delivered.
order.cancelled
The order was cancelled.
order.failed
The order will not be filled. See failureReason and declineReason.
order.updated
Something changed without a status change: another shipment, a cancel request or its refusal, or a note from us.
webhook_endpoint.test
A test event you sent from the Developers page. data.endpoint.id names the endpoint.
Payload
Event body (pretty-printed; we send it as compact JSON)
CF-Signature is t= the Unix time in seconds, then v1= the hex HMAC-SHA256 of the string t + "." + raw body, keyed with your endpoint's whole secret (whsec_ included). There may be more than one v1 value; accept the event when any of them matches.
Verifying a signature (Node.js)
JavaScript
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/compounding-finder', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('CF-Signature') || '';
if (!verifySignature(process.env.CF_WEBHOOK_SECRET, req.body, header)) {
return res.status(400).send('Bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer within 10 seconds, then do the work
handleEvent(event); // skip event.id values you have already handled
});
function verifySignature(secret, rawBody, header, toleranceSeconds = 300) {
let timestamp = '';
const signatures = [];
for (const part of header.split(',')) {
const at = part.indexOf('=');
if (at === -1) continue;
const name = part.slice(0, at).trim();
const value = part.slice(at + 1).trim();
if (name === 't') timestamp = value;
if (name === 'v1') signatures.push(value);
}
if (!/^\d+$/.test(timestamp) || signatures.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Use the raw body exactly as received. Parsing and re-serialising the JSON changes the bytes and the signature will not match.
Reject a t more than 300 seconds from your clock, so an old event cannot be replayed.
Deduplicate on CF-Event-Id (the event's id): a delivery can arrive more than once.
Events can arrive out of order. Compare data.order.updatedAt, or fetch the order for its latest state.
Delivery and retries
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds (3 seconds for the first attempt, made the moment the order changes). Anything else is retried about 1, 5 and 15 minutes, then 1, 3, 6, 12 and 24 hours after each failed attempt (retries go out from a scheduled job, so a retry can come several minutes later): nine attempts over about two days. After that the delivery is marked failed. The Developers page shows recent deliveries and sends test events (webhook_endpoint.test), which are not retried.
Testing
Test keys order from the Compounding Finder Test Pharmacy (pharmacy id ph_cf_sandbox, Austin, TX), which ships to every state. Its listings have ids starting lst_sbx_. Test orders go through the same checks, statuses and webhooks as live ones, but never reach a pharmacy and send no emails. Their prescription PDF is watermarked as a test order, not a valid prescription. Use made-up patient details only.
No to: the next step on the usual path. A test order that needs review is approved first.
processing, shipped, delivered: as the pharmacy accepting it, shipping it (carrier TEST, a tracking number that starts TEST-) and marking it delivered. A later target walks through every step on the way, each with its own webhook.
failed: as if we rejected it in review, the pharmacy declined it or, once accepted, the pharmacy could not fill it.
cancelled: cancels it. Once the order is accepted, this stands for the pharmacy confirming your cancel request.
A move the order cannot make from where it is, such as advancing a delivered order, gets 409 invalid_transition.
To try a cancel request, advance an order to processing, cancel it (202 Accepted), then advance it to cancelled.
Questions about the API, or ready to go live? Email contact@compoundingfinder.com with your account id and any X-Request-Id.