Guide

Cart sessions

Create and own a Throttle cart directly from the browser — no backend required. Cart sessions are the front door for JAMstack / static storefronts that don't run a server with a secret key.

Typed SDK + React hook
Prefer a typed client? CartSessionClient in @usethrottle/cart wraps every endpoint below — create/resume a session, then addItem, selectShipping, applyDiscount, checkout. In React, the useCartSession hook in @usethrottle/checkout-react adds reactive cart state + automatic localStorage persistence. The raw HTTP API is documented here for reference.

How it works

Server-side integrations build carts with a secret sk_ key. A frontend-only storefront can't hold a secret key, so it uses a cart session instead:

  • You create a cart session with your storefront quote token (qt_… — the same token used for shipping/tax quotes, from the dashboard Shipping & Tax page or POST /api/v1/shipping-tax/quote-tokens) from an allowed origin. Quote tokens minted before qt_ start with pk_ and keep working. A pk_ publishable API key is a different credential and is refused with 401 invalid_quote_token.
  • Throttle creates the cart and returns an opaque cart_… session id. That id is a bearer capability scoped to exactly one cart — hold it client-side (e.g. in localStorage) and use it to add, update, and remove items.
  • Because the cart lives in Throttle from the first add-to-cart, you get abandoned-cart recovery and a clean hand-off to checkout for free.
Two gates, no API key
Creation is authorized by your publishable token + the application origin allowlist. Every later call is authorized by possession of the opaque cart_… id + origin. No secret key is ever exposed to the browser, and a session can only ever touch its own cart.

Set your allowed origins first

Cart-session requests are rejected unless the request Origin is on the application's allowlist (the same allowlist used by embedded checkout and quotes). Set it via PUT /api/v1/embed-config.

Create a cart session

bash
curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions \
  -H "Origin: https://shop.example.com" \
  -H "Content-Type: application/json" \
  -d '{
    "applicationId": "7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40",
    "environmentId":  "a1b2c3d4-...",
    "quoteToken": "qt_...",
    "currency": "USD"
  }'

Returns cartSessionId (the cart_… token), expiresAt, a branding block ( logoUrl, primaryColor, merchantName — each nullable), and a cart snapshot. The same branding block is also returned by GET /api/v1/cart-sessions/{id}, so a storefront rendering its own cart UI can brand it without a separate call.

Build the cart

bash
# Add an item
curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/items \
  -H "Origin: https://shop.example.com" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Premium Widget", "unitPrice": 2999, "quantity": 2 }'

# Update an item
curl -X PATCH https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/items/<itemId> \
  -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
  -d '{ "quantity": 3 }'

# Remove an item
curl -X DELETE https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/items/<itemId> \
  -H "Origin: https://shop.example.com"

# Read the current cart
curl https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id> \
  -H "Origin: https://shop.example.com"

quantity is an integer between 1 and 10,000 on both add and update. Above that the request is rejected with Quantity cannot exceed 10000. — a cart line is not a bulk order; larger asks belong on the quote surface.

The add-item route (and the SDK's addItem) accepts the same recurring block as the Cart API, so a browser cart can hold subscription lines. The limits and codes are identical: see Cart API → Subscription lines.

Cart badge: just the number

A storefront header shows a count next to the cart icon on every page load. Reading it from GET /api/v1/cart-sessions/{id} works, but that response carries the line items, both addresses, the buyer's email and their notes — the whole cart, shipped to every page of the site, to render one integer. /summary is the small read for it:

bash
curl https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/summary \
  -H "Origin: https://shop.example.com"

# {
#   "data": {
#     "cartSessionId": "cart_<id>",
#     "status": "active",
#     "unitCount": 4,
#     "total": 12497,
#     "currency": "USD"
#   }
# }

unitCount counts units, not distinct lines: a cart holding 1 stand, 2 notebooks and 1 keyboard reports 4, not 3. The same field is on the cart object every other cart-session endpoint returns, so a page that already has the cart doesn't need this call.

The typical shape on a storefront: restore the session id from localStorage on page load, draw the number, and forget the id once the session is finished.

ts
import { CartSessionClient } from '@usethrottle/cart';

const savedId = localStorage.getItem('throttle_cart_session');
const badge = document.querySelector('#cart-badge');

if (savedId && badge) {
  // forSession() needs no quote token — the opaque cart_ id is the credential.
  const { unitCount, status } = await CartSessionClient.forSession(savedId).summary();

  badge.textContent = String(unitCount);
  badge.hidden = unitCount === 0;

  // Only these two mean the cart is done. Drop the id so the next add-to-cart
  // starts a fresh session. Do NOT test `status !== 'active'`: a cart
  // mid-payment reports 'checkout', and the checkout cancel path reopens it.
  if (status === 'expired' || status === 'converted') {
    localStorage.removeItem('throttle_cart_session');
  }
}

A finished session answers 200 with zeros — unitCount: 0, total: 0, currency: null — and a status of "expired" or "converted". A third value, "checkout", means the cart is mid-payment: keep the id, because the checkout cancel path reopens that cart and the buyer will want it back. This is the one endpoint that does not send cart_session_expired (410): a badge runs on every page, and drawing a zero shouldn't need a try/catch. An id that was never issued is still a cart_session_not_found (404) — that is a bug in the integration, not a finished cart.

A read stays a read
A cart the abandoned-cart sweep has taken reports its real unitCount with status: "active" — the buyer can still come back to it — and the summary never reopens it. Only a write does that, so a link prefetcher or an email scanner hitting a badge cannot resurrect a cart with nobody present.

Buyer details

PATCH /api/v1/cart-sessions/{id} sets buyer-editable fields on the cart: customerEmail and notes, both optional. Send null for either to clear it. The body must carry at least one of the two — an empty object is rejected with Provide at least one of customerEmail or notes.

On a cart backed by an external cart provider, customerEmail is rejected with mode_not_supported (422) rather than accepted and dropped; notes works on both providers.

bash
curl -X PATCH https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id> \
  -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
  -d '{ "customerEmail": "[email protected]", "notes": "Leave at the door" }'
customerEmail is what enables recovery
A guest cart is invisible to abandoned-cart recovery until it has a known email. Setting customerEmail here — even without a full customer record — is what makes the cart eligible for the cart.abandoned sweep and recovery email described in Cart states.

Shipping & discounts

Fetch live rates from POST /api/v1/shipping-tax/quotes (same publishable token), then select a method on the session. Apply a promo code the same way.

bash
# Select a shipping method
curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/shipping \
  -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
  -d '{ "methodId": "fedex_ground", "displayName": "FedEx Ground", "rateAmount": 599 }'

# Apply a discount code
curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/discount \
  -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
  -d '{ "code": "SAVE10" }'

Hand off to checkout

When the buyer is ready, convert the cart session into a checkout session and redirect them to the returned checkoutUrl. By default this marks the cart session converted (no further edits). Pass keepOpen: true to leave it active instead, so a buyer who backs out of checkout returns to a live cart rather than an expired one — Throttle's own hosted cart page sets this.

bash
curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions/cart_<id>/checkout-session \
  -H "Origin: https://shop.example.com" -H "Content-Type: application/json" \
  -d '{
    "returnUrl": "https://shop.example.com/thanks",
    "cancelUrl": "https://shop.example.com/cart",
    "keepOpen": true
  }'

If the cart holds a subscription plan line, the hand-off carries the plan's recurring intent over from the cart, so the subscription is created when the buyer pays. If the intent cannot be recovered the hand-off is refused before any charge: 422 subscription_terms_undeterminable, or 422 recurring_customer_required when there is no buyer email to inherit or send. Shipping is kept for carts that also hold physical goods. See Starting checkout from an existing cart .

Skip the UI: Throttle's hosted cart page

Don't want to build cart UI at all? Send the buyer to Throttle's own hosted cart page instead — it renders the same session, branded with the branding block above, and hands off to hosted checkout itself.

ts
import { CartSessionClient } from '@usethrottle/cart';

const cart = new CartSessionClient({ applicationId, environmentId, quoteToken });

const session = await cart.create({
  currency: 'USD',
  metadata: {
    returnUrl: 'https://shop.example.com/thanks',
    cancelUrl: 'https://shop.example.com/cart',
    storefrontUrl: 'https://shop.example.com',
  },
});

await cart.addItem({ name: 'Premium Widget', unitPrice: 2999, quantity: 2 });

location.href = cart.hostedCartUrl();

returnUrl, cancelUrl, and storefrontUrl are all optional. Each is read back off the session's metadata by the hosted page itself, not validated at create time:

  • Omit storefrontUrl and the hosted page simply doesn't render a "Continue shopping" button — checkout still works.
  • Omit returnUrl and a completed order lands the buyer on Throttle's own /complete page instead of yours.
  • Omit cancelUrl and backing out of checkout returns the buyer to this same hosted cart page (it stays live — the hand-off is sent with keepOpen: true).

One hook for the whole flow: useThrottleCheckout

useThrottleCheckout (in @usethrottle/checkout-react) wraps all of the above: it binds totals and selectedMethod straight to the cart (no shadow copy to drift), locks shipping in one call via selectMethod, exposes a status state machine, and auto-recovers from a stale cart — if the cart was converted by a completed order or abandoned, it rebuilds a fresh session from the last-known line items and retries ( status === 'recovering'). createSession returns the checkoutSessionId for <PaymentEmbed>.

tsx
import { useThrottleCheckout, PaymentEmbed } from '@usethrottle/checkout-react';

function Checkout() {
  const {
    addItem, selectMethod,   // one-call select — returns the recomputed cart
    totals, selectedMethod,  // bound to the cart; never a stale copy
    status,                  // 'idle' | 'loading' | 'recovering' | 'ready' | 'error'
    createSession,           // → { checkoutSessionId } for <PaymentEmbed>
  } = useThrottleCheckout({ applicationId, environmentId, quoteToken });

  const start = async () => {
    const { checkoutSessionId } = await createSession({
  returnUrl: 'https://shop.example.com/thanks',
  cancelUrl: 'https://shop.example.com/cart',
    });
    setSessionId(checkoutSessionId);
  };

  return sessionId
    ? <PaymentEmbed sessionId={sessionId} parentOrigin={location.origin} />
    : <button onClick={start} disabled={status === 'loading'}>Pay {totals?.total}</button>;
}
Address collection lives in the embed
The hook does not write addresses to the cart — the hosted/full checkout embed collects shipping & billing. The hook focuses on cart state, shipping selection, totals, recovery, and the session handoff.

Endpoints

  • POST /api/v1/cart-sessions — create a cart + session.
  • GET /api/v1/cart-sessions/{id} — read the session + cart.
  • GET /api/v1/cart-sessions/{id}/summary — unit count + total, nothing else.
  • PATCH /api/v1/cart-sessions/{id} — set customerEmail / notes.
  • POST /api/v1/cart-sessions/{id}/items — add an item.
  • PATCH /api/v1/cart-sessions/{id}/items/{itemId} — update an item.
  • DELETE /api/v1/cart-sessions/{id}/items/{itemId} — remove an item.
  • POST /api/v1/cart-sessions/{id}/shipping — select a shipping method.
  • DELETE /api/v1/cart-sessions/{id}/shipping — clear the selected method.
  • POST /api/v1/cart-sessions/{id}/discount — apply a discount code.
  • DELETE /api/v1/cart-sessions/{id}/discount — remove the discount.
  • POST /api/v1/cart-sessions/{id}/checkout-session — hand off to checkout.
Errors
invalid_quote_token (401) — bad token, app/environment, or origin. origin_not_allowed (403) — origin not on the allowlist. cart_session_expired (410) — session is no longer active; details.reason is "expired" (past expiresAt) or "converted" (already handed off to checkout), and details.storefrontUrl echoes back whatever the session's metadata held, for a "back to the store" link. A cart the abandoned-cart sweep has taken is not a 410: the session still reads, reporting "abandoned", and the first write reopens it — so both a recovery link and a buyer returning from checkout land on a working cart. Reads leave it alone, so a link scanner cannot resurrect a cart on the buyer's behalf. item_not_found (404) — the item isn't in this session's cart. mode_not_supported (422) — PATCH of a field this cart's provider cannot store. rate_limit_exceeded (429) — too many POST /cart-sessions creates from one client; back off and retry.