Skip to content

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:

  1. customerId (optional, top level) — an explicit reference. If it does not resolve in your account the request fails with 404 and no order is created.
  2. customer.sourceId — your own identifier. A Customer record carrying that sourceId is linked.
  3. 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.
  4. 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 with PUT /v2/customers/{customerId}, or send customerId on 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 customerId or sourceId match applies the customer's discountOverride. An email match prices at your partner rate.
  • isFirstOrder is stamped only for a customer referenced by customerId or sourceId. 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; daysSupply must be between 14 and 120.
  • Pack products (capsules dispensed into daily packs, external: false in the catalog) require an administration object, and the sum of morning + afternoon + evening + sleep must equal quantity. Pack line totals are multiplied by daysSupply.
  • External products (powders, liquids — external: true) are shipped as-is: administration is ignored, and their totals are not multiplied by daysSupply.
  • note (up to 500 characters) and takeWith (food | water | empty_stomach) are printed as guidance for the customer.
  • Every productId must exist in the catalog and be orderable; unknown or unavailable products fail the request with 400 — code: PRODUCTS_NOT_FOUND with the offending ids in details.missingProductIds, or code: PRODUCTS_NOT_AVAILABLE with details.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:

  1. Treat any 201 as committed.
  2. 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.
  3. 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": "..."}; pass cursor back verbatim to page, and stop when it is null.

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 with details.reason juniper_managed: contact DailyStak support to cancel it;
  • it is NEW or PROCESSING, 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). The externalPaymentReference you 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 leading 1).
  • Addresses are US-only: the 50 states, DC, and PR; ZIP must be exactly 5 digits.
  • totalTax is always "0.00" for API-created orders.
  • Free-shipping floor: if your matched shipping method is configured at $0.00 and the order subtotal is under $50.00, a $9.99 default shipping charge applies.
  • Null fields are present in responses (as null), never omitted. Unknown fields in your requests are silently ignored.
  • Deprecated response fields: discount and each line's discountedPrice are vestigial (always "0.00"); imageUrl is currently always null. 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 your 201 response is the reliable read-after-write.