Cart and checkout
A storefront sends SKUs and quantities; the catalog creates a Throttle cart, prices every line itself and answers the link to Throttle's hosted cart page. The shopper pays on Throttle's checkout, and the order lands in Throttle like any other.
Resolved carts
A cart the catalog creates is a resolved cart: Throttle asks the catalog for the name, price, image and limits of each line when it is added, when its quantity changes, when a shopper signs in and again at checkout (see Line resolvers). Lines carry catalog prices only, including a signed-in shopper's customer prices.
Publishable keys are public, and allowed websites protect browsers only. Carts are protected by
server-side resolution: nothing a browser sends can set a line's price, name or image. Throttle
refuses a price on a catalog line (400 price_locked) and prices it again
from the catalog before every checkout.
Create a cart: carts.create
carts.create takes 1 to 50 lines with unique SKUs, an optional currency (else the client's currency
option, else the store's default currency) and an optional locale for the line names. It answers 201 with cartUrl (Throttle's hosted cart page), sessionId, expiresAt and the priced lines
with their subtotal in minor units. Send the shopper to cartUrl: the hosted cart
page shows the lines, lets them change quantities and has the checkout button.
import { createCatalogClient } from '@usethrottle/catalog';
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
});
// SKUs and quantities only: the catalog prices every line itself.
const cart = await catalog.carts.create({
items: [
{ sku: 'BRK-PAD-01', quantity: 2 },
{ sku: 'BRK-FLUID-1L', quantity: 1 },
],
currency: 'USD',
// Optional. Each one is an https URL on a website listed in
// Checkout settings › Allowed return websites.
returnUrl: 'https://shop.example.com/order-complete', // after payment
cancelUrl: 'https://shop.example.com/cart', // the checkout's cancel link
storefrontUrl: 'https://shop.example.com/shop', // "Continue shopping"
});
// Present only when you sent a URL. false: Throttle did not store them, so
// the shopper ends on Throttle's own completion page. The cart still works.
if (cart.returnUrlApplied === false) {
console.warn('Return URLs were not applied to this cart.');
}
console.log(cart.cart.subtotalMinor, cart.cart.currency, cart.expiresAt);
// Throttle's hosted cart page.
location.assign(cart.cartUrl);
Pass an idempotencyKey in the call options to make a retry safe: the same key
answers the same cart.
Return URLs: back to your store
Three optional URLs bring the shopper back to your store. The catalog stores them on the hosted cart session, and Throttle's cart and checkout pages use them:
| Field | Where it sends the shopper | Without it |
|---|---|---|
returnUrl | After a successful payment. | Throttle's own order-complete page (/complete on Throttle's checkout). |
cancelUrl | The cancel link on Throttle’s checkout page. | Back to the hosted cart page, which stays open. |
storefrontUrl | The “Continue shopping” link on the hosted cart page. | No “Continue shopping” link. |
-
Each must be an absolute
https:URL of at most 2,048 characters, with no spaces or control characters (otherwise400 INVALID_REQUEST). Before it sends a request,carts.createrefuses a URL that is not absolutehttpswithINVALID_REQUEST, naming the field. -
Its website must be on the store's Checkout settings › Allowed return websites (in the
Product Catalog dashboard, Settings › General, the Allowed return origins
list): an exact origin, https, with no user name or password. Otherwise the answer is
422 RETURN_URL_NOT_ALLOWED, with one issue per refused field, and no cart is created. An empty list refuses every URL. -
The answer then carries
returnUrlApplied:truewhen Throttle stored all of them,falsewhen it did not (the cart works, and the shopper ends on Throttle's pages). Without any URL the field is absent. - The URLs are stored once per session; a create with other URLs gets its own hosted cart session.
/complete page, not on your store.
Your own cart page: carts.session
To keep the cart on your site, drive Throttle's cart session yourself. catalog.carts.session(sessionId) answers get, add, update, remove, refresh and checkout. They call Throttle's cart-session routes directly, with the
session id as the only credential, so your website must also be on Throttle's allowed origins (see Keys and channels).
import { createCatalogClient } from '@usethrottle/catalog';
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
});
const { sessionId } = await catalog.carts.create({
items: [{ sku: 'BRK-PAD-01', quantity: 1 }],
});
// Throttle's cart-session routes. The session id is the only credential:
// no catalog key and no customer token go with these calls.
const session = catalog.carts.session(sessionId);
const added = await session.add({ sku: 'BRK-FLUID-1L', quantity: 2 });
const line = added.cart.lineItems?.find((item) => item.referenceId === 'BRK-FLUID-1L');
if (line) await session.update(line.id, 3);
const { cart } = await session.get();
console.log(cart.lineItems, cart.subtotal, cart.currency);
// Throttle prices every line again at the hand-off. checkout() answers the
// checkout URL and never navigates. keepOpen keeps the cart alive for a
// shopper who cancels.
const { checkoutUrl } = await session.checkout({
returnUrl: 'https://shop.example.com/order-complete',
cancelUrl: 'https://shop.example.com/cart',
keepOpen: true,
});
location.assign(checkoutUrl);
checkout({ returnUrl, cancelUrl }) needs both URLs, each absolute http or https (unlike carts.create, it accepts http). It answers checkoutUrl and never navigates. Throttle prices every line again first; if a
price changed it answers cart_prices_changed with the repriced cart, and the
next checkout goes through.
In React: useCart
Give CatalogProvider a cartUrls prop with
the same three URLs. useCart sends them on every cart it creates, and checkout() uses its returnUrl and cancelUrl unless you pass others; with either missing it rejects with INVALID_REQUEST before any request.
Because cartUrls go on every cart create, they must be absolute https URLs on an allowed return website, or every add fails. On http://localhost, say, leave cartUrls out and pass the URLs to checkout(input).
'use client';
import { useCart } from '@usethrottle/catalog/react';
// Inside a <CatalogProvider> with cartUrls. Every useCart under one provider
// shares one cart, and its changes run one at a time, in order.
export function AddToCart({ sku }: { sku: string }) {
const { add, pending } = useCart();
// The first add creates the cart; later adds go to Throttle's cart session.
return (
<button disabled={pending} onClick={() => void add(sku, 1).catch(() => {})}>
Add to cart
</button>
);
}
export function CartPage() {
const { cart, cartUrl, status, error, pending, update, remove, checkout, switchCurrency } =
useCart({ currency: 'USD' });
if (status === 'loading') return <p>Loading your cart…</p>;
if (cart === null) return <p>Your cart is empty.</p>;
const goToCheckout = async () => {
// Uses the provider's cartUrls. It answers the URL and never navigates.
const { checkoutUrl } = await checkout();
location.assign(checkoutUrl);
};
return (
<section aria-busy={pending}>
{error?.code === 'cart_prices_changed' ? (
<p>Some prices changed. Check your cart, then check out again.</p>
) : null}
<ul>
{cart.lineItems?.map((line) => (
<li key={line.id}>
{line.name} × {line.quantity}
<button onClick={() => void update(line.id, line.quantity + 1).catch(() => {})}>
+
</button>
<button onClick={() => void remove(line.id).catch(() => {})}>Remove</button>
</li>
))}
</ul>
<p>
{cart.unitCount} items, {cart.subtotal} {cart.currency} (minor units)
</p>
{/* A new cart with the same lines, priced in EUR (BC4). */}
<button onClick={() => void switchCurrency('EUR').catch(() => {})}>Pay in EUR</button>
<button disabled={pending} onClick={() => void goToCheckout().catch(() => {})}>
Checkout
</button>
{/* Or Throttle's hosted cart page, which has its own checkout button. */}
{cartUrl ? <a href={cartUrl}>Open the hosted cart</a> : null}
</section>
);
}
-
A provider holds one cart. The first
addcreates it; changes run one at a time, in call order. A session Throttle ended is recreated once from its lines; a purchased cart is never carried into a new one. -
checkout()sendskeepOpen: true, so a shopper who cancels finds the cart intact, and answers the URL without navigating. -
A guest's cart is kept when the shopper signs in and moves to the customer in
cartStorage; a guest with no cart gets the customer's saved cart, if any. On a sign-out or a switch to another customer, the outgoing customer's saved entry is removed and the incoming identity's own saved cart loads, so one customer never gets another's cart. To keep carts across reloads, give the provider acartStoragekeyed by customer (see React). - The cart follows the customer. With
@usethrottle/auth0.12.0 or later andcustomerCarton the provider, a signed-in customer's open cart comes back on any device, but only when a currency is known (set on the client or passed touseCart). See Customer carts.
Signing in with a cart
When a shopper with a cart signs in, attach the cart to their account. If they already have an open cart, its
lines move into this one: the resolved cart wins, and every catalog line is priced again for the
signed-in customer. Then call refresh() so the page shows the merged lines and
their prices.
-
With
ThrottleProviderfrom@usethrottle/auth/components, passclaimCartOnSignIn: a function returning the cart'scartId(cart.cartIdinuseCart). It claims after a sign-in, and after a sign-up that signs the shopper in, but it does not refreshuseCart, and the function has to read the cart id from below the catalog provider. Callrefresh()only after the sign-in call resolves. -
With
useCart, the reliable route is to callaccount.claimCart(cartId)yourself (fromuseAuth()orcreateAccount(auth)) and thenrefresh():
'use client';
import { useEffect, useRef } from 'react';
import { useAuth } from '@usethrottle/auth/react';
import { useCart } from '@usethrottle/catalog/react';
// Inside both providers. When a signed-out shopper with a cart signs in,
// attach the cart to their account: Throttle moves the lines of their older
// open cart into this one (the resolved cart wins) and prices every line for
// them. A session restored on page load is not a sign-in and claims nothing.
export function ClaimCartOnSignIn() {
const { status, account } = useAuth();
const { cart, refresh } = useCart();
const cartId = cart?.cartId ?? null;
const previous = useRef(status);
useEffect(() => {
const was = previous.current;
previous.current = status;
if (was !== 'unauthenticated' || status !== 'authenticated') return;
if (cartId === null) return;
void account
.claimCart(cartId)
.then(() => refresh()) // show the merged lines at the shopper's prices
.catch(() => undefined); // a failed claim never blocks the sign-in
}, [status, cartId, account, refresh]);
return null;
}
If the catalog cannot be reached during the merge, the sign-in still succeeds; the lines keep their prices and are priced again at the next change or at checkout.
Switching currency makes a new cart
A cart's currency never changes. To switch, create a new cart with the same lines in the new currency; the
old one is left to Throttle. useCart().switchCurrency(currency) does this and
moves to the new session. A currency the store has not enabled for carts answers 422 CART_CURRENCY_NOT_ENABLED, and the hook keeps the old cart and currency.
See Prices, currencies and
languages.
“Browser carts may only hold resolved lines”
By default, a Throttle cart that is not a catalog cart still takes prices from the browser. If all your products
come from the catalog, we recommend turning on Browser carts may only hold resolved lines. It is
a Throttle setting, off by default, that only the merchant can change, in the Throttle dashboard (
App settings › General › Cart pricing); the catalog extension cannot turn it on. It is set per
environment. With it on, no cart of the application in that environment accepts a browser price (400 price_locked) or a line
that is not resolved (422 resolved_lines_required).
Cart errors
From the catalog, on carts.create:
| Code | HTTP | Meaning |
|---|---|---|
CART_ITEMS_INVALID | 422 | Some lines cannot be bought. Thrown as CartLineError; lines holds { sku, code, maxQuantity? } per refused line, with NOT_FOUND, NOT_PURCHASABLE, OUT_OF_STOCK, QUANTITY_UNAVAILABLE, BELOW_MIN, ABOVE_MAX, NO_PRICE_IN_CURRENCY or PRICE_SOURCE_MISMATCH. |
RETURN_URL_NOT_ALLOWED | 422 | A return URL’s website is not on Checkout settings › Allowed return websites. One issue per refused field. |
CART_CURRENCY_REQUIRED | 422 | No currency in the request and no default currency in the catalog’s settings. |
CART_CURRENCY_NOT_ENABLED | 422 | Throttle has not enabled that currency for this store’s carts. |
CHECKOUT_NOT_CONFIGURED | 409 | The catalog has no Throttle credentials for this store yet. |
EXTENSION_UPGRADE_REQUIRED | 409 | The store has not accepted the Product Catalog extension version that prices carts. |
CART_REJECTED | 502 | Throttle refused the cart. Its own code is in error.details.throttleCode. |
CART_UNAVAILABLE | 503 | Throttle could not be reached. Retryable. |
CUSTOMER_TOKEN_INVALID | 401 | A create sent with a customer token the catalog refuses, even after asking your function again. Thrown as CustomerTokenInvalidError: a cart is never created at the wrong prices. |
import {
CartLineError,
createCatalogClient,
isCatalogError,
type CartLineProblem,
} from '@usethrottle/catalog';
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
});
declare function showLineProblem(problem: CartLineProblem): void;
try {
const cart = await catalog.carts.create({
items: [{ sku: 'BRK-PAD-01', quantity: 12 }],
returnUrl: 'https://shop.example.com/order-complete',
});
location.assign(cart.cartUrl);
} catch (error) {
if (error instanceof CartLineError) {
// CART_ITEMS_INVALID: one entry per refused line, with its own code.
// QUANTITY_UNAVAILABLE carries maxQuantity, which can be 0.
for (const problem of error.lines) showLineProblem(problem);
} else if (isCatalogError(error) && error.code === 'RETURN_URL_NOT_ALLOWED') {
// One issue per refused field: returnUrl, cancelUrl or storefrontUrl.
console.error(error.issues.map((issue) => issue.path));
} else if (isCatalogError(error) && error.retryable) {
// CART_UNAVAILABLE, STOREFRONT_UNAVAILABLE or a 429: try again later.
console.warn(`Try again in ${error.retryAfterSeconds ?? 10} s`);
} else {
// CART_CURRENCY_*, CHECKOUT_NOT_CONFIGURED, EXTENSION_UPGRADE_REQUIRED,
// CART_REJECTED: the store needs fixing; nothing the shopper can do.
throw error;
}
}
From Throttle, on the cart session (source: 'throttle', Throttle's
lowercase codes):
| Code | HTTP | Meaning |
|---|---|---|
quantity_unavailable | 409 | Not enough stock for a line. CartLineError, with maxQuantity (which can be 0). |
line_not_purchasable | 422 | A line can no longer be bought. CartLineError. |
cart_prices_changed | 409 | At checkout, a price, name or availability changed. error.details.cart is the repriced cart (useCart shows it); check out again. |
cart_session_expired | 410 | The session ended (details.reason converted means it was paid for). useCart starts a new cart. details.storefrontUrl is passed on unchecked: never render it as a link or navigate to it without your own check. |
line_resolver_unavailable | 503 | The catalog could not price the lines; nothing changed. Retryable. |
origin_not_allowed | 403 | Your website is not on Throttle's allowed origins. OriginNotAllowedError. |
import {
CartLineError,
createCatalogClient,
isCatalogError,
type ThrottleCart,
} from '@usethrottle/catalog';
const catalog = createCatalogClient({
publishableKey: 'pc_pk_your_browser_key',
});
declare const sessionId: string;
declare function showCart(cart: ThrottleCart): void;
declare function startNewCart(): Promise<void>;
const session = catalog.carts.session(sessionId);
try {
const { checkoutUrl } = await session.checkout({
returnUrl: 'https://shop.example.com/order-complete',
cancelUrl: 'https://shop.example.com/cart',
keepOpen: true,
});
location.assign(checkoutUrl);
} catch (error) {
if (error instanceof CartLineError) {
// quantity_unavailable or line_not_purchasable: lines[] says which.
console.error(error.lines);
} else if (isCatalogError(error) && error.code === 'cart_prices_changed') {
// Throttle repriced the cart. Show the new prices; the next checkout
// goes through when nothing changed again.
const { cart } = (error.details ?? {}) as { cart?: ThrottleCart };
if (cart) showCart(cart);
} else if (isCatalogError(error) && error.code === 'cart_session_expired') {
// The session ended. details.storefrontUrl is passed on unchecked: never
// render it as a link or navigate to it without your own check.
await startNewCart();
} else {
throw error;
}
}
Every code, with the SDK's error classes, is on Errors and rate limits.