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.
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 orPOST /api/v1/shipping-tax/quote-tokens) from an allowed origin. Quote tokens minted beforeqt_start withpk_and keep working. Apk_publishable API key is a different credential and is refused with401 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. inlocalStorage) 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.
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
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
# 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:
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.
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.
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.
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
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.
# 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.
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.
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
storefrontUrland the hosted page simply doesn't render a "Continue shopping" button — checkout still works. -
Omit
returnUrland a completed order lands the buyer on Throttle's own/completepage instead of yours. -
Omit
cancelUrland backing out of checkout returns the buyer to this same hosted cart page (it stays live — the hand-off is sent withkeepOpen: 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>.
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>;
} 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}— setcustomerEmail/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.
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.