Skip to content

Carts

Our cart for your own storefront or app: priced server-side at your storefront rates, persistent, and fast enough to update on every tap. Your server creates a cart and checks it out; your shopper's browser or app reads and edits it directly.

The flow at a glance

  1. Your server creates a cart: POST /v2/carts (client credentials). You get a cartId and a cartSecret.
  2. Your server keeps the cartId (on the shopper's session or user record) and gives the browser or app both values.
  3. The browser or app edits the cart on the public cart host with Authorization: Bearer <cartSecret>. Every response is the whole priced cart plus a revision.
  4. Your server checks out: POST /v2/checkout/sessions with cartId instead of products. Everything from there is Hosted Checkout as usual.
Production Sandbox
Partner routes (create, read, rotate) https://api.fulfillment.juniperhealth.com https://api.fulfillment.sandbox.juniperhealth.com
Shopper routes (everything else) https://cart.fulfillment.juniperhealth.com https://cart.fulfillment.sandbox.juniperhealth.com

Cart responses are sent with Cache-Control: no-store.

Creating a cart

POST /v2/carts needs order/v2.read and order/v2.write. The response (201) is an empty cart with a 30-day cadence and revision: 0, plus cartSecret. The secret is shown only here and on rotation, so store it wherever you store the cartId.

GET /v2/carts/{cartId} on the partner host reads a cart server-side with your access token (order/v2.read), no secret needed. Another partner's cart, or a value that isn't a cart id at all, reads as 404 CART_NOT_FOUND.

A cart expires after 90 days without a change (expiresAt in every response; reads don't extend it). After that, reads return 404 CART_NOT_FOUND; create a new one.

The cart secret

  • It grants access to one cart: read it, change lines, change cadence, add a template. It can't create checkout sessions or read anything else.
  • It's safe in a browser or mobile app. The cart holds no personal data; email and address go on the checkout session, which only your server creates.
  • Store it as an opaque string. A secret is 70 characters, and a cartId is crt_ plus 22 characters.
  • If you think one has leaked, rotate it with POST /v2/carts/{cartId}/secret (order/v2.read and order/v2.write). The old secret stops working immediately. Rotating doesn't change the cart, its revision or its expiry.
  • Don't rotate the same cart's secret from two places at once. The last rotation wins, and the other caller still gets a 200 whose secret is already dead.

Editing a cart (shopper routes)

Every shopper route takes Authorization: Bearer <cartSecret> and returns the full cart. Any origin may call them (CORS *); the secret travels in a header, never a cookie.

Send the header exactly as Bearer <cartSecret>: the scheme in any case, one space, and no other whitespace anywhere (none before, after or inside the secret). Anything else is 401 CART_SECRET_INVALID, and so is a secret for a different cart than the cartId in the path.

Route Body Does
GET /v2/carts/{cartId} — Read
PUT /v2/carts/{cartId} {"daysSupply": 60} Set the cadence: 30, 60 or 90 days. Always a new revision, even when unchanged
PUT /v2/carts/{cartId}/lines/{productId} {"administration": {...}} or {"unitsPerCycle": n} Set one line (replaces it if present)
DELETE /v2/carts/{cartId}/lines/{productId} — Remove one line. Removing a line that isn't there changes nothing and keeps the same revision
POST /v2/carts/{cartId}/templates/{templateId} — Add every line of one of your ACTIVE protocol templates

Bodies are strict. An unknown key (a typo such as evenning, or a field from another API such as quantity) and a number that isn't a plain integer (true, "2", 2.0) are refused with 422 VALIDATION_FAILED, never ignored or converted.

Line shape. Products dispensed by dose take administration (morning, afternoon, evening and sleep counts, at least one above zero). Pre-packaged products take unitsPerCycle (1-50 units per shipment). Sending the wrong one returns 422 LINE_MODE_MISMATCH. unitsPerShipment in the response is what one shipment contains: daily dose × daysSupply, or unitsPerCycle.

Product ids. productId is the catalog product ID written as a plain integer of at most 20 digits, with no sign or leading zeros. On PUT, any other value is 422 PRODUCT_NOT_FOUND; on DELETE, it can't be in the cart, so nothing changes.

Templates. Any ACTIVE template you own can be added; publishing it to a hosted storefront isn't required. A template id that isn't one of yours (including any id over 128 characters) is 404 PROTOCOL_TEMPLATE_NOT_FOUND. Each line records protocolTemplateId, and changing that line's dose or units later keeps it. A product already in the cart is replaced by the template's line. If any template line can't be bought, nothing is added: 422 TEMPLATE_NOT_PURCHASABLE, with details.lines listing {"productId", "name", "reason"} and reason one of not purchasable, duplicate product in template, no usable schedule or invalid quantity.

Limits. 50 distinct products per cart (422 CART_LINE_LIMIT). Dose caps apply per product (422 DOSE_CAP_EXCEEDED, with details.totalDaily and details.cap, plus details.productId on a template add). maxDailyUnits on each line is its cap. doseWarning: true on a write's response means the dose is above the recommended amount but allowed.

Fast updates and revision

Every change increments revision. Requests can overlap (a shopper tapping + five times), and their responses can arrive out of order, so render only the response with the highest revision you have seen. Writes are absolute ("set this line to 3"), so the cart always ends in the state of the last write. Debounce quantity steppers; one request per settled value is plenty.

If a cart is written faster than it can settle, a write returns 503 CART_BUSY with Retry-After: 1; wait that many seconds and retry. Browser code can read Retry-After too, so honor it from the browser or app as well as from a server.

Rate limits

The public cart host counts requests in 5-minute windows. These are the current limits, and they may change:

Counted per Limit
Cart secret 1,000 requests per 5 minutes
IP address 6,000 requests per 5 minutes

A request over either limit gets 429 RATE_LIMITED, sent with Access-Control-Allow-Origin: * so browser code can read it. Back off on a 429 and retry later rather than straight away; retries count too.

  • A server calling for many shoppers shares one IP budget. Each cart keeps its own per-secret limit, but every request from your server's IP address counts toward the same 6,000. Calling the cart host from the shopper's browser or app spreads the load across their addresses.
  • Once an IP address is blocked, CORS preflights are blocked too. The browser's OPTIONS preflight also gets the 429, and a browser treats a failed preflight as a network error: fetch rejects with TypeError: Failed to fetch instead of giving you a readable 429.
  • The cart host also has an overall throttle, shared by all partners. A request it refuses gets a 429 with API Gateway's own body, which has no code, before the request reaches the cart service. That response probably carries no CORS headers either, so a browser would report it as a network error too.

So treat a Failed to fetch from the cart host that you can't otherwise explain as a possible throttle, and back off the same way as on a 429.

Prices

  • Line prices and subtotal use your storefront pricing: your storefront discount, falling back to your default discount. That's the same price your hosted storefront shows.
  • They're computed on our side and may be up to 5 minutes behind a catalog price change.
  • Money is a decimal string in dollars, with at least 2 decimal places. Digits below a cent are kept, never rounded, and exponent notation is never used: "16.00", "0.40", "0.0833". Parse it as a decimal, not a float.
  • subtotal is priced for the cart as a whole, so it can differ slightly from adding up customerPrice × unitsPerShipment yourself.
  • The checkout total is authoritative. Shipping, tax and promo codes are calculated at checkout, and the checkout reprices from current catalog data. The cart subtotal does not include volume discounts, and a one-time checkout can differ from it by rounding.
  • A product deactivated or deleted in the last 5 minutes can still be added to a cart. A line whose product is no longer for sale stays in the cart until it's removed, and checkout refuses the cart while it's there (422 CART_NOT_PURCHASABLE). A deleted product's line drops out of the cart by itself once those 5 minutes have passed.
  • Renewals are priced at your default discount, not your storefront discount. The first charge of a subscription started from a cart uses your storefront discount, but its renewals are priced at your default discount, so a renewal can cost more than the first charge. A customer's own personal discount, when they have one, applies to both. The session's nextRenewalEstimate shows what the renewal will cost; disclose that amount for the recurring charge (see Disclosing the renewal amount).
  • Pass customerId at checkout for a shopper you know. Their personal pricing applies there, and a subscription attaches to their existing customer record. For a signed-out shopper whose email already belongs to one of your customers, a subscription is paid but held for review rather than attached.

Checking out

POST /v2/checkout/sessions
{
  "cartId": "crt_2mH3kQ7vX9pL4nR8sT1uWy",
  "mode": "subscription",
  "customerEmail": "jane@example.com",
  "firstName": "Jane",
  "lastName": "Doe",
  "shippingAddress": {"line1": "1 Main St", "city": "Austin", "state": "TX", "zipCode": "78701"},
  "uiMode": "elements"
}
  • Send cartId instead of protocolId or products, with the same customer fields as products mode.
  • Don't send daysSupply; the cart's cadence is used (422 DAYS_SUPPLY_NOT_ALLOWED).
  • mode can be one_time or subscription. A subscription from a cart creates the customer's protocol when checkout completes.
  • One open session per cart. Creating a session from a cart cancels that cart's previous open session and its payment intent.
  • A missing, expired or malformed cartId, or another partner's cart, is 404 CART_NOT_FOUND.
  • If another session is being created from the same cart at the same moment, this call returns 503 CART_BUSY with Retry-After: 1, and the other call's session stands. A retry supersedes that session: it creates a new one and cancels the one that won. So if you double-submitted and already hold a 201 from the other call, keep using that session instead of retrying. Otherwise retry after Retry-After; an Idempotency-Key sent with the refused call is released, so the retry can reuse it.
  • Re-sending a request with the same Idempotency-Key returns the original session (Idempotent-Replayed: true), even after payment has cleared the cart's lines.
  • An empty cart is 422 CART_EMPTY. A cart with a line that can't be bought is 422 CART_NOT_PURCHASABLE, and details.lines lists those lines in the same shape and with the same reason values as TEMPLATE_NOT_PURCHASABLE, for example {"productId": "789", "name": "Focus", "reason": "not purchasable"}.
  • When checkout completes, the purchased lines are removed from the cart and its revision increments. Purchased lines leave the cart once payment succeeds, including when the order is held for review.

Reference: errors

Errors use the standard body {"message", "code"}, plus details when the refusal names specifics. Branch on code: codes are the contract, and message is for people and may be reworded.

Status Code When
401 CART_SECRET_INVALID Missing, malformed or wrong secret, or a secret for a different cart than the one in the path
404 CART_NOT_FOUND No such cart, expired, or not yours. On the partner routes and at checkout, also a cartId that isn't a cart id
404 PROTOCOL_TEMPLATE_NOT_FOUND Adding a template: not one of your ACTIVE templates, or it has no lines
422 LINE_MODE_MISMATCH, DOSE_CAP_EXCEEDED, CART_LINE_LIMIT, PRODUCT_NOT_FOUND, PRODUCT_NOT_ACTIVE, TEMPLATE_NOT_PURCHASABLE The line or template can't go in the cart
422 VALIDATION_FAILED Malformed body: an unknown key, a number that isn't a plain integer, both or neither of administration and unitsPerCycle, no dose above zero, unitsPerCycle outside 1-50, or daysSupply not 30, 60 or 90
422, 503 CART_EMPTY, CART_NOT_PURCHASABLE, DAYS_SUPPLY_NOT_ALLOWED, LINE_MODE_MISMATCH, DOSE_CAP_EXCEEDED (422); CART_BUSY (503) At checkout. LINE_MODE_MISMATCH and DOSE_CAP_EXCEEDED come from checkout too when a product changed after its line was added; for CART_BUSY, see Checking out before retrying
429 RATE_LIMITED Over a rate limit; back off
503 CART_BUSY A write on the shopper routes lost a race; retry after Retry-After