@usethrottle/cart
v3.15.0
Published
Typed REST client for the Throttle Cart API.
Readme
@usethrottle/cart
Typed Node.js REST client for the Throttle Cart API.
Install
npm install @usethrottle/cartQuickstart
End-to-end: create a cart, build it out, and convert it to an order.
import { CartClient } from '@usethrottle/cart';
const client = new CartClient({ apiKey: process.env.THROTTLE_API_KEY! });
// 1. Create a cart
const cart = await client.carts.create({
applicationId: '7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40',
currency: 'USD',
netN: 45,
});
// 2. Add a line item
await client.items.add(cart.id, {
type: 'product',
name: 'Premium Widget',
unitPrice: 2999,
quantity: 2,
});
// 3. Select a shipping method
await client.shipping.select(cart.id, {
methodId: 'fedex_ground',
displayName: 'FedEx Ground',
rateAmount: 599,
});
// 4. Apply a discount code
await client.discounts.apply(cart.id, 'SAVE10');
// 5. Set tax lines
await client.taxLines.set(cart.id, [
{ lineItemId: '...', jurisdictionCode: 'US-NY', taxType: 'sales', rate: 0.04, amount: 400 },
]);
// 6. Checkout → Order
const order = await client.carts.checkout(cart.id, { paymentMethod: 'card' });
console.log(order.id, order.status);Use applicationId for every public request body. Deprecated store identifiers are rejected by the API.
All monetary values are integers in the smallest currency unit (cents for USD).
Browser carts (backend-less)
CartClient carries a secret sk_ key and must run on your server. For
JAMstack / frontend-only storefronts that have no backend, use
CartSessionClient instead — it creates and owns a Throttle cart directly from
the browser, authorized by a storefront quote token (qt_…, the same token used
by StorefrontQuoteClient) plus your application's origin allowlist. 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.
Set your allowed origins first (PUT /api/v1/embed-config), then:
import { CartSessionClient } from '@usethrottle/cart';
const cart = new CartSessionClient({
applicationId: '7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40',
environmentId: 'a1b2c3d4-...',
quoteToken: 'qt_...',
});
// Create a new session (store cart.cartSessionId in localStorage),
// or resume one you saved: cart.resume(savedId)
await cart.create({ currency: 'USD' });
await cart.addItem({ name: 'Premium Widget', unitPrice: 2999, quantity: 2 });
await cart.applyDiscount('SAVE10');
await cart.selectShipping({ methodId: 'fedex_ground', displayName: 'FedEx Ground', rateAmount: 599 });
// Set buyer-editable fields. `customerEmail` is what makes a guest cart
// visible to abandoned-cart recovery. `null` clears a field.
await cart.update({ customerEmail: '[email protected]', notes: 'Leave at the door' });
// Hand off to hosted/embedded checkout and redirect the buyer.
const { checkoutUrl } = await cart.checkout({
returnUrl: 'https://shop.example.com/thanks',
cancelUrl: 'https://shop.example.com/cart',
// Leave the session `active` instead of `converted`, so a buyer who backs
// out of checkout returns to a live cart instead of an expired one.
keepOpen: true,
});create() and get() also return a branding block (CartBranding:
logoUrl, primaryColor, merchantName, each nullable) so a storefront
rendering its own cart UI doesn't need a separate call to brand it.
To bind to an existing session without a publishable token — e.g. Throttle's
own hosted cart page, or a storefront restoring a saved session id — use
CartSessionClient.forSession() instead of new CartSessionClient():
const cart = CartSessionClient.forSession(savedCartSessionId);
await cart.addItem({ name: 'Premium Widget', unitPrice: 2999, quantity: 1 });The opaque cart_… session id is scoped to exactly one cart. See the
cart sessions guide.
Hosted cart page
Skip building cart UI entirely and send the buyer to Throttle's own hosted
cart page with hostedCartUrl():
const session = await cart.create({
metadata: {
returnUrl: 'https://shop.example.com/thanks',
cancelUrl: 'https://shop.example.com/cart',
storefrontUrl: 'https://shop.example.com',
},
});
location.href = cart.hostedCartUrl(); // https://checkout.usethrottle.dev/cart/cart_...returnUrl, cancelUrl, and storefrontUrl in metadata are all optional.
Omitting storefrontUrl just removes the hosted page's "Continue shopping"
button — checkout still works. Omitting returnUrl or cancelUrl falls back
to Throttle's own /complete page and this same hosted cart page,
respectively.
Resolved carts
A resolved cart's lines are priced by an installed app (a line resolver, such
as Product Catalog), never by the browser. Create the session with
lineResolver and the environment's currency, then add lines by
referenceId. Throttle asks the app for the price, stock and limits on every
add, quantity change, sign-in merge and checkout.
import {
QuantityUnavailableError,
LineNotPurchasableError,
LineResolverUnavailableError,
CartPricesChangedError,
} from '@usethrottle/cart';
await cart.create({
currency: 'USD', // required with lineResolver; must be the environment's currency
lineResolver: { extensionId: '0b6a3c1e-8d2f-4a5b-9c7d-1e2f3a4b5c6d' },
locale: 'en-US', // optional, passed to the app
});
try {
await cart.addResolvedItem({ referenceId: 'TSHIRT-BLUE-M', quantity: 2 });
} catch (e) {
if (e instanceof QuantityUnavailableError) showMessage(`Only ${e.maxQuantity ?? 0} available`);
else if (e instanceof LineNotPurchasableError) showMessage('No longer available');
else if (e instanceof LineResolverUnavailableError) showMessage('Try again in a moment');
else throw e;
}
// Re-check every owned line (for example when the buyer comes back to the cart).
const { changed, ...current } = await cart.refresh();
try {
const { checkoutUrl } = await cart.checkout({ returnUrl, cancelUrl });
location.href = checkoutUrl;
} catch (e) {
if (e instanceof CartPricesChangedError) {
// The new prices are already applied; show them, then check out again.
if (e.cart) render(e.cart);
} else throw e;
}addResolvedItem() sends only referenceId, quantity (default 1) and
metadata. The app sets the name, price and image: sending unitPrice,
name, imageUrl, taxAmount, discountAmount, a reserved metadata key
(lineResolver, requiresShipping) or a metadata key that changes a shipping
or tax quote answers 400 price_locked (PriceLockedError.fields names them).
updateItem() keeps working for quantity and other metadata; a quantity change
is re-checked by the app.
A resolved cart carries lineResolver: { extensionId } (extensionId is
null if the app's installation is gone), and each line carries resolution:
null on a line the app does not own, otherwise { status, code, maxQuantity,
minQuantity }. status is resolved, refused (the app refused the line;
code says why, for example OUT_OF_STOCK) or stale (the last re-check
failed; checkout re-checks it). Carts created without lineResolver have
neither key.
Shipping on a resolved cart is priced by Throttle, not the browser: pick a
method Throttle quotes for the cart with
cart.selectShippingMethod({ methodId }), which sends only the method id (the
cart needs a shipping address first). A method Throttle does not quote answers
422 shipping_method_unavailable. If you use selectShipping() instead, a
rateAmount or currency different from the quote answers 400
price_locked. There is no flat-rate fallback (no selection means shipping
is not calculated yet). The checkout hand-off re-checks every line and
re-quotes shipping; any change answers 409 cart_prices_changed with the
updated cart and, when shipping moved, shipping: { methodId,
previousAmount, amount, reason? } (amount is null with reason
method_unavailable or quote_unavailable).
From your server (CartClient)
Other apps can integrate the same way with a secret key. Create the cart with
lineResolver (and the environment's currency), optionally pricing up to 50
lines in one call with lineItems (all or nothing), then add lines by
referenceId:
const cart = await client.carts.create({
applicationId,
currency: 'USD',
lineResolver: { extensionId: '0b6a3c1e-8d2f-4a5b-9c7d-1e2f3a4b5c6d' },
locale: 'en-GB',
lineItems: [{ referenceId: 'TSHIRT-BLUE-M', quantity: 2 }],
});
const line = await client.items.addResolvedItem(cart.id, { referenceId: 'MUG-WHITE', quantity: 1 });A server cart has no refresh: refresh() exists only on a browser cart
session (CartSessionClient). From the server, a line is re-checked with the
app when you change its quantity with items.update(cartId, itemId,
{ quantity }). To re-check the whole cart, mint a hosted-cart session for it
(POST /api/v1/carts/{id}/sessions, which answers { sessionId, cartUrl,
expiresAt }) and call CartSessionClient.forSession(sessionId).refresh(). An
owned line's unitPrice, taxAmount, discountAmount and reserved metadata
keys answer 400 price_locked for every caller. Both clients throw the same
typed errors:
| Error class | Status / code | Typed fields (details) |
| --- | --- | --- |
| PriceLockedError | 400 price_locked | fields |
| QuantityUnavailableError | 409 quantity_unavailable | reason, maxQuantity (or null) |
| LineNotPurchasableError | 422 line_not_purchasable | reason on an add; lines at checkout |
| LineResolverUnavailableError | 503 line_resolver_unavailable | reason (or null); nothing changed, retry |
| LineResolverNotAvailableError | 422 line_resolver_not_available | reason; the app is not installed |
| CurrencyNotEnabledError | 422 currency_not_enabled | details.currency, details.enabledCurrencies |
| ResolvedLinesRequiredError | 422 resolved_lines_required | the store only accepts resolved lines |
| LineLimitReachedError | 422 line_limit_reached | details.maxLines (50 per app) |
| CartPricesChangedError | 409 cart_prices_changed | cart, shipping (or null) |
| ShippingMethodUnavailableError | 422 shipping_method_unavailable | |
| ShippingQuoteUnavailableError | 503 shipping_quote_unavailable | nothing changed, retry |
| AmountTooLargeError | 422 amount_too_large | lower the quantity |
| CartChangedError | 409 cart_changed | the cart changed during the add; retry |
Every class extends ThrottleApiError (and so ThrottleError), so existing
instanceof ThrottleApiError and e.code checks keep working.
Net-N invoice terms
Cart-level invoice terms are optional. Pass netN on cart create or update to
set the cart override, or null to clear it. The final invoice term is resolved
when the buyer completes checkout with Invoice Terms:
customer.netN ?? cart.netN ?? DEFAULT_NET_NallowedMethods only controls which payment methods can appear in checkout; it
does not change the Net-N day count. For hosted/embed checkout, create a cart
with netN and then create a checkout session for that cart.
Shipping and tax
Use the secret-key client on your backend to calculate and persist cart totals with Throttle's native engine:
const estimate = await client.shippingTax.calculateCart(cart.id, {
kind: 'cart_estimate',
});
const locked = await client.shippingTax.calculateCart(cart.id, {
kind: 'checkout_final',
selectedShippingMethodId: estimate.shipping.selectedMethod?.id,
});External stores can also request read-only storefront quotes with a storefront
quote token (qt_…, or a legacy pk_… quote token). Create one from the dashboard Shipping & Tax page or with
POST /api/v1/shipping-tax/quote-tokens.
import { StorefrontQuoteClient } from '@usethrottle/cart';
const quotes = new StorefrontQuoteClient({
applicationId: '7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40',
quoteToken: 'qt_storefront_quote_token',
});
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' },
},
});For provider-owned totals, set the store to byo mode on the shipping or tax
axis (or both) and push the final snapshot through
/api/v1/shipping-tax/external-snapshots. Throttle accepts external pushes
when at least one axis is in byo mode:
await client.shippingTax.pushExternalSnapshot({
applicationId: '7f9d4c8a-5b2e-4f16-9a73-2d1e5c8b6f40',
cartId: cart.id,
currency: 'USD',
totals: {
subtotal: 12900,
discountTotal: 0,
shippingTotal: 600,
taxTotal: 1215,
total: 14715,
},
});Subscriptions
Put recurring on a line to make it a subscription. A cart can hold any
number of one-time lines and up to 10 subscription lines, with the same plan
or different ones. The whole cart is paid in one capture, with one shipping
fee, and completes as one order linked to every subscription it created.
await client.items.add(cart.id, { name: 'Coffee kit', unitPrice: 4500, quantity: 1 });
await client.items.add(cart.id, {
name: 'Barista Club',
unitPrice: 2000, // per period, per seat
quantity: 2, // seats
recurring: { plan: 'club_monthly', interval: 'monthly', trialDays: 14 },
});interval(weekly,biweekly,monthly,quarterly,yearly), orintervalUnit(hour,day,week,month) +intervalCount: 1 hour to 1 year.totalPayments(2–60) makes an installment plan;trialDays(0–365) a free trial. They cannot be combined (400 invalid_combination).typeis forced tosubscription, and the line is never charged shipping. It ships only withdeliveryMethod: 'shipment', which makes a physical subscription: every renewal order owes a box.requiresShipping: trueis rejected with400 validation_error.- A free-trial line is stored with
unitPrice: 0and your price onrecurring.amount, and its name gets a "— 14-day free trial" suffix. PATCHmay changequantity(seats) andmetadataonly. ChangingunitPrice,taxAmountordiscountAmountis422 recurring_line_immutable. Remove the line and add it again to change its terms.- Each line's
subscriptionIdis set when the checkout completes.
| Status | Code | When |
|---|---|---|
| 422 | too_many_subscriptions | An 11th subscription line |
| 422 | recurring_not_supported_for_cart | The cart is managed by an external cart provider |
| 422 | recurring_line_immutable | A PATCH changes a subscription line's price, tax or discount |
| 400 | invalid_line_item | A free-trial line carries taxAmount or discountAmount |
| 400 | invalid_interval / interval_conflict / invalid_total_payments / invalid_combination / invalid_plan / invalid_trial_days | Terms out of range |
Do not also send a session recurring intent for the same cart. The two are
exclusive, and session creation answers 422 recurring_source_conflict.
Events
Every mutation appends an immutable event to the cart's event log. Fetch it with:
const events = await client.events.list(cart.id);
// [{ eventType: 'cart.created', sequence: 1, ... }, ...]
// Poll only new events since the last sequence you saw
const newEvents = await client.events.list(cart.id, { sinceSequence: 5, limit: 50 });For real-time delivery, subscribe to Throttle's outbound webhooks from your merchant dashboard. Webhooks are signed with HMAC-SHA256 and fire from Throttle's servers to your backend over HTTPS.
Errors
All non-2xx responses throw ThrottleApiError. Inspect code for the
machine-readable reason and statusCode for the HTTP status:
import { ThrottleApiError } from '@usethrottle/cart';
try {
await client.discounts.apply(cart.id, 'EXPIRED');
} catch (e) {
if (e instanceof ThrottleApiError && e.code === 'discount_invalid') {
// surface a user-friendly message
}
if (e instanceof ThrottleApiError) {
console.error(e.statusCode, e.code, e.message);
}
}ThrottleApiError extends the shared ThrottleError from
@usethrottle/errors,
re-exported here. If you use more than one Throttle SDK you can catch all of
their errors with a single check — instanceof ThrottleApiError keeps working:
import { ThrottleError } from '@usethrottle/cart'; // same class across every SDK
try {
await cart.items.add(cartId, item);
await checkout.completeSession(sessionId, payment);
} catch (e) {
if (e instanceof ThrottleError) {
console.error(e.statusCode, e.code, e.message);
}
}Line item imageUrl
imageUrl accepts an absolute http(s) URL or a relative path like
/images/x.png. Relative paths are resolved to an absolute URL against your
application's storefront base URL so the image renders on the hosted checkout —
set it with PUT /v1/embed-config { "storefrontBaseUrl": "https://yourstore.com" }
(the first allowedOrigin is used as a fallback). A relative path with no
resolvable base returns 400 image_url_unresolvable.
Client options
| Option | Default | Description |
| ----------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| apiKey | — | Required. Your Throttle secret key (sk_…). |
| baseUrl | https://api.usethrottle.dev | Optional Throttle API host override for self-hosted/proxy setups. Workspace environment selection comes from the API key. |
| timeoutMs | 30000 | Per-request abort timeout in ms. |
| fetch | globalThis.fetch | Custom fetch implementation (e.g. node-fetch). |
