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¶
- Your server creates a cart:
POST /v2/carts(client credentials). You get acartIdand acartSecret. - Your server keeps the
cartId(on the shopper's session or user record) and gives the browser or app both values. - The browser or app edits the cart on the public cart host with
Authorization: Bearer <cartSecret>. Every response is the whole priced cart plus arevision. - Your server checks out:
POST /v2/checkout/sessionswithcartIdinstead ofproducts. 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
cartIdiscrt_plus 22 characters. - If you think one has leaked, rotate it with
POST /v2/carts/{cartId}/secret(order/v2.readandorder/v2.write). The old secret stops working immediately. Rotating doesn't change the cart, itsrevisionor 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
200whose 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
OPTIONSpreflight also gets the429, and a browser treats a failed preflight as a network error:fetchrejects withTypeError: Failed to fetchinstead of giving you a readable429. - The cart host also has an overall throttle, shared by all partners. A request it refuses gets a
429with API Gateway's own body, which has nocode, 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
subtotaluse 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. subtotalis priced for the cart as a whole, so it can differ slightly from adding upcustomerPrice × unitsPerShipmentyourself.- 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
nextRenewalEstimateshows what the renewal will cost; disclose that amount for the recurring charge (see Disclosing the renewal amount). - Pass
customerIdat 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
cartIdinstead ofprotocolIdorproducts, with the same customer fields as products mode. - Don't send
daysSupply; the cart's cadence is used (422 DAYS_SUPPLY_NOT_ALLOWED). modecan beone_timeorsubscription. 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, is404 CART_NOT_FOUND. - If another session is being created from the same cart at the same moment, this call returns
503 CART_BUSYwithRetry-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 a201from the other call, keep using that session instead of retrying. Otherwise retry afterRetry-After; anIdempotency-Keysent with the refused call is released, so the retry can reuse it. - Re-sending a request with the same
Idempotency-Keyreturns 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 is422 CART_NOT_PURCHASABLE, anddetails.lineslists those lines in the same shape and with the samereasonvalues asTEMPLATE_NOT_PURCHASABLE, for example{"productId": "789", "name": "Focus", "reason": "not purchasable"}. - When checkout completes, the purchased lines are removed from the cart and its
revisionincrements. 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 |