Skip to content

Hosted Checkout

Hosted checkout is the fastest way to collect payment from a customer without building your own payment UI. You create a checkout session server-to-server, send the customer to the returned checkoutUrl, and DailyStak handles the payment page, shipping selection, tax, and card collection. When the customer pays, the platform creates the order (and, in subscription mode, a Juniper-managed subscription) and notifies you. If you would rather render checkout on your own site, see Elements mode below.

Use hosted checkout when:

  • A practitioner has built a protocol and you want to send the customer a payment link for it.
  • You want to sell catalog products ad hoc without storing card data or handling PCI scope yourself.
  • You want recurring billing with the card saved automatically for renewals.

All checkout endpoints live under /v2/checkout/sessions. Reads require the order/v2.read scope; POST, PATCH, and DELETE additionally require order/v2.write. v1 API keys cannot call these endpoints — mint a v2 key in the partner portal.

The flow at a glance

  1. You call POST /v2/checkout/sessions. The cart is priced immediately (subtotal plus sales tax for the shipping address) and a payment intent is created for totalAmount.
  2. You send the customer to checkoutUrl. The link is valid for 24 hours.
  3. The customer selects a shipping method (which updates totalAmount), optionally applies a promo code, and pays.
  4. Completion is asynchronous: within seconds of payment, the order — and the subscription, in subscription mode — is created. The order.created webhook event is your signal; see Webhooks.

Creating a session

POST /v2/checkout/sessions accepts exactly one of three input modes.

Protocol mode (protocolId)

Line items come from the protocol's regimen, priced server-side at the customer's rates. Customer identity auto-populates from the protocol's linked customer:

  • customerEmail, firstName, lastName, phone, and shippingAddress are all optional — they fall back to the protocol customer's values. Anything you do supply overrides the auto-populated value.
  • Exception: customerId is always forced to the protocol's customer. A mismatched customerId in the request is ignored, not an error.
  • If neither the request nor the protocol's customer can supply an email or a shipping address, the request fails with 400.

One active session per protocol. Creating a session for a protocol automatically cancels any older pending sessions for the same protocol, including their payment intents. Old checkout links stop working the moment you create a new one — so re-creating a session is the correct way to "refresh" a link.

Products mode (products)

You supply an explicit list of catalog products. In this mode customerEmail, firstName, lastName, and shippingAddress are required (422 if missing). Each product entry needs productId (integer), quantity and packDescription. A product taken by the day also needs an administration object whose dayparts (morning + afternoon + evening + sleep) must sum to quantity, and is refused with 400 without one. An external product (a powder or a liquid, external: true in the catalog) is taken at no time of day, so leave administration out: its quantity is the number of containers, priced once and never multiplied by daysSupply.

A product entry may also carry templateId, the id of the protocol template the line came from, the same key a POST /v2/protocols line takes. It is copied onto the order line, and the portal's order page links the template and shows whether it is yours or copied from the Juniper library. An id that is not one of your templates stays on the line but shows no link; it never fails the checkout. Leave it out for a product the customer chose on its own.

In protocol and products mode, prices always come from your catalog — you cannot set prices in the request. Every product must exist, be available for sale, and be in stock, or the request fails with 400.

Cart mode (cartId)

Lines and cadence come from a cart your shopper built with the Carts API. Customer fields are required as in products mode; daysSupply must be omitted (422 DAYS_SUPPLY_NOT_ALLOWED). One open session per cart: creating a session from a cart cancels that cart's previous open session and its payment intent.

Lines are priced at your storefront discount, the same scope the cart shows. In subscription mode that's the first charge only: renewals are priced at your default discount, so a renewal can cost more than the first charge. Disclose nextRenewalEstimate, which shows it. A missing, expired or malformed cartId, or another partner's cart, is 404 CART_NOT_FOUND. An empty cart is refused with 422 CART_EMPTY, and a cart holding a product that's no longer for sale with 422 CART_NOT_PURCHASABLE: details.lines lists each such line as an object, for example {"productId": "789", "name": "Focus", "reason": "not purchasable"}, with the same reason values as the Carts API's TEMPLATE_NOT_PURCHASABLE. If another session is being created from the same cart at the same moment, the call returns 503 CART_BUSY with Retry-After: 1, and the other call's session stands. A retry creates a new session that cancels it, so if you double-submitted and already hold a 201 from the other call, keep using that session. Re-sending a request with the same Idempotency-Key returns the original session, even after payment has cleared the cart. When checkout completes, the purchased lines are removed from the cart. Purchased lines leave the cart once payment succeeds, including when the order is held for review.

Subscription mode

Set mode: "subscription" to make completion create a recurring subscription alongside the first order:

  • daysSupply is required (400 otherwise), except in cart mode, where the cart's cadence is used. It sets the renewal cycle: the customer is billed every daysSupply days.
  • The customer's card is saved during checkout and used for renewals automatically.
  • Subscription checkout must come from a protocolId or a cartId. The subscription is built from the protocol's regimen, or from the cart's lines; products-mode carts cannot be converted into subscriptions through this endpoint. A products + mode: "subscription" request is rejected at creation with 400 {"message": "Invalid checkout data: ..."}.
  • On completion, subscriptionId is set on the session. Manage the result through the Subscriptions API.
  • nextRenewalEstimate tells you what the next charge will cost, so your page can disclose the recurring amount. Do not use totalAmount for that — see Disclosing the renewal amount.

The default mode is one_time, which works with any input mode and creates a single order.

What happens at creation

  • Totals are computed server-side: subtotal (pre-shipping, pre-tax, with volume and standing discounts applied), plus sales tax calculated against the shipping address, giving totalAmount. Shipping is not included yet — the customer picks a method on the hosted page.
  • If you have configured a first-order promotion and the customer (identified via customerId or the protocol's customer) has no prior orders, the discount is applied automatically. It applies to this charge only — never to a renewal.
  • For subscription sessions with a shipping address, nextRenewalEstimate is priced alongside the charge, through the same engine the renewal itself uses (no promotion, no shipping). It is null for one-time sessions and until an address is known, and it is re-estimated whenever you change the address.
  • A payment intent is created for totalAmount. Quote equals charge: the amount the customer confirms on the page is exactly what is charged, and it becomes the order's frozen total.
  • The session is returned with status: "pending" and expiresAt 24 hours out.

Example: subscription checkout from a protocol

curl -X POST https://api.fulfillment.juniperhealth.com/v2/checkout/sessions \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "protocolId": "2vSGym0bH8qVEwCIGlyFoRgJq1A",
    "mode": "subscription",
    "daysSupply": 30,
    "successUrl": "https://portal.acme.com/thanks",
    "source": "api",
    "metadata": { "internalRef": "po-1842" }
  }'

Response (201 Created):

{
  "sessionId": "2zXq1r8vWn3kFh9YbT0cGdE5LmA",
  "partnerId": "2vP0aBcDeFgHiJkLmNoPqRsTuVw",
  "customerEmail": "jane.doe@example.com",
  "customerFirstName": "Jane",
  "customerLastName": "Doe",
  "customerPhone": "5125551234",
  "mode": "subscription",
  "status": "pending",
  "lineItems": [
    {
      "id": "2zXq2s9wXo4lGi0ZcU1dHeF6MnB",
      "productId": "77123129891",
      "name": "Elite EPA",
      "quantity": 2,
      "unitPrice": "0.37",
      "packDescription": "Daily Wellness",
      "administration": { "morning": 1, "afternoon": 0, "evening": 1, "sleep": 0 }
    }
  ],
  "daysSupply": 30,
  "subtotal": "22.20",
  "totalAmount": "24.03",
  "currency": "usd",
  "nextRenewalEstimate": {
    "subtotal": "27.75",
    "shipping": "0.00",
    "tax": "2.29",
    "total": "30.04",
    "currency": "usd",
    "daysSupply": 30,
    "quotedAt": "2026-07-25T18:04:11.512345Z",
    "basis": "quote"
  },
  "shippingMethod": null,
  "shippingAddress": {
    "line1": "123 Main St",
    "line2": null,
    "city": "Austin",
    "state": "TX",
    "zipCode": "78701"
  },
  "checkoutUrl": "https://checkout.acme.com/checkout/Nt2xGm4qP8vRw6ZsB1cJd9fKh3LyT5nAe7UoWiE0YkM",
  "clientSecret": null,
  "stripePublishableKey": null,
  "customerSessionClientSecret": null,
  "uiMode": "hosted",
  "stripeCheckoutSessionUrl": null,
  "expiresAt": "2026-07-26T18:04:11.512345+00:00",
  "orderId": null,
  "subscriptionId": null,
  "source": "api",
  "createdAt": "2026-07-25T18:04:11.512345+00:00",
  "updatedAt": "2026-07-25T18:04:11.512345+00:00",
  "viewedAt": null,
  "partnerConsent": false
}

The customer's identity and shipping address were auto-populated from the protocol — the request never mentioned them. Note that quantity on a pack line item is the per-day dose count, and money fields are decimal strings.

totalAmount here (24.03) is lower than nextRenewalEstimate.total (30.04) because a first-order promotion is applied to this charge. That gap is the reason the two numbers are separate fields.

Disclosing the renewal amount

Your page must tell the customer what they will be charged on renewal, and totalAmount is not that number: it is today's charge, and a configured first-order promotion discounts it. Quoting it would understate every later charge.

Show daysSupply for the cadence and nextRenewalEstimate.total for the amount, labelled as an estimate:

Renews every 30 days. Estimated renewal: $30.04 — includes $9.99 shipping, tax estimated at your address.

Two things to say plainly alongside it:

  • Renewals follow the shipping threshold. nextRenewalEstimate.shipping is "9.99" under $50 of product and "0.00" at $50 or more, and total already includes it. Show it the way you show the first order's shipping.
  • It is an estimate. Tax varies with the address and catalogue prices move between cycles. nextRenewalEstimate is a snapshot taken at quotedAt, not a guaranteed amount.

nextRenewalEstimate is null for one-time sessions and until a shipping address is known. If it is null on a subscription session, fall back to describing the regimen price per delivery and naming tax and shipping as additions.

Every reprice response (RepricedSession) carries the current nextRenewalEstimate too, so an Elements page can refresh both numbers from one response. Changing the address re-estimates it; changing the first order's shipping method or the promo does not move it, because a renewal ships Standard and carries no promotion.

A one-time, products-mode request instead looks like:

{
  "customerEmail": "jane.doe@example.com",
  "firstName": "Jane",
  "lastName": "Doe",
  "phone": "5125551234",
  "shippingAddress": { "line1": "123 Main St", "city": "Austin", "state": "TX", "zipCode": "78701" },
  "products": [
    {
      "productId": 77123129891,
      "quantity": 2,
      "packDescription": "Daily Wellness",
      "administration": { "morning": 1, "afternoon": 0, "evening": 1, "sleep": 0 }
    }
  ],
  "mode": "one_time",
  "source": "api"
}

The checkout URL

checkoutUrl has the form {origin}/checkout/{accessToken}:

  • Origin is your custom domain when you have one registered, verified, and linked to your site (see POST /v2/partners/settings/custom-domain); otherwise it is a DailyStak-hosted domain. Branded checkout keeps the customer on your domain end to end.
  • The token is the credential. There is no login on the checkout page — anyone holding the URL can view the cart contents and pay. Treat checkoutUrl as sensitive: don't log it, don't put it in analytics, and only deliver it to the customer it was created for.
  • It expires 24 hours after creation (expiresAt). Expired links cannot be refreshed or extended — create a new session. For protocol-backed sessions the new session automatically supersedes the old one.

What the customer sees

On the hosted page the customer confirms their shipping address, chooses a shipping method, optionally applies a promo code, and pays. Two things move as they do:

  • totalAmount changes. Shipping cost is added when a method is chosen, and tax is recalculated if the address is edited. The totalAmount you got at creation is a floor, not the final figure — re-fetch the session (or read the completed order) for current numbers.
  • shippingMethod populates on the session once selected.

Whatever amount the customer confirms is exactly the amount charged and exactly the frozen total on the resulting order.

After payment, the hosted page shows our confirmation page. successUrl / cancelUrl are accepted and stored but are not currently used for redirection.

Elements mode (your own checkout page)

Hosted checkout hands the customer to a DailyStak-rendered page. If you would rather own the cart and checkout on your own site, create the session with "uiMode": "elements" instead. We still price the cart and mint the Stripe PaymentIntent, and the charge still lands in DailyStak's Stripe account exactly as with hosted checkout — but nothing is rendered by us. Your page mounts Stripe's Payment Element against the returned clientSecret; the response carries clientSecret, stripePublishableKey and customerSessionClientSecret, and checkoutUrl is null (a new subscription is the exception; see Subscriptions).

Your browser never calls this API — only your backend does. The page talks to Stripe.js alone.

Prerequisites

  • Your account is on JUNIPER_MANAGED billing and DailyStak has enabled partner-hosted checkout for you. Ask your DailyStak contact; the switch is visible as the read-only elementsCheckoutEnabled on GET /v2/partners/settings. Otherwise session creation fails with 403 {"message": "...", "error": "ELEMENTS_CHECKOUT_DISABLED"} or 409 {"error": "ELEMENTS_REQUIRES_JUNIPER_MANAGED"}. A 503 {"error": "ELEMENTS_CHECKOUT_NOT_CONFIGURED"} means the environment itself is not configured for partner-hosted checkout.
  • Your checkout domain is registered as a payment method domain on our Stripe account, and you host Stripe's Apple Pay domain-association file at https://<your-checkout-domain>/.well-known/apple-developer-merchantid-domain-association. Without this, cards and Link work but Apple Pay and Google Pay do not appear in the Payment Element.

The flow

  1. Your backend creates the session. POST /v2/checkout/sessions with the customer, protocolId, products or cartId, mode, optional metadata, and "uiMode": "elements". Everything else about creation — the input mode, subscription rules, pricing — is unchanged. A shipping address is required at creation on this API, so the PaymentIntent exists immediately. A one-time session has no shipping method yet, so its clientSecret is null on the 201: select one with PATCH /v2/checkout/sessions/{sessionId}/shipping (step 3) and take clientSecret from that response. We never let a one-time payment be confirmed before a shipping method is chosen, because no order could be shipped from it. A new subscription's clientSecret is null until the customer's acceptance of the recurring terms is recorded; see Subscriptions. Keep clientSecret, stripePublishableKey and customerSessionClientSecret for the page, and sessionId for the reprice calls and completion.
  2. Your page mounts the Payment Element. const stripe = await loadStripe(stripePublishableKey), then const elements = stripe.elements({ clientSecret, customerSessionClientSecret }), then elements.create('payment').mount('#payment-element'). Both secrets are safe to send to the browser — one is scoped to this PaymentIntent, the other to this customer. Passing customerSessionClientSecret is what makes a returning subscription customer see their saved card instead of an empty card form; omit it and the element always asks for a new card.
  3. Reprice on every change. When the customer edits the address, picks a shipping method, or applies or removes a promo code, call the matching endpoint from your backend. Each one re-quotes the cart, updates the PaymentIntent amount in place, and returns the new totals plus clientSecret:
  4. PATCH /v2/checkout/sessions/{sessionId}/address — {"line1", "line2", "city", "state", "zipCode"} plus an optional billingAddress; tax is recalculated. Send firstName and lastName too when the recipient changes: the order ships to that name. An omitted or null name keeps the current one (the address itself is replaced), and a blank name is a 422. If your create request sent customerId, the new name also updates that customer's record, so renewals ship to it too; send customerId only for a customer you have signed in. Without it (a protocolId alone, or only an email), only this checkout's order takes the new name.
  5. PATCH /v2/checkout/sessions/{sessionId}/shipping — {"shippingMethodId"}; shippingCost is set. shippingMethodId is the id of one of your active shipping methods — use the id from GET /v2/shipping-methods.
  6. POST /v2/checkout/sessions/{sessionId}/promo — {"code"} (case-insensitive, whitespace trimmed); a code that is not valid returns 400 with code: INVALID_CHECKOUT_DATA. DELETE /v2/checkout/sessions/{sessionId}/promo clears it — and if the session was created with your automatic first-order promotion, removal restores that promotion rather than repricing to full price. Applying a second code replaces the first.

Then call await elements.fetchUpdates() so the Payment Element reflects the new amount. Never show the customer a total you computed yourself: totalAmount from the reprice response is exactly what will be charged. 4. Confirm on your page. stripe.confirmPayment({ elements, confirmParams: { return_url: "https://your-site.example/checkout/return" } }). The return URL is yours — successUrl/cancelUrl on the session play no part in elements mode — and 3D Secure challenges run on your page. 5. Completion is asynchronous. Stripe returns to your return_url with payment_intent=pi_…&redirect_status=succeeded. That means the payment succeeded, not that the order exists yet. Poll GET /v2/checkout/sessions/{sessionId} for a few seconds to show the order number (status: "completed", orderId), and record the order.created webhook event as the durable signal — it carries checkoutSessionId and your session metadata, so you can match it to your cart without polling. See Detecting completion.

Example — reprice after the customer picks a shipping method:

curl -X PATCH https://api.fulfillment.juniperhealth.com/v2/checkout/sessions/2zXq1r8vWn3kFh9YbT0cGdE5LmA/shipping \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "shippingMethodId": "2vShipMethodKsuid0000000000" }'

Response (200 OK):

{
  "sessionId": "2zXq1r8vWn3kFh9YbT0cGdE5LmA",
  "subtotal": "22.20",
  "shippingCost": "9.99",
  "taxAmount": "1.83",
  "totalAmount": "34.02",
  "clientSecret": "pi_3Pxyz_secret_abc123",
  "appliedPromoCode": null,
  "appliedPromoType": null,
  "effectiveDiscount": 10
}

Things to know

  • The switch is checked when you create a session and again on every reprice call. If DailyStak turns it off later, sessions you already created can still be fetched and paid — the customer can still confirm the Payment Element and the order is still created — but PATCH/POST/DELETE reprice calls on them are refused with 403 ELEMENTS_CHECKOUT_DISABLED.
  • clientSecret is returned only while the session awaits payment (pending/viewed and not past expiresAt), for a one-time session only once a shipping method is selected, and only from create, GET /v2/checkout/sessions/{sessionId}, and the reprice endpoints. The list endpoint and DELETE always return it as null, and in any status other than pending or viewed (including failed) it is null everywhere.
  • customerSessionClientSecret follows the same rules as clientSecret — elements sessions only, only while the session awaits payment (pending/viewed), and only from create and GET /v2/checkout/sessions/{sessionId}; it is null on list, DELETE and the reprice responses, and null for a session with no payment profile behind it. It is short-lived, so use the one from the response that rendered the page rather than storing it, and it is safe on its own — it exposes the customer's saved cards to the element, nothing else.
  • Only previously saved cards are shown, and one-time checkouts do not save one. A card is vaulted by a subscription checkout (the first charge saves it for renewals) or by POST /v2/customers/{customerId}/payment-method. A customer whose only purchases were one-time has nothing to redisplay and still sees an empty card form — that is expected, not a bug. The element lists the cards and does not offer to delete them; send the customer to the Billing Portal for that.
  • Promo codes apply only where the price can include them. A one-time session created in products mode, or from a protocol without a regimen, refuses POST .../promo with 400 rather than accepting a code that would not change the total.
  • Reprice calls are elements-only. Calling them on a hosted session returns 409 {"error": "ELEMENTS_SESSION_REQUIRED"}; on a session that is no longer pending/viewed, 409 {"error": "SESSION_NOT_PAYABLE"}; on an expired session, 410 with code: SESSION_EXPIRED.
  • Expiry and cancellation work as for hosted sessions: 24 hours, DELETE to cancel.

Subscriptions

mode: "subscription" works the same way in elements mode (still created from a protocolId, with daysSupply, or from a cartId): the first charge saves the customer's card, and we bill renewals on our schedule. Manage the subscription through the Subscriptions API.

The customer's acceptance of the recurring terms must be recorded before the first charge. Until it is, recurringTerms.status is acceptance_required and clientSecret is null. There are two ways to record it:

  • On your page. Present the recurring terms yourself (see What your page must show), then create the session with termsAcceptedAt: your affirmation that the customer accepted them, and when. The 201 carries recurringTerms.status: "accepted", recurringTerms.acceptedAt and a clientSecret. The acceptance carries over the /address, /shipping and /promo calls, so you don't send it again. A change to the products or daysSupply needs a new session: cancel this one and create another with a fresh termsAcceptedAt.
  • On our page. Leave out termsAcceptedAt and send the customer to checkoutUrl. That is our complete checkout page (address, shipping, promo and payment), not only the acceptance step, and it does not send the customer back to your site afterwards.

termsAcceptedAt is accepted only with mode: "subscription" and uiMode: "elements"; anything else returns 400 {"code": "TERMS_ACCEPTED_AT_NOT_APPLICABLE"}. It must carry a UTC offset (2026-09-19T16:02:11Z, or -05:00 for local time), be no more than 5 minutes in the future and no more than 24 hours old. Otherwise the call returns 400 with TERMS_ACCEPTED_AT_TIMEZONE_REQUIRED, TERMS_ACCEPTED_AT_IN_FUTURE or TERMS_ACCEPTED_AT_TOO_OLD, and no session is created. We record your client id and the time with the acceptance, as the consent for the recurring charge.

Replacing a saved card on your site

To let a customer replace the card used for renewals without leaving your site:

  1. Backend: POST /v2/customers/{customerId}/setup-intent (no body) → 200 {"clientSecret": "seti_…_secret_…", "setupIntentId": "seti_…", "stripePublishableKey": "pk_…"}. Load Stripe.js with the returned key rather than a copy of your own: it names the Stripe account and mode this SetupIntent belongs to, so a test-to-live switch needs no change on your side. Same gate as elements checkout (403/409/503 as above). If the customer doesn't have a payment profile yet, one is created automatically on first use; a customer with no reachable email (no email of their own and, for a managed customer, no caregiver email) gets a 400.
  2. Page: mount a Payment Element in setup mode — stripe.elements({ clientSecret }) with the SetupIntent secret — then stripe.confirmSetup({ elements, confirmParams: { return_url } }).
  3. Stripe returns to your return_url with setup_intent=seti_…&redirect_status=succeeded.
  4. Backend: POST /v2/customers/{customerId}/payment-method with {"setupIntentId": "seti_…"} → 200 {"paymentMethodId": "pm_…", "isDefault": true}. The next renewal charges this card.

Caveats:

  • Call payment-method only after redirect_status=succeeded. A SetupIntent still waiting on a 3DS challenge is requires_action, and the call returns 400 {"message": "SetupIntent is not confirmed"}. The other 400s: "SetupIntent not found" (unknown id), "Payment method does not belong to this customer", and "Customer has no payment profile; create a setup intent first".
  • The previous card stays saved on the customer; only the default changes.
  • The SetupIntent is created for the card payment method type; which wallets the Payment Element offers in setup mode follows Stripe's behaviour for the card type.

The Stripe Billing Portal (POST /v2/customers/{customerId}/billing-portal) remains available as an alternative — see Customers.

What your page must show

DailyStak is the merchant of record for these charges, so your checkout page must show, before the pay button:

  • for subscriptions, before the customer accepts the recurring terms: that the card is saved and charged automatically every daysSupply days, including when the customer is not present; today's charge; that renewal amounts use the prices and discounts in effect at renewal, plus tax, and may change (nextRenewalEstimate.total, labelled an estimate; see Disclosing the renewal amount); that checkout promotions apply only to today's order; that renewals under $50 include $9.99 shipping and renewals of $50 or more ship free; and how to cancel future renewals;
  • for every purchase: your terms and your refund terms.

A returning subscription customer sees the card DailyStak already holds, preselected in the Payment Element, when you pass customerSessionClientSecret to stripe.elements().

Metadata

metadata you send at creation is stored with the session, copied onto the resulting order, and echoed on every order.* webhook event for that order as metadata, alongside checkoutSessionId. Values may be strings, integers, booleans, null, or nested objects/arrays of those; decimal numbers such as 9.99 (at any depth) are rejected with 422 at creation. On the webhook, 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. Do not put PII or secrets in it: it is logged with the order.

Detecting completion

Completion is asynchronous — payment triggers order creation, which typically lands within seconds.

The canonical signal is the order.created webhook event. Subscribe to it and match the order back to your records. Two things to know:

  • There is no checkout.* webhook event. Sessions themselves never emit webhooks.
  • Every order.* event for the order carries checkoutSessionId (the sessionId you created) and your session 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), so you can match the order back to your records without polling — see Webhooks.
  • order.created carries subscriptionId in subscription mode, so you can attribute the first order and every renewal to the right subscription without a session read. It is null in one-time mode. protocolId rides along on the same payload.
  • subscription.created fires for checkout-created subscriptions and carries the subscriptionId; you no longer need to poll the session for it. It also carries protocolId — with one protocol per cart, that is your cart correlation key.

If you can't receive webhooks, poll the session:

curl https://api.fulfillment.juniperhealth.com/v2/checkout/sessions/2zXq1r8vWn3kFh9YbT0cGdE5LmA \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Poll every few seconds after the customer reports paying; stop when status is completed and read orderId (and subscriptionId in subscription mode). Persist orderId on your side — session records are eventually purged (see below), orders are not.

Listing sessions

GET /v2/checkout/sessions returns your sessions newest first, cursor-paginated:

curl "https://api.fulfillment.juniperhealth.com/v2/checkout/sessions?limit=25&status=completed&protocolId=2vSGym0bH8qVEwCIGlyFoRgJq1A" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
  • limit — page size, 1–100, default 50.
  • cursor — opaque cursor from the previous response's nextCursor.
  • status — filter by session status (values below).
  • protocolId — only sessions created from that protocol.

The response is {"sessions": [...], "nextCursor": "..."}. Keep paging until nextCursor is null, not until you get an empty page — when filters are applied, a page can contain fewer than limit items (even zero) while more results remain.

Cancelling a session

DELETE /v2/checkout/sessions/{sessionId} cancels a session and its payment intent, so the checkout link stops working immediately. The updated session is returned with status: "cancelled".

  • Any non-completed session can be cancelled, not just pending ones. Cancelling an already-cancelled session succeeds again (idempotent).
  • Cancellation waits for the payment intent to be cancelled. If the customer's payment has already started, or the cancellation cannot be confirmed, DELETE returns 400 and the session stays open; the payment completes as normal or you can retry the cancel.
  • A completed session cannot be cancelled (409) — the money has moved and the order exists. Refunds are issued by DailyStak; see Refunds.
  • You rarely need to cancel protocol-backed sessions manually: creating a new session for the same protocol cancels the old one for you.

Reference: session statuses

Status Meaning
pending Created; waiting for the customer to pay.
viewed Reserved for view tracking; not currently populated (same for viewedAt).
processing Payment captured; order creation in progress. Transient — usually visible for seconds.
completed Order (and subscription) created; orderId (and subscriptionId) are set. Terminal.
failed Payment was captured but order creation failed. The platform retries automatically and flags the session for support; contact support if a session stays failed.
expired Past expiresAt. Note the stored status is not eagerly flipped — an expired session usually still reads pending; judge expiry from expiresAt yourself.
cancelled Cancelled via DELETE, or superseded by a newer session for the same protocol or cart. Terminal.

Reference: errors

Checkout refusals use the standard body {"message", "code"} — branch on code. Partner-hosted (elements) refusals are the one exception: they carry the code in error instead ({"message", "error"}), marked below. No body carries both, so reading code with a fallback to error covers every case.

Status When
400 INVALID_CHECKOUT_DATA: protocol/products/partner not found, products unavailable or out of stock, a product taken by the day sent without administration, daysSupply missing in subscription mode, no customer email or shipping address available. On the reprice endpoints: an invalid promo code, a promo code on a one-time session whose price cannot include one, or an unknown shippingMethodId. On DELETE: the payment has already started or its cancellation could not be confirmed. Also IDEMPOTENCY_KEY_INVALID (an Idempotency-Key that is empty or longer than 255 characters) and the TERMS_ACCEPTED_AT_* codes above.
401 Missing or unparseable access token.
403 Valid token but missing a required scope (e.g. no order/v2.write on POST/PATCH/DELETE); or uiMode: elements / a reprice call without partner-hosted checkout enabled — body {"message": "...", "error": "ELEMENTS_CHECKOUT_DISABLED"}.
404 CHECKOUT_SESSION_NOT_FOUND: session ID unknown, owned by another partner, or purged after the retention window. CART_NOT_FOUND on create in cart mode: the cart is unknown, expired, or another partner's, or cartId isn't a cart id.
409 CHECKOUT_SESSION_ALREADY_COMPLETED: DELETE on a completed session — the money has moved and the order exists; refunds are issued by DailyStak (see Refunds). In elements mode (body {"message", "error"}): ELEMENTS_REQUIRES_JUNIPER_MANAGED (not on JUNIPER_MANAGED billing), ELEMENTS_SESSION_REQUIRED (reprice call on a hosted session), SESSION_NOT_PAYABLE (reprice call on a session no longer pending/viewed). On POST /v2/checkout/sessions with an Idempotency-Key (body {"message", "code"}): IDEMPOTENCY_KEY_REUSED (same key, different body), IDEMPOTENCY_KEY_IN_FLIGHT (the first call is still running).
410 SESSION_EXPIRED: a reprice call on an expired session, or any read or reprice of a session whose payment intent no longer exists with our payment provider (for example, one created before a payment-account change); it can never be paid, so create a new session. Both cases carry the same code; the message says which.
422 VALIDATION_FAILED — request shape invalid: more than one or none of protocolId/products/cartId, missing customer fields in products or cart mode, bad email/state/ZIP, phone with the wrong digit count, administration dayparts not summing to quantity, unknown mode, source, or uiMode value, a malformed PATCH .../address body (including an empty or whitespace-only firstName or lastName), an empty code or shippingMethodId, or a non-numeric limit on the list endpoint. In cart mode: DAYS_SUPPLY_NOT_ALLOWED (daysSupply sent with cartId), CART_EMPTY, CART_NOT_PURCHASABLE, and LINE_MODE_MISMATCH or DOSE_CAP_EXCEEDED when a product changed since its line was added; see Carts.
503 CHECKOUT_UNAVAILABLE: a session read (GET) could not be served right now; retry. CART_BUSY on create in cart mode: another session was being created from the same cart at the same moment. A retry (after Retry-After, 1 second) cancels that other session, so don't retry if you already hold its 201. Elements mode (body {"message", "error"}): ELEMENTS_CHECKOUT_NOT_CONFIGURED (partner-hosted checkout is not configured in this environment) or, on POST .../promo, PROMO_SERVICE_NOT_CONFIGURED (promo codes are unavailable in this environment).

Good to know

  • POST accepts an Idempotency-Key header. Send an opaque value of 1-255 characters and a retry of the same request returns the session the first call created — same body, 201, plus Idempotent-Replayed: true — instead of a second session and a second payment intent. Keys are scoped to your partner account and to this endpoint, and are remembered for 24 hours. Reuse of a key with a different body is a 409 IDEMPOTENCY_KEY_REUSED; retrying while the first call is still running is a 409 IDEMPOTENCY_KEY_IN_FLIGHT (wait a second and retry). A key is released if the call failed, so the retry that follows a timeout can succeed. A key longer than 255 characters (or empty) is a 400 IDEMPOTENCY_KEY_INVALID. Without the header every call creates a new session, as before. Products-mode sessions rely on this header; protocol-mode and cart-mode sessions additionally supersede open predecessors (a new protocol-backed or cart-backed session cancels its predecessors).
  • Sessions are not an archive. Records are retained roughly 90 days past expiresAt and then deleted — after that, GET returns 404. Store orderId when a session completes; use the Orders API for history.
  • clientSecret, stripePublishableKey and customerSessionClientSecret are populated only on uiMode: elements sessions — and the two secrets only while the session awaits payment (pending/viewed, not expired, and for one-time sessions after a shipping method is selected): clientSecret on create, GET by id and the reprice endpoints, customerSessionClientSecret on create and GET by id; list and cancel return them as null. On hosted sessions all three are null. stripeCheckoutSessionUrl is a legacy field and always null — never build against it.
  • source is an enum — link, widget, redirect, api, or storefront — recorded for your own attribution and echoed on the session. Anything else is a 422.
  • metadata accepts an arbitrary JSON object. It is not echoed on session responses, but it is copied onto the resulting order and echoed on every order.* webhook event for that order as 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), alongside checkoutSessionId. Values may be strings, integers, booleans, null, or nested objects/arrays of those; decimal numbers such as 9.99 (at any depth) are rejected with 422 at creation. Don't put PII or secrets in it. The reserved key campaign_token (a signed marketing campaign token) records campaign attribution when present.
  • successUrl and cancelUrl are accepted and stored but are not currently used for redirection — the hosted page ends on our confirmation page, and in elements mode your own return_url applies. They are not echoed on responses.
  • Phone numbers are normalized to 10 digits. Send 10 digits, or 11 with a leading 1; anything else is rejected.
  • An unrecognized status filter value on the list endpoint isn't an error — it just returns an empty page. Stick to the documented enum.