Orders¶
An order is a single shipment of products for one of your customers: the line items, the shipping address, and a pricing breakdown that is frozen the moment the order is created. Orders move through a fulfillment lifecycle from NEW to DELIVERED, and every state change is observable through the API and through webhooks.
Orders come into existence three ways:
| How | When to use it |
|---|---|
POST /v2/orders (this guide) |
Server-to-server creation for a customer you already know. No card payment happens at creation — settlement runs through your billing arrangement. |
| Hosted Checkout | The customer pays by card at purchase time. Checkout creates the order for you when the session completes. |
| Subscriptions | Renewal orders are generated automatically on the subscription's schedule. |
All three paths produce the same order object, appear in the same GET /v2/orders list, and emit the same webhook events.
Authentication¶
All order endpoints require a v2 API key (OAuth2 client credentials, minted in the partner portal). Reads require the order/v2.read scope; POST /v2/orders, PATCH /v2/orders/{orderId}/address, and POST /v2/orders/{orderId}/cancel require both order/v2.read and order/v2.write. v1 keys cannot call v2 endpoints, and vice versa.
Creating an order¶
POST /v2/orders — all five top-level fields are required:
curl -X POST https://api.fulfillment.sandbox.juniperhealth.com/v2/orders \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customer": {
"sourceId": "cust-1029",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phone": "(555) 010-2030",
"address": {
"line1": "437 Lytton Ave",
"line2": null,
"city": "Palo Alto",
"state": "CA",
"zipCode": "94301"
}
},
"daysSupply": 30,
"orderType": "ONE_TIME",
"shippingMethod": "STANDARD",
"lineItems": [
{
"productId": 77123129891,
"quantity": 2,
"administration": {"morning": 1, "afternoon": 0, "evening": 1, "sleep": 0},
"note": "Take with breakfast and dinner",
"takeWith": "food"
}
]
}'
A successful create returns 201 with the full priced order, abbreviated here:
{
"id": "2vSGym0bH8qVEwCIGlyFoRgJq1A",
"status": "NEW",
"isFirstOrder": true,
"daysSupply": 30,
"orderType": "ONE_TIME",
"shippingMethod": "STANDARD",
"customer": {
"sourceId": "cust-1029",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phone": "5550102030",
"address": {"line1": "437 Lytton Ave", "line2": null, "city": "Palo Alto", "state": "CA", "zipCode": "94301"}
},
"lineItems": [
{
"id": "2vSGyowEEPYh6T4C4NylMGXBpX2",
"productId": 77123129891,
"name": "Elite EPA",
"quantity": 2,
"administration": {"morning": 1, "afternoon": 0, "evening": 1, "sleep": 0},
"external": false,
"price": "0.39",
"totalPrice": "23.40",
"totalRetailPrice": "30.00",
"totalPartnerPrice": "18.00",
"totalCustomerPrice": "23.40"
}
],
"retailSubtotal": "30.00",
"partnerSubtotal": "18.00",
"customerSubtotal": "23.40",
"totalDiscount": "6.60",
"totalShipping": "9.99",
"totalTax": "0.00",
"customerTotal": "33.39",
"partnerCommission": "5.40",
"fulfillmentInfo": null,
"createdAt": "2026-07-25T12:00:00+00:00",
"updatedAt": "2026-07-25T12:00:00+00:00"
}
The response comes back immediately; fulfillment starts asynchronously (the order advances from NEW to PROCESSING on its own).
Pricing is frozen at creation¶
Every amount on the order — line pricing, subtotals, shipping, discounts, commission, customerTotal — is computed at creation from the live catalog, your partner discount, any per-customer discount, and your volume-discount tiers, then frozen onto the order. Later catalog or discount changes never alter an existing order. What you see in the 201 response is what settles, and it is also the ceiling for refunds.
Volume discounts are applied automatically from your configured tiers based on daysSupply; they cannot be requested per order. All money fields are decimal strings with two decimals (e.g. "29.70"), and partnerCommission can be negative.
The embedded customer, and how an order finds its Customer record¶
The customer object on an order is an embedded snapshot, not the full Customer resource. Every order is resolved to a Customer record in your account, in this order — the first step that yields a record wins:
customerId(optional, top level) — an explicit reference. If it does not resolve in your account the request fails with404and no order is created.customer.sourceId— your own identifier. A Customer record carrying thatsourceIdis linked.customer.email— a Customer record with that email is linked, provided the match is unambiguous: exactly one active record, not a caregiver-managed dependent, not a caregiver with dependents, and the first and last name agree with the snapshot.- Created — when the snapshot carries an email and nothing matched, a Customer record is created from it: name, email, phone, and the order's address as its shipping address. A created record carries no
sourceId; attach yours withPUT /v2/customers/{customerId}, or sendcustomerIdon later orders.
The linked or created id comes back as customerId on the 201. It is null only when the snapshot has no usable email or step 3 refused the match — the order is still created, it just isn't linked.
Two things follow from the fact that an email is unverified on this call:
- Only a
customerIdorsourceIdmatch applies the customer'sdiscountOverride. An email match prices at your partner rate. isFirstOrderis stamped only for a customer referenced bycustomerIdorsourceId. A customer the order created or matched by email is not stamped: their order history under your account starts with this order.
Two Customer records with the same email under your account stop email linking for that address until one is deleted or re-addressed.
Where a recurring order came from¶
An order carries subscriptionId (the subscription whose billing cycle generated it) and protocolId (the protocol it was built from). subscriptionId is null for one-time and API-created orders. protocolId is null for API-created orders; an order placed from a protocol carries it whether or not it recurs, including one the practice places from the protocol in the portal. orderType: "SUBSCRIPTION" alone cannot tell you which subscription a renewal belongs to once a customer holds more than one — subscriptionId can. protocolId survives a subscription being replaced, so it is the stabler key for grouping a customer's recurring orders over time.
The card that paid¶
A checkout or renewal order paid by card carries paymentMethod: {"brand": "visa", "last4": "4242", "expMonth": 12, "expYear": 2030}, the same shape GET /v2/customers/{customerId}/payment-method returns for the card on file. It is recorded once, when the payment settles, and never looked up again, so it names the card that paid even after the customer replaces their card on file. It arrives moments after the order is created: a read prompted by order.created can see null, and the write that adds it sends another order.* event. It is null for orders paid before the field existed, for payments that were not by card, and for orders with no customer payment, such as those created with POST /v2/orders.
Line-item rules¶
- 1–99 line items per order;
daysSupplymust be between 14 and 120. - Pack products (capsules dispensed into daily packs,
external: falsein the catalog) require anadministrationobject, and the sum ofmorning + afternoon + evening + sleepmust equalquantity. Pack line totals are multiplied bydaysSupply. - External products (powders, liquids —
external: true) are shipped as-is:administrationis ignored, and their totals are not multiplied bydaysSupply. note(up to 500 characters) andtakeWith(food|water|empty_stomach) are printed as guidance for the customer.- Every
productIdmust exist in the catalog and be orderable; unknown or unavailable products fail the request with400—code: PRODUCTS_NOT_FOUNDwith the offending ids indetails.missingProductIds, orcode: PRODUCTS_NOT_AVAILABLEwithdetails.productsNotAvailable.
Shipping methods¶
shippingMethod is one of STANDARD, EXPEDITED, or OVERNIGHT — and it must also be a method configured for your account. If it isn't, the request fails with 400:
{
"message": "Invalid shipping method",
"code": "INVALID_SHIPPING_METHOD",
"details": { "availableShippingMethods": ["STANDARD", "EXPEDITED"] }
}
Fetch your configured methods (with prices) from GET /v2/shipping-methods.
No idempotency key — deduplicate on your side¶
POST /v2/orders has no idempotency key. Every successful call creates a new order with a fresh ID, so a blind retry after a timeout can create a duplicate shipment. Recommended pattern:
- Treat any
201as committed. - If a create request times out or the connection drops, do not retry immediately. First call
GET /v2/orders?search=<customer sourceId>(or list recent orders) and check whether the order landed. The search index lags creation by a few seconds, so wait briefly before checking. - Only retry once you have confirmed the first attempt did not go through.
A blind retry duplicates the order, never the customer: the second request resolves to the Customer record the first one created.
Customer confirmation email¶
If the order includes a customer email and the order-confirmation email toggle in your partner settings is on, the customer receives a branded confirmation email (item count only — no product itemization). This is new in v2: v1 API orders never emailed customers, so if you are migrating from v1, decide deliberately whether you want this on.
Retrieving orders¶
GET /v2/orders/{orderId}— one order. Another partner's order is indistinguishable from a missing one (404).GET /v2/orders— paginated list, newest-first by default. Returns{"orders": [...], "cursor": "..."}; passcursorback verbatim to page, and stop when it isnull.
Query parameters worth knowing:
| Parameter | Behavior |
|---|---|
limit |
1–100, default 10. Out-of-range values are clamped, not rejected. |
status |
Filter by a single order status. Filtered results come back in index order, not newest-first. |
direction |
next (newest-first, default) or previous (oldest-first). |
search |
Fuzzy match over customer name, order ID, customer sourceId, and product names. When present, status and direction are ignored. |
cursor |
Opaque — never construct or modify one. Cursors from search and non-search responses are not interchangeable, and a cursor minted with a status filter only works with the same filter. |
Search is served from a secondary index that is populated asynchronously, so a just-created order may not be searchable for a few seconds. If the search backend has a problem, you get 200 with an empty list rather than an error — treat empty search results as "nothing found right now", not proof of absence.
Updating the shipping address¶
PATCH /v2/orders/{orderId}/address replaces the shipping address on an order that has not yet been filled — allowed only while the status is NEW or PROCESSING; afterwards you get 409.
curl -X PATCH https://api.fulfillment.sandbox.juniperhealth.com/v2/orders/2vSGym0bH8qVEwCIGlyFoRgJq1A/address \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"line1": "123 Updated St", "line2": "Apt 4", "city": "Springfield", "state": "IL", "zipCode": "62704"}'
The full updated order is returned. Amounts do not change — the order is not re-priced; only the delivery address is replaced. The update emits an order.updated webhook event.
Cancelling an order¶
POST /v2/orders/{orderId}/cancel (no request body) cancels a partner-managed order that has not been dispensed. It never refunds. An order can be cancelled only when:
- its billing mode is
PARTNER_MANAGED(frozen on the order at creation; an order from before that stamp uses your account's current mode). A DailyStak-managed order is refused withdetails.reasonjuniper_managed: contact DailyStak support to cancel it; - it is
NEWorPROCESSING, and dispensing has not begun for any of its fill orders (past_processing,already_dispensed); - it has no shipping label or tracking number (
labeled) and is not on an invoice (invoiced); - it was not paid through Shopify (
shopify_paid) and carries no DailyStak payment (paid_through_dailystak). TheexternalPaymentReferenceyou give when confirming a renewal is your own reference, not a DailyStak payment, so it doesn't block a cancel.
A refused cancel is 409 with code: ORDER_NOT_CANCELLABLE, a message, and the rule in details.reason (an already-cancelled order is already_cancelled). If the dispensing or payment check can't run, it is 503 with code: CANCEL_CHECK_UNAVAILABLE; nothing was cancelled, so retry.
{"orderId": "2vTgd30JFgnwLrgb383PcV9Rken", "status": "CANCELLED"}
Cancellation propagates asynchronously: pending fulfillment work is stopped and any shipment already registered with the carrier is cancelled, and the order.cancelled webhook event fires. Cancelling does not refund anything — refunds are issued by DailyStak (see Refunds).
Status lifecycle and tracking¶
| Status | Meaning | Can cancel? | Can edit address? |
|---|---|---|---|
NEW |
Created, fulfillment not yet started | Partner-managed orders | Yes |
PROCESSING |
Fulfillment accepted, queued for dispensing | Partner-managed orders, until dispensing starts | Yes |
DISPENSING |
Products are being dispensed and packed | No | No |
FILLED |
Packed, awaiting shipment | No | No |
FULFILLED |
Fulfillment complete, handing off to carrier | No | No |
SHIPPED |
In the carrier's hands; tracking available | No | No |
DELIVERED |
Carrier confirmed delivery (terminal) | No | No |
CANCELLED |
Cancelled (terminal) | No | No |
EXCEPTION |
A fulfillment problem needs attention; the order returns to PROCESSING when resolved |
No | No |
AWAITING_SHIPMENT |
Deprecated — only on old orders, never written anymore | No | No |
Once the order ships, fulfillmentInfo populates with carrier, trackingNumber, trackingUrl, trackingStatus, and a checkpoints array of carrier scan events ({checkpointTime, location, message, tag}).
Webhook events¶
The orders domain emits these events (see Webhooks for delivery, signing, and retries):
| Event | Fires when |
|---|---|
order.created |
An order is created (any creation path) |
order.updated |
Any other change — address updates, NEW → PROCESSING, dispensing progress |
order.cancelled |
The order is cancelled |
order.shipped |
The carrier accepts the shipment |
order.delivered |
Carrier tracking confirms delivery |
order.exception |
The order enters EXCEPTION |
order.fulfilled |
Reserved for future use |
Payloads carry the order ID, its new status, the current fulfillmentInfo snapshot, timestamps, subscriptionId (null for one-time and API orders) and protocolId (null for API orders and orders not placed from a protocol), and — on every event — checkoutSessionId and metadata: the checkout session that created the order and the metadata object sent when that session was created (both null for orders created any other way; in metadata, numbers (including nested ones and those inside arrays) arrive as strings — 3 comes back as "3" — while strings, booleans, null and the object/array structure are preserved). Delivery is at-least-once and unordered — dedupe on order ID + status + updatedAt.
Errors¶
Bodies are {"message", "code", "details"?} — branch on code.
| Status | code |
When |
|---|---|---|
400 |
INVALID_SHIPPING_METHOD |
shippingMethod not configured for your account; details.availableShippingMethods lists yours |
400 |
PRODUCTS_NOT_FOUND / PRODUCTS_NOT_AVAILABLE |
Unknown products (details.missingProductIds) or products not orderable (details.productsNotAvailable) |
401 / 403 |
— | Missing/invalid token, or token lacks a required scope |
404 |
ORDER_NOT_FOUND / PARTNER_NOT_FOUND |
Order not found (or belongs to another partner); on create, your partner account could not be resolved |
409 |
ORDERS_REQUIRE_CHECKOUT |
Create refused: your account's billing is DailyStak-managed, so orders go through checkout links |
409 |
ORDER_STATUS_CHANGE_FORBIDDEN / INVALID_ORDER_TRANSITION |
Address update attempted after the order left NEW/PROCESSING |
409 |
ORDER_NOT_CANCELLABLE / ORDER_CHANGED |
Cancel refused (details.reason names the rule, see Cancelling an order), or the order changed mid-cancel |
503 |
CANCEL_CHECK_UNAVAILABLE |
The cancel's dispensing or payment check could not run; nothing was cancelled |
422 |
VALIDATION_FAILED |
Request validation failed — missing fields, daysSupply outside 14–120, bad enum values, administration sum ≠ quantity, invalid email/phone |
Good to know¶
- Phone numbers are normalized to digits on the way in (
(555) 010-2030→5550102030) and must resolve to 10 digits (or 11 with a leading1). - Addresses are US-only: the 50 states, DC, and PR; ZIP must be exactly 5 digits.
totalTaxis always"0.00"for API-created orders.- Free-shipping floor: if your matched shipping method is configured at
$0.00and the order subtotal is under$50.00, a$9.99default shipping charge applies. - Null fields are present in responses (as
null), never omitted. Unknown fields in your requests are silently ignored. - Deprecated response fields:
discountand each line'sdiscountedPriceare vestigial (always"0.00");imageUrlis currently alwaysnull. Don't build on them. - Refund accounting is tracked against the order as refunds complete, and it caps what remains refundable — but it is not exposed as fields on the order object. List the order's refunds (
GET /v2/orders/{orderId}/refunds) to see cumulative refund state. - List reads are eventually consistent — an order created milliseconds ago can be briefly missing from
GET /v2/orders.GET /v2/orders/{orderId}with the ID from your201response is the reliable read-after-write.