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.
POST /api/v1/checkout-sessions/:id/calculate
accepts an optional email. Send it from a
custom checkout the moment the buyer has entered one.
A tax exemption belongs to a customer, so the calculation can only apply one if it knows who is buying. A cart that carries no
customerId — a guest checkout, or a quote
accepted by its buyer — is quoted as an anonymous buyer unless the email
identifies an existing customer. Completing the checkout resolves that email
anyway, so an exempt buyer who was never identified during calculation is
charged tax on screen while the document filed with your tax provider
records the sale as exempt.
The email is written onto the cart and never cleared from this route, so a later estimate that omits it does not erase one the cart already had. No customer is created here — an email that matches nothing is simply quoted as a guest.
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.