One POST /v1/orders request creates the order, signs it for the prescriber and sends it to the
pharmacy. There is no draft, sign or submit step in the API.
A signed order keeps its medication, shipping service and dispensing pharmacy. Later catalog changes do not change what the pharmacy receives. Order reads keep the shipping option name and price saved at signing.
An order has one practice, one patient, one prescriber and 1 to 20 prescriptions. All prescriptions go to one pharmacy.
Each prescription returns its pharmacy, status and nullable failure reason.
PerfectRx prescriptions use the same status and failure reason as their order.
All prescriptions in one order must go to one pharmacy. Send a separate order for each pharmacy.
The same medication may appear more than once. Each line is a separate prescription with its own price.
To order more than one fill of a medication, send one line with a larger quantity.
Orders use /v1/orders, not a route under a practice.
Platform keys must send practiceId. Missing practiceId returns 422 with the path practiceId.
A practice key can omit practiceId; Affinity uses that practice.
An archived or unknown practice returns 404 not_found and cannot take new orders. Use an active practice that your organization owns.
Schema validation runs before ordering rules. A 400 response can list missing body fields before
the API checks practiceId and prescriber. After those fields are corrected, a platform request
without practiceId, or any API key request without prescriber, returns 422 invalid_order.
Send these fields with the first request.
A practice key that names another practice gets 404.
The order list still accepts GET /v1/orders?practiceId=….
Before you send
- Get the clinician’s approval of this exact order in your product.
- Choose a medication for each prescription.
- Make sure that the practice has an address and a phone number. The pharmacy needs them. Without
them, the order returns
422with the pathpractice.addressorpractice.phone.
Prescriber contact details
Send the prescriber’s NPI and name in prescriber.
You can also send prescriber.phone and prescriber.address.
Each omitted contact field uses the practice’s corresponding value.
A null or blank prescriber.phone also uses the practice phone.
Affinity checks the NPI format and check digit only.
It does not check the name against the NPI registry.
It does not look up an account by NPI.
App orders use the practice’s address and phone. The member’s phone and address in Settings → Profile do not go to the pharmacy.
Affinity stores the effective contact details when it signs the order. Later practice edits do not change that snapshot.
Send the request
Use this synthetic example in Test mode. Replace practiceId with your practice ID.
Use Test outcomes to test a failed order or a refused cancel.
Read the current catalog before you send the request.
Use the medication ID, an accepted quantity, and a shipping option ID from the same catalog item.
This example uses PerfectRx Tirzepatide / B12 30 mg/0.5 mg/mL, quantity 6 (6 vials of 2 mL),
and Ground Cold Chain shipping.
Test and Live use the same medication and shipping option IDs, names and default prices.
Copy shippingOptions[].id from your GET /v1/medications response into shipping.optionId.
Do not reuse a shipping option ID from these docs.
The prescription directions and compounding reason are synthetic Test data. Use clinician-approved values for Live orders.
Save the JSON body as order.json.
curl https://api.affinityrx.com/v1/orders \
-H "Authorization: Bearer $AFFINITY_API_KEY" \
-H "Affinity-Version: 2026-10-28" \
-H "Idempotency-Key: order-cuvo-123" \
-H "Content-Type: application/json" \
--data @order.json{
"practiceId": "prac_01j9z3k4m5n6p7q8r9s0t1v2w3",
"externalOrderId": "cuvo-123",
"prescriber": {
"npi": "1234567893",
"firstName": "Sam",
"lastName": "Lee",
"phone": "+15125550124",
"address": {
"line1": "300 Example St",
"line2": null,
"city": "Austin",
"state": "TX",
"postalCode": "78701"
}
},
"signatureAttestation": true,
"shipping": { "optionId": "<shipping option id from GET /v1/medications>" },
"patient": {
"externalId": "patient-88",
"firstName": "Test",
"middleName": null,
"lastName": "Patient",
"dateOfBirth": "1988-04-02",
"sex": "female",
"phone": "+15125550100",
"email": "test.patient@example.com",
"address": {
"line1": "100 Example St",
"line2": null,
"city": "Austin",
"state": "TX",
"postalCode": "78701"
},
"allergies": [
{
"display": "Penicillin",
"category": "drug"
}
]
},
"prescriptions": [
{
"medicationId": "med_3v9r73nmfs8bmbjb960yd2pggg",
"quantity": 6,
"refills": 0,
"directions": "Inject 0.25 mL (7.5 mg tirzepatide) under the skin once weekly.",
"compoundingReason": "concentration_adjustment",
"notes": null,
"substitutionPermitted": true
}
]
}The fields
| Field | Rule |
|---|---|
practiceId |
Required with a platform key. Use a child practice ID. Independent practice keys can omit this field to use their own practice. |
externalOrderId |
Optional. Your ID for the order, up to 128 characters. Events and lists return it. A second order with the same value for the practice returns 409 order_already_exists. |
prescriber |
Required with an API key. The NPI and name of the clinician who signs. |
signatureAttestation |
Required. Send true. It states that the named prescriber approved and signed this order in your product. |
shipping.optionId |
Required. A shipping option from the medication’s shippingOptions. |
patient |
Required. The patient as the pharmacy must see them. |
prescriptions |
Required. 1 to 20 prescriptions. |
Affinity trims surrounding whitespace from text fields and external IDs. It validates an NFC copy.
Length limits count Unicode code points in that copy. Affinity stores the source after trimming.
External IDs keep exact identity, including case and normalization form.
The externalOrderId list filter uses the same rule as order creation.
An ID that is blank after trimming is refused. Names, addresses and external IDs accept internal tabs.
Other control and format characters are refused in single-line fields.
Email and allergy descriptions refuse internal tabs. Email also refuses format characters, including the soft hyphen U+00AD.
The patient:
- Send
firstName,lastName,dateOfBirth(YYYY-MM-DD),sex(female,maleorunknown),phone(a US phone, such as(512) 555-0100or+15125550100),emailandaddress. See Input formats for the phone, state and ZIP forms you can send. middleNameandaddress.line2are optional. Omit them, or sendnullor empty text, when there is no value.- Send
allergiesas[{ display, category }].categoryis one ofdrug,food,insect,latex,mold,petorpollen. An empty list means “no new allergies reported”. PerfectRx keeps allergies it already holds when you sendallergies: []. - Send
externalId, your ID for the patient. Affinity finds the patient again byexternalIdin the practice and mode, and updates the patient from each order. WithoutexternalId, each order adds a new patient.
Reusing patient.externalId updates the patient’s details. Earlier orders keep their original patient
snapshots. The first signed PerfectRx order reserves the patient’s firstName and lastName,
including while that order waits to reach the pharmacy. If every pending order is confirmed cancelled
or fails before PerfectRx accepts the patient, a replacement order can use a corrected name.
Once PerfectRx accepts the patient, the name stays reserved even if the order is rejected or cancelled.
Sending a different reserved name returns 422
with an issue on the changed field before Affinity updates the patient or signs another order.
Case and whitespace differences, including spaces inside a name, are accepted. Accents remain part
of the stored name. When matching an existing patient across practices, Affinity also tolerates
accent differences such as “José” / “Jose”; the matched pharmacy name remains authoritative.
PerfectRx keeps the original demographic name even when
its update endpoint reports success. Contact Affinity support to correct the name before sending
another order. Use a new externalId for a different person, never to bypass a correction for the
same patient.
PerfectRx can share one matched patient record across practices and platforms. Later orders update that record’s contact details.
Each prescription:
- Send
medicationId,quantity,refills(0 to 99) anddirections. - If
quantitiesis not empty, use one of its values. Do not assume that the quantity is1. - For a vial product,
quantitycounts vials:quantity: 1is one vial andquantity: 6is 6 vials. Send 1 to 10. The prescription price is the medicationpricetimes the number of vials. For tablets, capsules and creams,quantitycountsquantityUnitand is a whole number of fills. Read Fill, quantity and strength. - Send
compoundingReasonwhen the medication’srequirementscontains it. Use one of the medication’scompoundingReasons, for exampleconcentration_adjustment. Another value returns422invalid_orderwith the accepted values. notesis optional.substitutionPermittedis optional and istruewhen you do not send it.
directions and notes accept tabs and line breaks. Affinity preserves internal tabs when it sends these fields to PerfectRx.
Tabs stay unchanged in other text fields that permit them, including names and addresses.
refills is the total number of refills the prescriber authorizes at the pharmacy, in addition to the first fill.
Affinity sends this authorization to the pharmacy. Affinity does not request refills.
If your platform sends a new order for each monthly fill, send refills: 0 on each order.
For example, send one order in January and a new order in February, each with a new externalOrderId and Idempotency-Key.
This avoids authorizing extra pharmacy refills alongside the monthly orders.
The order keeps the patient and the prescriber as you sent them. There are no patient or prescriber endpoints.
A cancelled order keeps its externalOrderId in use. Reusing it returns 409 order_already_exists; use a new ID for a replacement.
Where the order ships
Send the shipping option ID in shipping.optionId.
If you omit shipping.destination, the pharmacy ships to the patient’s address.
Choose a destination with its type:
{ "type": "patient" }uses the patient’s address.{ "type": "practice" }uses the practice address on file at the pharmacy. Affinity updates that address before sending.{ "type": "address", "address": { ... } }uses another address. The patient address rules apply.
Only the address type accepts an address field. An address on another type returns 400 invalid_request.
The patient address remains required for the pharmacy’s patient record.
restrictedStates and pharmacy coverage use the destination’s state.
A refusal names patient.address.state, practice.address.state or shipping.destination.address.state.
To use the practice address, replace the request’s shipping object with:
{
"optionId": "<shipping option id from GET /v1/medications>",
"destination": { "type": "practice" }
}To use another address, replace the request’s shipping object with:
{
"optionId": "<shipping option id from GET /v1/medications>",
"destination": {
"type": "address",
"address": {
"line1": "55 Other Location Rd",
"line2": "Suite 4",
"city": "Columbus",
"state": "OH",
"postalCode": "43215"
}
}
}The response returns shipping.option with id, name and price, or null for a historical order without an option.
It always returns shipping.destination, including { "type": "patient" } when you omitted the destination.
Retry safely
Idempotency-Key is required. A missing key returns 400 invalid_request.
Send a new key for each new order. When a request times out, send it again with the
same key and the same body. Affinity does not create a second order. It returns 200 with the order
that the first request created, as it is now. A request refused with 400 or 422 does not use up
its key. Read Retry safely for the rules that apply to every request.
Save the key with the order before the first request, and send it in the Idempotency-Key header every time you retry that order.
The response
A created order returns 200 with the whole order:
{
"id": "ord_01j9z3k4m5n6p7q8r9s0t1v2w7",
"externalOrderId": "cuvo-123",
"mode": "test",
"status": "submitted",
"statusHistory": [
{
"status": "submitted",
"createdAt": "2026-10-20T15:04:05.000Z"
}
],
"failureReason": null,
"cancelRequestedAt": null,
"trackingUrl": null,
"practiceId": "prac_01j9z3k4m5n6p7q8r9s0t1v2w3",
"practice": {
"archivedAt": null,
"name": "Example Clinic",
"phone": "+15125550123",
"address": {
"line1": "200 Example Ave",
"line2": "Suite 4",
"city": "Austin",
"state": "TX",
"postalCode": "78701"
}
},
"patientId": "pat_01j9z3k4m5n6p7q8r9s0t1v2w8",
"prescriber": {
"npi": "1234567893",
"firstName": "Sam",
"lastName": "Lee",
"phone": "+15125550124",
"address": {
"line1": "300 Example St",
"line2": null,
"city": "Austin",
"state": "TX",
"postalCode": "78701"
}
},
"patient": {
"firstName": "Test",
"middleName": null,
"lastName": "Patient",
"dateOfBirth": "1988-04-02",
"phone": "+15125550100",
"email": "test.patient@example.com",
"address": {
"line1": "100 Example St",
"line2": null,
"city": "Austin",
"state": "TX",
"postalCode": "78701"
},
"allergies": [
{
"display": "Penicillin",
"category": "drug"
}
],
"sex": "female"
},
"prescriptions": [
{
"id": "rx_01j9z3k4m5n6p7q8r9s0t1v2w9",
"medication": {
"id": "med_3v9r73nmfs8bmbjb960yd2pggg",
"name": "Tirzepatide / B12",
"strength": "30 mg/0.5 mg/mL",
"form": "Vial",
"quantityUnit": "vial",
"fill": {
"amount": 1,
"container": "vial",
"ingredients": [
{
"amount": 60,
"name": "Tirzepatide",
"unit": "mg"
},
{
"amount": 1,
"name": "B12",
"unit": "mg"
}
],
"unit": "vial",
"volume": {
"amount": 2,
"unit": "mL"
}
}
},
"quantity": 6,
"refills": 0,
"directions": "Inject 0.25 mL (7.5 mg tirzepatide) under the skin once weekly.",
"compoundingReason": "concentration_adjustment",
"notes": null,
"substitutionPermitted": true,
"price": 70350,
"pharmacy": {
"id": "pharm_314xqfy8g09snbzq56416t1zb1",
"name": "PerfectRx Pharmacy"
},
"status": "submitted",
"failureReason": null
}
],
"shipments": [],
"total": 73056,
"createdAt": "2026-10-20T15:04:05.000Z",
"updatedAt": "2026-10-20T15:04:05.000Z",
"shipping": {
"option": {
"id": "shp_7yna882w139zfbmwzeyb16y24e",
"name": "Ground Cold Chain",
"price": 2706
},
"destination": {
"type": "patient"
}
}
}Keep the order id. All prices are integers in cents.
A prescription’s medication.fill is the fill at signing. Later catalog changes do not change it.
Order creation uses the target practice’s prescription and shipping prices.
Read Prices for your practice before you show catalog prices to that practice.
total is the sum of these amounts:
- Prescription prices
- Shipping price, including packing and supplies
Each option price includes shippingSuppliesPrice; a mixed order pays the highest supplies price once. Example: $25.00 cold chain + $2.06 supplies = $27.06.
The order keeps its prices. Later catalog price changes do not change the order or its invoice.
When the order fails validation
- A missing or malformed field returns
400invalid_request. - A field that breaks a rule returns
422invalid_order. Examples: a required compounding reason, a restricted state, a shipping option that the pharmacy does not offer.
Both responses list each problem in issues, with its field and path.
Each validation message explains the expected type, allowed values, format or limit. For example,
patient.sex lists female, male and unknown; practiceId expects a prac_ ID.
Idempotency-Key must contain 1 to 255 printable ASCII characters
without spaces. Read Errors.
What happens next
| Status | Meaning |
|---|---|
submitted | Affinity accepted the order. In Live, it can stay submitted while Affinity resolves a pharmacy problem. |
processing | The pharmacy has the order. It received, accepted or compounds it. |
shipped | At least one package left the pharmacy. Read shipments. |
delivered | The pharmacy reports that the carrier delivered it. Some pharmacies never report this status. |
cancelled | The pharmacy confirmed the cancel, or cancelled the order itself. |
failed | The order could not proceed. Read failureReason; a Live order failed by staff carries their written reason. |
A pharmacy can skip a status. For example, an order can go from submitted to shipped.
failed and delivered are final. A failed order has a failureReason for a person to read.
To fix a failed order, send a new order.
Temporary database failures retry automatically in Test and Live mode. If a pharmacy answer is lost, routine status checks and verified pharmacy updates can recover the order. Affinity checks the original pharmacy reference before it sends an uncertain order again.
In Live mode, an order the pharmacy refused or Affinity could not send stays submitted while
Affinity staff resolve the problem. This is a held order. A platform cannot distinguish a held
order from a slow order through the API.
Affinity staff can resend the order or set it to failed with a written reason in failureReason.
A patient-record mismatch still fails promptly and tells you which details to check.
Read failureReason before you send a replacement order.
In Test mode, a pharmacy refusal fails the order promptly.
Retryable failures get up to 3 attempts.
When a transmit may have started, Affinity checks the original pharmacy reference before sending again.
Exhausted attempts fail the order and send order.failed. This also applies when a transmit may have
reached the Test pharmacy, because Test has no staff queue.
A pharmacy can ship after it confirms cancellation. A cancelled order can then become shipped or delivered.
Continue to apply newer webhook events. Check the latest order before you send a replacement.
Each status change sends a webhook event. Read Receive order updates.
PerfectRx field limits
For an order routed to PerfectRx, each patient, practice and prescriber address must have at
most 70 characters across line1 and line2 combined. Prescriber and patient first and last
names must each have at most 50 characters. The patient’s full contact name, including the
optional middle name and separating spaces, must have at most 100 characters.
Practice names must have at most 100 characters. Practice save and order preflight enforce this limit.
Each patient first and last name must include at least one letter A-Z, in either case.
A name without one returns 422 invalid_order with patient.firstName or patient.lastName.
Affinity checks these PerfectRx requirements before signing. An over-limit order returns
422 invalid_order with the affected field and limit. Correct the source value and resend;
Affinity does not shorten names or addresses. These limits apply only to PerfectRx.
PerfectRx accepts Windows-1252 text, including é ñ ü ç ’ “ ” – — €. Affinity normalizes
transmitted text to NFC, so a decomposed accent such as the letter e plus U+0301 becomes é.
The stored signed text stays unchanged.
Unsupported characters, such as ł, ő, Hangul, Greek,
Cyrillic, CJK, emoji and thin spaces, return 422 with the affected field and the first
unsupported character. Correct the source text and resend. Practice names and addresses
use the same rule when saved because practices can order from PerfectRx.
A refused combining mark is named by code point, such as U+0301 COMBINING ACUTE ACCENT.
Invisible characters, including Hangul fillers, use a code point such as U+3164.
Raw < and > are also refused. Write “less than” or “more than” instead. Quotes and
& are sent directly; do not replace them with HTML escapes.
Prescription events
Order events include data.order.prescriptions, with id, status and failureReason for each prescription.
prescription.failed and prescription.cancelled identify one changed prescription through data.prescriptionId.
They use the same data.order payload as order events.
PerfectRx sends no separate prescription events because all prescriptions move with their order.
Handle unknown event types and new status values without stopping event processing.