Build Cart State That Checkout Can Trust
Use @usethrottle/cart when Throttle owns the cart,
totals, and checkout handoff for your application.
Configure your application
Copy your Application UUID + add at least one allowedOrigins entry from the dashboard. POST /carts and the embed both refuse to render until these are set.
Create the cart
Start with applicationId. Currency is optional — it inherits your application currency, and a mismatched value is rejected (currency_mismatch). Keep cart operations on your backend.
Add commerce state
Add items, apply discounts, select shipping, and calculate tax before checkout.
Lock totals for checkout
Run checkout_final calculation when the buyer commits to shipping and payment.
Convert to a draft order
POST /carts/{id}/finalize converts the cart to a draft order. (Legacy alias: /checkout — same handler.) To collect payment, mint a checkout session afterwards (see Embedded Checkout).
0. Configure your application
Following the SDK example verbatim before this step fails with missing_application_id on POST /carts, or the embed renders Embed not authorized. Both checks happen before any SDK error path.
-
From the dashboard, copy your Application UUID (Settings → Identifiers) into
THROTTLE_APPLICATION_ID. -
From Embed Config, add your storefront origin (e.g.
http://localhost:3000) toallowedOrigins. -
From API Keys, create a secret key in the workspace
environment you want to use. Non-production keys look like
sk_uat_*; production keys look likesk_live_*.
applicationId, unitPrice, referenceId). Single shape — no
snake_case aliases. See
Conventions
for the full rule.
Who owns each step
Your backend owns cart mutations and secret-key calls. The browser renders buyer controls
and the checkout iframe, but it never receives your sk_ key.
sequenceDiagram
participant B as Browser
participant M as Merchant Backend
participant T as Throttle API
participant C as Checkout Iframe
B->>M: add item / apply code / choose shipping
M->>T: POST /api/v1/carts
M->>T: POST /api/v1/carts/{id}/items
M->>T: POST /api/v1/carts/{id}/apply-discount
M->>T: POST /api/v1/shipping-tax/carts/{id}/calculate
T-->>M: totals + shipping/tax snapshot
M->>T: POST /api/v1/checkout/sessions
M-->>B: sessionId + embed URL
B->>C: render checkout
C->>T: POST /api/v1/checkout-sessions/{id}/complete
T-->>B: throttle.completed End-to-end cart flow
The cart client wraps the native cart endpoints and throws ThrottleApiError for non-2xx responses.
It extends the shared ThrottleError (from @usethrottle/errors, re-exported by
every SDK), so one instanceof ThrottleError catch
handles errors from all Throttle SDKs. See the
error reference
.
npm install @usethrottle/cart // PREREQUISITES (Step 0 in /developers/quickstart):
// 1. Sign in to the dashboard. A default Application is auto-created for you.
// Copy its UUID (App settings -> Identifiers) into THROTTLE_APPLICATION_ID.
// 2. In Embed Config, add your storefront origin (e.g. http://localhost:3000)
// to allowedOrigins. The PaymentEmbed will render "Embed not authorized" until you do.
// 3. Generate a server-side secret key (for example sk_uat_* or sk_live_*)
// from API Keys and put it in THROTTLE_API_KEY.
import { CartClient } from '@usethrottle/cart';
const client = new CartClient({
apiKey: process.env.THROTTLE_API_KEY!,
});
const cart = await client.carts.create({
applicationId: process.env.THROTTLE_APPLICATION_ID!, // uuid from Step 0.1
currency: 'USD',
});
await client.items.add(cart.id, {
type: 'product',
name: 'Premium Widget',
unitPrice: 2999, // minor units (cents)
quantity: 2,
});
await client.discounts.apply(cart.id, 'SAVE10');
// Pass the address inline to set the destination AND get rates in one call
// (it is persisted to the cart) — no separate carts.update needed.
const quote = await client.shippingTax.calculateCart(cart.id, {
kind: 'cart_estimate',
shippingAddress: { countryCode: 'US', postalCode: '94107', stateProvince: 'CA' },
});
// /carts/{id}/checkout creates a DRAFT ORDER from the cart. It does NOT
// create a checkout session by itself. To render the payment embed, mint
// a checkout session afterwards (see /developers/embedded-checkout).
const order = await client.carts.checkout(cart.id, {
paymentMethod: 'card',
}); openapi-typescript / openapi-fetch for typed client
generation. The pre-generated client lives at
@usethrottle/cart
.
POST /api/v1/carts, then pass its cartId to POST /api/v1/checkout/sessions. The externalCartId field is only for
applications that have connected an external cart provider. Passing externalCartId on a native application returns 400 invalid_mode (“this application uses
native Throttle carts”) — create a cart first and use cartId instead.
Shipping and tax
Use cart calculation from your backend when a cart already exists in Throttle. Use storefront quote tokens for read-only estimates before checkout.
import { StorefrontQuoteClient } from '@usethrottle/cart';
const quotes = new StorefrontQuoteClient({
applicationId: 'e7efb0a6-892e-46b2-97ab-296bb04c5b29',
quoteToken: 'pk_publishable_quote_token',
origin: 'https://shop.example.com',
});
const estimate = await quotes.quote({
currency: 'USD',
items: [
{
id: 'sku_123',
quantity: 1,
subtotalAmount: 12900,
requiresShipping: true,
taxCategory: 'standard',
},
],
addresses: {
shipping: {
countryCode: 'US',
stateProvince: 'CA',
postalCode: '90001',
},
},
}); pk_ and are scoped by
application and allowed origin. They can estimate totals, but they cannot mutate carts or
create orders.
carts.create and carts.update accept an optional customerEmail — a way to attach a
buyer’s email to a cart without minting a full customer record
(e.g. an “email me my cart” field). When set, an otherwise-anonymous
abandoned cart becomes recoverable: the cart.abandoned webhook carries it as customer.email (with id: null). A fully linked customer’s
email always takes precedence. Pass null on update to clear it.
carts.create and carts.update accept an optional buyerVatNumber, and the public POST /api/v1/checkout-sessions/:id/calculate
accepts it too so a hosted or embedded checkout can capture it as the buyer
types. Pass null to clear it.
On a cross-border supply between two EU member states, a buyer VAT number zero-rates the sale under the reverse-charge rules — the buyer self-accounts for the VAT. A domestic B2B sale still carries VAT, so supplying a VAT number there changes nothing. The decision is recorded once, at calculation time, and surfaces as
taxReverseCharge on the cart and on the
resulting order; the customer invoice PDF renders the required notice.
This applies when the tax axis is in
app_based mode with a connected provider.
Your provider decides the final treatment — setting a VAT number does not
by itself guarantee zero tax.
Because of that, the public
GET /api/v1/checkout-sessions/:id response
carries collect.vatNumber. It is true only when the merchant's published tax
mode is app_based, and Throttle's own hosted
checkout hides the VAT field when it is false. Build a custom checkout the
same way: asking for a number no provider will read records data that
changes nothing, while implying the buyer's supply will be zero-rated. The
field is read-only and derived server-side — the collect object you pass at session create
still only accepts shippingAddress and billingAddress.
The cart is the source of truth for shipping and totals
Selecting a shipping method is a single, atomic call. The response is the full cart with shippingTotal, taxTotal, and total already recomputed — you do not need a
calculate-before or a recalculate-after round trip, and you do not need to keep a parallel
copy of the selected method or totals in client state.
// Selecting a shipping method is ONE call. The response is the full cart with
// shipping, tax, and total already recomputed — no separate calculate-before or
// recalculate-after, and no need to keep your own copy of the totals.
const cart = await client.shipping.select(cartId, {
methodId: 'standard',
displayName: 'Standard (3-5 days)',
rateAmount: 799,
});
cart.selectedShipping; // { methodId, displayName, rateAmount, currency, ... }
cart.shippingTotal; // 799
cart.taxTotal; // recomputed
cart.total; // recomputed
// GET /carts/{id} returns the same authoritative shape any time you need it:
const fresh = await client.carts.get(cartId);
fresh.selectedShipping; // bind your UI directly to this — it never drifts. shipping.select and carts.get both return selectedShipping plus all totals and per-jurisdiction taxLines. Totals are recomputed on every cart mutation,
so bind your UI directly to the returned cart. Keeping a separate copy of the shipping rate
or tax total is the most common source of a stale Order Summary.
Addresses
carts.update accepts shippingAddress and billingAddress in one canonical shape — the same CartAddress type the SDK exports and the shipping/tax
calculate endpoint uses. addressLine1, city, and countryCode (ISO-3166-1 alpha-2) are required; the rest
(addressLine2, stateProvince, postalCode, firstName, lastName, company, phone, email) are optional. company is for B2B addresses and is never required.
// One canonical address shape (CartAddress). Required: addressLine1, city,
// countryCode (ISO-3166-1 alpha-2). Validated at write time.
await client.carts.update(cartId, {
shippingAddress: {
firstName: 'Ada',
addressLine1: '1 Market St',
addressLine2: 'Suite 400',
city: 'San Francisco',
stateProvince: 'CA',
postalCode: '94105',
countryCode: 'US',
},
});
// ❌ line1 / state / country / zip are rejected with a did-you-mean hint —
// they are NOT silently stored. line1, state, country, or zip returns a validation_error that names the offending field and the
canonical replacement (e.g. line1 → addressLine1). This
is deliberate: previously a mismatched address was stored verbatim and only failed much
later at checkout with address_required.
Discounts
Native carts support percentage and fixed amount promotion codes. Preview a code in your storefront, then apply it to the cart before checkout so payment receives the discounted total.
Native cart endpoints
Create
POST /api/v1/carts
Add item
POST /api/v1/carts/{id}/items
Preview discount
POST /api/v1/discounts/preview
Apply discount
POST /api/v1/carts/{id}/apply-discount
Remove discount
DELETE /api/v1/carts/{id}/discount
Select shipping
POST /api/v1/carts/{id}/shipping
Set tax lines
PUT /api/v1/carts/{id}/tax-lines
Finalize
POST /api/v1/carts/{id}/finalize
POST /api/v1/carts/{id}/finalize converts the cart to a draft order (status draft). It does not mint a
checkout session or render an embed by itself. To collect payment,
follow with POST /api/v1/checkout-sessions/embed-token
and mount PaymentEmbed against
the resulting sessionId. See
Embedded Checkout
.
The legacy alias
POST /api/v1/carts/{id}/checkout
still exists and hits the same handler — kept for backward
compatibility, but new integrations should call /finalize.