@usethrottle/mcp 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/contract-index.json +1 -1
- package/dist/docs-index.json +1 -1
- package/dist/lib.cjs +335 -6
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.d.cts +69 -1
- package/dist/lib.d.ts +69 -1
- package/dist/lib.js +329 -5
- package/dist/lib.js.map +1 -1
- package/dist/mcp.js +4 -4
- package/dist/mcp.js.map +1 -1
- package/package.json +3 -2
package/dist/docs-index.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"generatedAt":"2026-10-27T06:25:59.649Z","entries":[{"title":"Docs","route":"/","headings":["Docs","Product docs","{c.title}","Developer docs"],"text":"Documentation Docs Look up how one thing works. Product docs explain what a feature does and where its edges are; developer docs are the API contract. New to Throttle? What Throttle stores → Integrating? Developer quickstart → Doing a job end to end? Guides by role → Product docs totalProduct pages on how each feature behaves — for operators, finance, and admins. productCards.map((c) => ( c.title c.blurb c.links.map((l) => l.label ) )) Developer docs The API, SDKs, webhooks, and extension platform. Building something new? Start with the developer overview . developerCards.map((c) => ( c.title c.blurb && c.blurb c.links.map((l) => ( l.label isExternal(l.href) && ↗ )) c.more && c.more.label )) Looking for what Throttle connects to? See integrations . For what each part of the product does, see features . .hub max-width: 1180px; margin: 0 auto; padding: 48px 24px 96px; .hub-head margin-bottom: 56px; .hub h1 font-size: clamp(34px, 4vw, 48px); line-height: 1.1; letter-spacing: -0.03em; margin: 8px 0 16px; .hub-lead font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 62ch; margin: 0 0 28px; .hub-start display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; .hub-start a display: flex; flex-direction: column; gap: 4px; padding: 16px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); text-decoration: none; .hub-start a:hover border-color: var(--teal); .hub-start strong font-size: 15px; color: var(--ink); .hub-start span font-size: 14px; color: var(--teal); font-weight: 600; .hub-band margin-bottom: 64px; .hub-band-head display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; padding-bottom: 14px; border-bottom: 1px solid var(--line); margin-bottom: 22px; .hub-band-head h2 font-size: 24px; letter-spacing: -0.02em; margin: 0; .hub-band-head p font-size: 15px; color: var(--muted); margin: 0; .hub-band-head a color: var(--teal); .hub-grid display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; .hub-card padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); .hub-card h3 font-size: 17px; letter-spacing: -0.01em; margin: 0 0 6px; color: var(--ink); .hub-card > p font-size: 14px; line-height: 1.5; color: var(--muted); margin: 0 0 14px; .hub-card ul list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid var(--line); display: grid; gap: 8px; .hub-card li a font-size: 14.5px; color: var(--ink); text-decoration: none; .hub-card li a:hover color: var(--teal); .hub-more display: inline-block; margin-top: 12px; font-size: 13.5px; font-weight: 600; color: var(--teal); text-decoration: none; .hub-foot font-size: 15px; color: var(--muted); .hub-foot a color: var(--teal); @media (max-width: 960px) .hub-grid grid-template-columns: repeat(2, minmax(0, 1fr)); @media (max-width: 640px) .hub padding: 40px 16px 80px; .hub-grid, .hub-start grid-template-columns: 1fr;"},{"title":"Build a checkout that settles cleanly.","route":"/developers","headings":["Build a checkout that settles cleanly.","Choose Your Path","{title}","Canonical URLs"],"text":"Native checkout docs Build a checkout that settles cleanly. Throttle gives your storefront a hosted payment surface, server-side checkout sessions, signed webhooks, native carts, and shipping/tax calculation. Start the quickstart View API reference 1. Merchant backend Creates a checkout session with a secret API key. 2. Buyer browser Mounts the payment iframe and receives postMessage events. 3. Merchant webhook Receives signed durable payment and order events. Choose Your Path V1 focuses on native Throttle checkout and carts. Start with a payment-only embed, then add carts, shipping, tax, and webhooks as your integration grows. Building an app for other merchants to install rather than integrating your own store? That path starts with extensions. paths.map(( href, title, body, icon ) => ( title body Open guide )) Canonical URLs Examples use the branded Throttle URLs below. Keep these values on your backend unless the variable is explicitly prefixed for browser use."},{"title":"Analytics","route":"/developers/analytics","headings":["Analytics","Which one","Pages in this section"],"text":"Analytics Analytics There are two ways to get analytics off a Throttle checkout: Throttle s first-party tracking settings for GA4 and Meta, or your own script. Pick based on whether a vendor secret is involved. Which one Track conversions — the built-in path. Secrets (GA4 s Measurement Protocol API secret, Meta s Conversions API token) stay server-side. , ], [ Something Throttle does not build in — a custom collector, cookie-based attribution stitching, a vendor with no secret to protect , html: Your own scripts — merchant-authored JavaScript Throttle runs in an isolated sandbox on hosted checkout. , ], [ A product feed for Google Merchant Center / Meta Commerce Manager , html: Product feeds — Throttle stores no catalog, so this is a recipe for building one from your own catalog source. , ], ] /> Pages in this section Track Conversions — GA4 + Meta purchase events, server-side forwarding, clientContext attribution. Your Own Scripts — the register() contract, consent, external domains, and the management API. Product Feeds — feeds live with your catalog; keep referenceId equal to the feed item id."},{"title":"Product Feeds Live With Your Catalog","route":"/developers/analytics/product-feeds","headings":["Product Feeds Live With Your Catalog","Why Throttle does not generate feeds","Reference: Next.js feed route from a CMS","The ID-consistency contract"],"text":"Analytics Product Feeds Live With Your Catalog Throttle stores no product catalog by design — feeds for Google Merchant Center, Meta Commerce Manager, and other ad platforms belong to your catalog source. Why Throttle does not generate feeds Products are not stored in Throttle. Product and line item data arrives from your cart/checkout payload and is saved as cart and order line items — Throttle is the system of record for transactions , not for your catalog. A product feed needs the full catalog (every product, in stock or not, with images, descriptions, and category data), which only your catalog source has. Reference: Next.js feed route from a CMS A compact route handler that renders a Google Merchant Center RSS 2.0 feed from a CMS query. Meta Commerce Manager accepts the same format, so one feed URL usually serves both platforms. The ID-consistency contract Dynamic remarketing only works when the ids in your purchase events match the ids in your feed. Throttle carries your catalog id through checkout untouched — but only if you send it. The referenceId your cart integration sends Throttle on each line item (surfaced as sku on throttle.completed and used as content_ids / item_id in purchase events ) must equal the feed item id ( <g:id> ). If they diverge, Google and Meta cannot join purchases back to catalog items and dynamic remarketing silently degrades. Use one canonical id — typically the SKU — in both places. Same id everywhere: feed g:id , cart line item referenceId , and any pixel ViewContent / AddToCart events your storefront fires. Variants: if your feed lists variants as separate items ( g:item_group_id grouping), send the variant id as referenceId , not the parent product id. Server-side too: Throttle's server-side conversions build content_ids from the order line items' referenceId — a missing referenceId drops that item from the join."},{"title":"Track Conversions in GA4 and Meta","route":"/developers/analytics/track-conversions","headings":["Track Conversions in GA4 and Meta","How it works","Embed recipe","Hosted setup","Server-side conversions","Attribution capture (clientContext)"],"text":"Analytics Track Conversions in GA4 and Meta Fire purchase events from the embed with zero extra fetches, let the hosted page inject your pixels for you, or forward conversions server-side — with attribution captured end to end. How it works Where the purchase event fires depends on which checkout surface you use. In the embed, your page hosts the pixels; on the hosted page, Throttle injects them. throttle.completed (and onSucceeded in the React SDK) carries total , currency , and lineItems so you fire the purchase event yourself — no extra fetch. Throttle never injects pixels inside the iframe; that would double-count. , ], [ Hosted checkout page , Throttle — gtag.js and/or the Meta pixel are injected when tracking settings are configured and consent passes. , The page fires the purchase events automatically right before the post-completion redirect, using beacon transport so the events survive the navigation. , ], [ Server-side (either surface) , Nobody — Throttle forwards the purchase from its backend. , html: When enabled, Throttle POSTs the purchase to the GA4 Measurement Protocol and Meta Conversions API at the payment moment, using the attribution snapshot on order.clientContext . , ] ] /> Embed recipe Protocol v1.4 additively extends throttle.completed with total (minor units), currency , and lineItems . Convert minor units to major ( total / 100 ) before handing values to analytics. Each line item's sku is the merchant catalog id (the cart line item's referenceId ). Use it as item_id / content_ids so purchase events join against your product feed for dynamic remarketing. The analytics fields are optional — payment-only sessions with no cart snapshot omit them, so guard with fallbacks as above. Hosted setup For the hosted checkout page, configure tracking once per application and environment via GET/PUT /api/v1/tracking-settings . Only the public ids reach the buyer's browser; secrets stay server-side and come back masked from GET . requireConsent defaults to true : without an explicit consent=granted parameter on the checkout URL, no pixels are injected and no browser events fire. An explicit consent=denied always wins, even when requireConsent is off. Server-side forwarding honors the same signal via clientContext.consent — analytics gates GA4 and marketing gates Meta. Server-side conversions Browser pixels miss buyers with ad blockers and tabs closed mid-redirect. Set serverSideEnabled plus the per-channel secrets and Throttle forwards the purchase from its backend at the payment moment: card payments at capture, Net-N invoices at authorization (issuance), and subscription renewal charges skipped — renewals are not new acquisitions. GA4 Measurement Protocol — sends a purchase event with transaction_id set to the order id, using the captured gaClientId / gaSessionId when available. Requires ga4MeasurementId + ga4ApiSecret (create the API secret under the GA4 data stream's Measurement Protocol settings). Meta Conversions API — sends a Purchase event with event_id set to the order id and user_data built from the SHA-256-hashed buyer email, server-stamped IP address and user agent, and the fbp / fbc cookies from clientContext . Requires metaPixelId + metaCapiAccessToken . or server — Measurement Protocol and gtag purchases do not reliably dedup. When server-side GA4 is on, the hosted page automatically suppresses its browser purchase ; embed integrators should likewise skip the browser GA4 purchase. transaction_id = orderId guards against page-level double-fires. , ], [ Meta , html: Both fire — the browser pixel and CAPI event are deduplicated natively by Meta via eventID = orderId . Keep the browser fbq Purchase in place; it improves match quality. , ] ] /> Forwarding failures are logged and not retried in v1. Treat server-side conversions as an analytics signal, not an accounting source of truth — use webhooks for durable side effects. Attribution capture (clientContext) POST /api/v1/checkout/sessions accepts an optional clientContext attribution blob. The hosted page also auto-captures what it can at completion (UTMs, click ids, GA and Meta cookies, landing page, referrer) and Throttle merges the two per key — completion-time values win — then server-stamps ipAddress and userAgent . The merged snapshot lands on order.clientContext (merchant order GET ) and on the order.created webhook payload. Accepted keys: landingPage , referrer , utmSource , utmMedium , utmCampaign , utmTerm , utmContent , gclid , fbclid , msclkid , ttclid , gaClientId , gaSessionId , fbp , fbc , and consent ( analytics?, marketing? ). Unknown keys are rejected; ipAddress / userAgent are server-stamped and rejected as client input. The buyer's first-party attribution cookies live on your domain, not the checkout domain. Capture UTMs, click ids, and the _ga / _fbp / _fbc cookies on your storefront, persist them with the cart, and send them as clientContext when your server mints the session. The hosted page's completion-time capture is a best-effort fallback."},{"title":"API Surface by Integration Task","route":"/developers/api-reference","headings":["API Surface by Integration Task","{group.title}"],"text":"Curated reference API Surface by Integration Task Start here for the endpoints most native checkout integrations need. Use the full OpenAPI reference when you need every schema and response field. Open full Swagger docs Download OpenAPI JSON endpointGroups.map((group) => ( group.title group.description group.endpoints.map((endpoint) => ( )) ))"},{"title":"Build Cart State That Checkout Can Trust","route":"/developers/cart-api","headings":["Build Cart State That Checkout Can Trust","0. Configure your application","Who owns each step","End-to-end cart flow","Shipping and tax","The cart is the source of truth for shipping and totals","Addresses","Discounts","Subscription lines","Resolved carts","Two add-item shapes","Owned lines","Open the hosted cart page for a cart","Native cart endpoints","{title}","unitPrice is per period, per seat; quantity is seats.","Stored as: type \"subscription\", name \"Barista Club — 14-day free trial\",","unitPrice 0, recurring.amount 2000, subscriptionId null (until checkout completes).`;","Add a line the app prices","The body is optional. 201 (new) or 200 (reused):","\"data\": {","\"sessionId\": \"cart_9f2c…\",","\"cartUrl\": \"https://checkout.usethrottle.dev/cart/cart_9f2c…\",","\"expiresAt\": \"2026-10-27T12:00:00.000Z\",","\"urls\": {","\"returnUrl\": \"https://shop.example.com/thanks\",","\"cancelUrl\": \"https://shop.example.com/cart\",","\"storefrontUrl\": \"https://shop.example.com\"","}","}`;"],"text":"Native carts Build Cart State That Checkout Can Trust Use @usethrottle/cart when Throttle owns the cart, totals, and checkout handoff for your application. 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 ) to allowedOrigins . 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 like sk_live_* . Every field on the wire is camelCase ( applicationId , unitPrice , referenceId ). Single shape — no snake_case aliases. See Conventions for the full rule. You don't have to plug in a payment processor to test cart → checkout end-to-end. Every new Application gets a sandbox payment sub-account on signup, so a checkout session minted with an non-production environment key renders the Card Simulator out-of-the-box. See Workspace environments for provider routing details. 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. 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 . The full request/response schemas for every endpoint on this page live in the public OpenAPI 3.1 document at api.usethrottle.dev/openapi.json . Open it in Swagger UI to try requests against a sandbox key, or pipe it into openapi-typescript / openapi-fetch for typed client generation. The pre-generated client lives at @usethrottle/cart . Most applications use native Throttle carts : create a cart with 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. Quote tokens start with qt_ (tokens minted before this change start with pk_ and remain valid indefinitely) and are scoped by application and allowed origin. They can estimate totals, but they cannot mutate carts or create orders. POST /api/v1/shipping-tax/quotes also accepts an X-API-Key carrying shipping_quotes:write instead of a quote token — see the API reference . 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 same public 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 ke"},{"title":"Cart sessions","route":"/developers/cart-sessions","headings":["Cart sessions","How it works","Set your allowed origins first","Create a cart session","Build the cart","Cart badge: just the number","Buyer details","Shipping & discounts","Hand off to checkout","Skip the UI: Throttle's hosted cart page","One hook for the whole flow: useThrottleCheckout","Resolved carts: lines priced by an installed app","Mobile apps: no Origin needed","Adding and changing lines","Refresh","Shipping on resolved carts","Checkout hand-off","Signing in","In @usethrottle/cart","Browser carts may only hold resolved lines","Extra cart currencies","Endpoints","Add a line by the app's id for the item. The app sets name, price and image.","Re-check every line the app owns (send {} as the body)","→ { \"data\": { \"cartSessionId\": \"cart_<id>\", \"cart\": { … }, \"changed\": false } }`;","Update an item","Remove an item","Read the current cart","\"data\": {","\"cartSessionId\": \"cart_<id>\",","\"status\": \"active\",","\"unitCount\": 4,","\"total\": 12497,","\"currency\": \"USD\"","}","}`}","Apply a discount code"],"text":"Guide 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. Prefer a typed client? 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 or POST /api/v1/shipping-tax/quote-tokens ) from an allowed origin. 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 . 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. in localStorage ) 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. Creation is authorized by your publishable token + the application origin allowlist. Every later call is authorized by possession of the opaque 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 . A resolved cart also accepts a request that sends neither Origin nor Referer , as a mobile app does. Create a cart session 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 /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_ /items/ \\\\ -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_ /items/ \\\\ -H Origin: https://shop.example.com # Read the current cart curl https://api.usethrottle.dev/api/v1/cart-sessions/cart_ \\\\ -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: /summary \\\\ -H Origin: https://shop.example.com # # data : # cartSessionId : cart_ , # 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. 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. A cart the abandoned-cart sweep has taken reports its real 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. \\\\ -H Origin: https://shop.example.com -H Content-Type: application/json \\\\ -d customerEmail : buyer@example.com , notes : Leave at the door /> A guest cart is invisible to abandoned-cart recovery until it has a known email. Setting customerEmail here — even without a full customer record — is what makes the cart eligible for th"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","2026-10-26 — Buyer-portal proxy limits what a buyer may send","2026-10-26 — Physical subscriptions","2026-10-24 — Line items on the order list","2026-10-23 — Customer directory for extensions","2026-10-23 — Extra cart currencies for resolved carts","2026-10-20 — Line resolvers: extensions set the price of a cart line","2026-10-19 — Manual extension setup is deprecated","2026-10-19 — Read one installation by id","2026-10-19 — Uninstall deliveries name the install they ended","2026-10-10 — Signup invoices carry their transaction id","2026-10-10 — A free first period with no order writes no invoice","2026-10-09 — $0 renewals are quiet","2026-10-09 — Subscriptions at amount 0 need an opt-in (breaking)","2026-10-09 — Quote subscription lines need a price","2026-10-07 — Native tax correctness","2026-10-07 — Lost chargebacks have their own state","2026-10-07 — Change one subscription's card","2026-10-05 — Payment correctness","2026-10-04 — Invoice and order follow-ups","2026-10-04 — Net-N invoices follow their payment","2026-10-04 — Editing an order bills its outstanding Net-N invoice","2026-10-03 — Custom installment schedules","2026-10-03 — Editing a provider-taxed order re-quotes its tax","2026-09-30 — Declined embed cards answer 402, checkout-react under vite dev","2026-09-30 — Switched-off email templates stay off","2026-09-29 — Several subscriptions in one checkout","2026-09-29 — Stalled-renewal reason, send-test filtering, chained-change credit, hosted-cart subscriptions","2026-09-28 — Metered usage lines, stuck-renewal visibility, paid-based credits","2026-09-26 — Custom billing cadence and installment plans","2026-09-25 — Automation connectors get their own OAuth audience","2026-09-25 — OAuth tokens work only through the MCP server","2026-09-23 — OAuth connections never see shopper personal data","2026-09-23 — OAuth tokens bound to the MCP resource; hosted server strictly read-only","2026-09-21 — Order confirmation emails restored; receipts list what was bought","2026-09-21 — Forgot password now works for guests and imported customers","2026-09-21 — Migration guide: api-client 2.x to 4.x","2026-09-21 — Known customers can pay on the /c embed; route prop on the React embeds","2026-09-21 — processorPaymentMethodId on payment and session responses","2026-09-21 — Wildcard origins for preview deployments (non-production)"],"text":"Release notes Developer Changelog Public, developer-facing contract changes for the Throttle API, embedded checkout, webhooks, and SDKs. Internal refactors and UI-only changes are excluded. 2026-10-26 — Buyer-portal proxy limits what a buyer may send @usethrottle/subscriptions 4.0.0 (breaking). The proxy helper forwards on your secret key, so it now refuses buyer fields that set money or terms: a refund on cancel, plan or amount on PATCH , and direct creates, with 403 field_not_allowed / create_not_allowed . New authorizeUpdate and authorizeCreate options let you approve them after checking your price list. Reading another buyer's subscription answers 404. Buyer portal 2026-10-26 — Physical subscriptions Buyers can change where their box ships. New PATCH /v1/storefront/me/subscriptions/ id /shipping-address (session plus step-up), updateSubscriptionShippingAddress() in @usethrottle/auth , and a Change address button in its <SubscriptionsPanel /> . Storefront subscriptions now carry deliveryMethod and shippingAddress . A subscription can ship a box every cycle. A cart line with recurring and deliveryMethod: shipment was refused with 400 validation_error ; it is now accepted and creates a physical subscription. Its signup order and every renewal order carry a shipment line and wait in the fulfillment queue, shipped to the subscription's new shippingAddress . No shipping is charged. Access subscriptions are unchanged. Physical subscriptions New subscription fields deliveryMethod ( access_grant or shipment ) and shippingAddress , on responses and subscription.* webhooks. POST /api/v1/subscriptions accepts both; PATCH /api/v1/subscriptions/ id accepts shippingAddress ( 422 subscription_not_physical on a subscription that does not ship). The address step is on for a physical subscription. An explicit collect.shippingAddress: false on such a cart answers 400 shipping_address_required . 2026-10-24 — Line items on the order list New optional include=lineItems on GET /api/v1/orders . Each row then carries lineItems , exactly as GET /api/v1/orders/ id answers them (an order with no lines answers [] ), in the same query count. Without it the answer is unchanged. Any other value answers 400 validation_error . The CSV export ignores it. 2026-10-23 — Customer directory for extensions New scope customer_directory:read and GET /api/v1/customer-directory . Find customers by a name or email prefix ( q ), or name up to 50 customer ids ( ids ), answering only id, name and email of the key's own application and environment. The scope is sensitive and extension-allowed; customers:read does not grant it. Finding customers 2026-10-23 — Extra cart currencies for resolved carts New per-environment setting cartCurrencies . Up to 10 extra currencies a cart priced by an installed app's line resolver may use besides the environment's currency. Set it with PUT /api/v1/embed-config or in the dashboard under Cart pricing; GET /api/v1/embed-config returns it. Only the merchant can change it: other callers get 403 permission_denied , and an invalid list is 400 validation_error with details[].path . Only currencies shown and charged with two decimal places are accepted for now (not JPY, KRW, KWD, BHD, HUF or IDR). Carts without a resolver are unchanged. Extra cart currencies Resolved carts may use an extra currency. A resolved cart, from POST /api/v1/cart-sessions or POST /api/v1/carts with lineResolver , may be created in the environment's currency or one of its extra currencies; the resolver is asked for prices in it, and checkout and payment run in it. Any other currency answers 422 currency_not_enabled , whose details.enabledCurrencies now lists the extra currencies too. New 422 discount_currency_unsupported and 422 shipping_currency_unsupported . On a resolved cart in an extra currency, a fixed_amount discount and a non-zero shipping rate from a configuration in another currency are refused. Percentage discounts and zero-rate shipping work. Errors 2026-10-20 — Line resolvers: extensions set the price of a cart line An extension can declare lineResolverUrl . When it is installed, Throttle asks it for the price, availability and details of each line it owns, on every cart add, quantity change, refresh and checkout hand-off, with a signed request. If the resolver cannot answer, the call fails closed with 503 line_resolver_unavailable . Line resolvers New POST /api/v1/carts/ id /sessions , POST /api/v1/cart-sessions/ id /refresh and POST /api/v1/customer-tokens/introspect . Mint a storefront session for a resolved cart, re-resolve its lines, and introspect a buyer token (scope customer_tokens:read ). Browser prices are refused on resolved carts. A unitPrice on a resolved line is 400 price_locked . A checkout whose prices moved answers 409 cart_prices_changed with the new price. Carts without a resolver are unchanged. New per-environment setting resolvedLinesOnly . When on, an unresolved add is 422 resolved_lines_required and a priced add is 400 price_locked . @usethrottle/cart@3.13.0 and @usethrottle/api-client@5.12.0 . Typed resolver errors (including CartPricesChangedError ), addResolvedItem , selectShippingMethod and the new routes. Additive. 2026-10-19 — Manual extension setup is deprecated Pasting install-time credentials is deprecated. An extension that relies on a merchant copying the webhook signing secret (and an API key) out of the install wizard still works, but this manual setup will be removed in a future release. No date is set. Switch to automatic setup: issue client credentials, set an install callback URL, and fetch the secret and key yourself. The install wizard now marks extensions without client credentials as using deprecated manual setup. Automatic setup for extensions 2026-10-19 — Read one installation by id New GET /api/v1/installations/:id . Returns one installation of the current application and environment, in the same shape as the list, or 404 not_found . Same"},{"title":"API Conventions","route":"/developers/conventions","headings":["API Conventions","Field naming: camelCase, single shape","Errors","Typed errors in the SDK","Request IDs","Pagination","Identifier prefixes","Timestamps and timezones","Quote and order numbers"],"text":"Reference API Conventions One section captures the rules that apply across every Throttle endpoint: naming, error shapes, pagination, idempotency, and workspace environments. When in doubt, this page wins over the per-endpoint docs. Field naming: camelCase, single shape Every request and response field on the wire is camelCase . One name per concept — no aliases, no alternate forms. Type generation from the OpenAPI spec is the intended workflow. Throttle public request and response bodies use camelCase. Use applicationId , customerId , lineItems , unitPrice , and externalId ; stale snake_case or store aliases are rejected with a validation error. Generated api-client@1.3.0+ already uses the current shape. Historical store terminology may still appear in dashboard labels or internal database migrations, but it is not exposed on the public wire. Errors Every non-2xx response follows the same envelope: error.code is the contract — use it for programmatic handling. Reach for error.message only for surfacing a human-readable string in your UI. Field-level details identify unrecognized keys and may include a suggestion when the server can identify the intended canonical camelCase field: Typed errors in the SDK The envelope is published as a named ErrorEnvelope model, so @usethrottle/api-client exposes it as a type rather than leaving you to re-parse the body: ApiError extends ThrottleError , so one catch covers every Throttle SDK. err.message is the message the server sent; if a response carries no message, it falls back to a description of what that status means on this API, and the raw description stays available as err.statusDescription . Request IDs API responses carry an x-request-id response header, and it s readable cross-origin (it s in Access-Control-Expose-Headers ) — hijacked and streamed replies are the exception. Where the response body is a JSON envelope, meta.requestId is the same value, where present: If your own request already carries an x-request-id header matching ^[A-Za-z0-9_-] 8,128 $ , Throttle honours it and echoes it back unchanged — useful for correlating a request across your own systems. Anything that doesn t match (too short, too long, or containing characters outside A-Za-z0-9_- ) is ignored, and a fresh req_<uuid> is generated instead. ThrottleError s requestId field — available in @usethrottle/errors 1.1.0 and later — exposes the same value across every Throttle SDK: quote it when contacting support, since it s the fastest way for us to find the exact request in our logs. It s read from meta.requestId when the response body carries one; for a hand-built error body without a meta envelope (a 401, 403, or 500), it falls back to the x-request-id response header instead, so it s populated either way. On earlier versions, read x-request-id or meta.requestId directly from the response. Pagination List endpoints accept ?limit + ?cursor and return: , // pass back as ?cursor= to fetch next page hasMore : true, limit : 25 language= json label= Paginated response /> When hasMore is false , cursor is null . You can use if (cursor) as a more pages? check without re-reading hasMore . Identifier prefixes Resource ids are UUID v7 strings. API keys, embed tokens, and session ids carry a short prefix that encodes their type and, for keys, the workspace environment slug: sk_<environment-slug>_* — secret key for one workspace environment. pk_<environment-slug>_* — publishable key for one workspace environment. sk_live_* and pk_live_* — production environment keys. whsec_* — webhook signing secret. Timestamps and timezones Every timestamp in an API request or response is ISO 8601 in UTC , always, and is unaffected by any setting. Parse it as an instant and render it wherever you like. Separately, each application carries a timezone — an IANA zone name such as America/Los_Angeles . That setting is what Throttle s own rendered surfaces use: the dashboard, quote PDFs, and transactional emails. null means UTC. Setting a timezone never changes an API payload. If you need local time in your own UI, read timezone from the application and format the UTC instant yourself. Like currency and branding, the timezone is stored per workspace environment , so a store can read its test data in one zone and its production data in another. Quote and order numbers quoteNumber and orderNumber are human-facing labels that count up per application, per workspace environment . Two applications each issue their own Q-1 ; neither ever sees a gap caused by the other. Both the prefix and the next number are configurable, from the dashboard or over the API: quoteNumberPrefix / orderNumberPrefix — 1–12 characters of letters, digits, - or _ . Defaults are Q- and ORD- . nextQuoteSeq / nextOrderSeq — the next number to issue. It must be greater than every number this environment has already issued under that prefix; otherwise the request fails with 409 starting_number_already_used . A document number is a display label, not a cursor. Sequences are per store, restart under a new prefix, and can be moved forward deliberately. Sort and paginate on createdAt plus the id instead, and never infer volume from a number. Test environments keep their own counters, so QA traffic never consumes a number a live document would otherwise get."},{"title":"Discounts for Cart-Backed Checkout","route":"/developers/discounts","headings":["Discounts for Cart-Backed Checkout","Supported types","Percentage off","Fixed amount off","Use the SDK","Preview without mutation","Customer limits","List and search codes","Cart and order records","Payment flow","Metadata","Apply to checkout","Pre-applying at session create","Current boundaries"],"text":"Promotion codes Discounts for Cart-Backed Checkout Throttle supports percentage and fixed amount promotion codes. The payment processor does not validate or calculate codes; Throttle computes the discounted cart total before checkout hands the final amount to payment. Supported types Percentage off type: "percentage" , with value: 25 for 25% off. Fixed amount off type: "fixed_amount" , with value: 500 for $5.00 off in minor units. Free shipping and buy-X-get-Y are no longer active product surfaces. Existing historical rows may remain inactive, but new code should only create percentage or fixed amount discounts. Use the SDK @usethrottle/discounts is a server-side, headless SDK. It does not ship a coupon input UI; your checkout owns that field. Preview without mutation Use preview when the buyer types a code in your order summary. It returns the discount amount and total without creating or changing a cart. Include a customer ID when you want the preview to check the customer redemption limit. Customer limits New discounts default to one redemption per customer. Set maxRedemptionsPerCustomer to another positive integer for repeat use, or null for unlimited customer redemptions. Usage is recorded when a discounted cart becomes an order. A cart can hold one applied code. Applying a valid new code replaces the previous one; invalid replacement attempts leave the existing code unchanged. List and search codes GET /api/v1/discounts uses cursor pagination and accepts q , type , isActive , code , and sort filters. Use meta.pagination.cursor for the next page until hasMore is false. Cart and order records Applied coupons are visible on cart reads and are preserved on the order snapshot. A cart response includes appliedDiscount with the code, discount ID, type, amount, currency, validity, and snapshot hash. When the cart becomes an order, the order stores discountId , discountCode , and metadata.appliedDiscount . Calculated tax is charged on the price after the code: a cart-wide code lowers every line s taxable amount in proportion, and a code limited to certain products lowers only those lines. Native tax, the Avalara quote and the filed Avalara document agree, and the stateless quote nets its discountsTotal the same way. A tax rule set to tax before discounts still taxes the full price. An order with a discount carries metadata.taxDiscountBasis: net_of_cart_code ; editing its line items re-taxes it the same way. Orders placed before this behaviour shipped have no such key and keep the tax basis they were placed on when edited. The key is managed by Throttle, so do not set or remove it yourself. The lines a code applied to are recorded on the order when it is placed, as metadata.discountScope (the code, its type and value, its product scope, and the order line ids it reduced). Editing the order's line items and the filed Avalara document read that record, not the discount as it is now: narrowing, widening, re-pricing or deleting a code after an order was placed does not change that order's tax. A line added by an edit shares the code when the recorded product scope covers it. Orders placed before this record existed read the live discount, as before. Throttle manages this key too: a value you send is ignored. Global and per-customer usage counts are recorded when the discounted cart is converted to an order. Preview does not mutate usage. Apply validates customer limits when the cart has a customer ID. Payment flow Throttle applies discounts before payment. The payment processor receives the final amount to authorize or capture; it does not receive a coupon code to validate. Metadata Discounts accept arbitrary JSON metadata for campaign IDs, channels, attribution, or merchant notes. Metadata is stored with the discount and returned by the REST API, GraphQL API, and SDK. Apply to checkout Cart application is the authoritative step. Apply the code to a native Throttle cart before you create or render the checkout session. Pre-applying at session create POST /api/v1/checkout/sessions accepts a top-level discountCode: string field. When provided, Throttle validates the code synchronously against the session's cart and applies it before returning. The response cart total reflects the discount. An invalid or expired code returns 422 discount_invalid . The session is not created. Your storefront should call /api/v1/discounts/preview first if you want to surface the error inline, then retry the session create with the validated code (or omit it). The preview endpoint computes the discounted total without mutating any cart or session. Current boundaries Promotion codes work on cart-backed checkout totals. Discounts are order-level cart reductions, not item-level discounts. Only one coupon can be attached to a cart; this is enforced by the API and database. External/provider-owned carts must apply discounts upstream in the provider. Recurring subscription discounts are not part of this v1 surface. Keep secret API keys on your backend; browsers should call your server. Continue with Cart API for native carts and Embedded Checkout for the iframe handoff."},{"title":"Embed Throttle Without Owning Payment UI","route":"/developers/embedded-checkout","headings":["Embed Throttle Without Owning Payment UI","PaymentEmbed","CheckoutEmbed","Raw iframe","Origin allowlist","Preview origins","Choosing the right surface","Session lifecycle","Sequence: postMessage handshake","Query parameter reference","postMessage event reference","Complete-session payloads","Completion errors","Payment-method filtering"],"text":"Checkout surfaces Embed Throttle Without Owning Payment UI Use an iframe-backed surface for payment capture while your backend controls session creation and your webhook endpoint controls durable state. PaymentEmbed Use PaymentEmbed when your storefront already owns cart, address, shipping, and order summary UI. Throttle renders the payment form and posts lifecycle events to the parent page. If you show a promotion-code field in that order summary, preview it on your server and apply it to the Throttle cart before creating the session. Both components build their own iframe URL and load the /c page by default. Pass route="s" to load the unified /s page instead — for example to match the route you give buildCheckoutEmbedUrl on your server. The component does not see a URL you built yourself. Card-vault (add-a-card) sessions are only served by /c . CheckoutEmbed Use CheckoutEmbed when you want a fuller hosted checkout flow inside your page. It supports the same terminal events plus checkout step changes. Raw iframe If you are not using React, render the hosted checkout iframe directly and validate the postMessage envelope before trusting events. Origin allowlist Embedded checkout refuses to render unless the parent origin is allowlisted. Configure production and staging origins before mounting the iframe. Preview origins Preview deployments get a new hostname per branch, so they cannot be listed one by one. In a non-production environment, an entry may use one wildcard as its leftmost label: https://*.preview.example.com matches https://pr-42.preview.example.com and any deeper subdomain. It does not match https://preview.example.com itself; list that separately if you need it. The wildcard must sit under a domain you own. One over a shared or public suffix ( https://*.vercel.app , https://*.pages.dev , https://*.co.uk ) is refused with 400 invalid_origin , because anyone can obtain a hostname there and could then host your checkout. Point a subdomain of your own at the preview host instead. Production takes exact origins only. A wildcard sent to the production environment is refused, and creating an application never accepts one, since that seeds every environment at once. For a domain cutover, list the old and the new origin side by side and remove the old one afterwards. A wildcard entry applies to embedded checkout, cart sessions, storefront sign-in and the script manifest. One exception: the sandbox that runs extension scripts only ever lets exact origins frame it, so sandboxed scripts do not run on a preview origin matched by a wildcard. A wildcard is also never used to build a link. If your allowlist holds only wildcards, set storefrontBaseUrl so buyer emails and relative image paths have a real host to point at. Throttle sends iframe events with a specific target origin. Your parent page should also verify event.origin , source: "throttle" , and version: 1 . Choosing the right surface The hosted checkout app exposes two route shapes. Both honour the same origin allowlist and emit the same postMessage events; they differ in who owns the buyer-data form (Throttle vs. the payment provider) and which payment methods can co-exist. /c/[sessionId] Provider proxy mode , The provider embed collects email, address, and card inside its own iframe. Throttle wraps it with merchant branding and a Pay button. , Card (provider) , code: PaymentEmbed , ], [ html: /s/[sessionId] Unified flow , Throttle renders the cart, address form, and a payment-method picker. Card and Net 30 can appear side-by-side as tiles in the same iframe. , Card (provider) and/or Net 30 , code: CheckoutEmbed , ] ] /> Session lifecycle Every embedded checkout follows the same four-step rhythm. Sessions are server-side state; embed tokens are short-lived JWTs that the iframe re-mints on demand. The session id ( sess_xxx ) is the durable handle stored in checkout_sessions . The embed JWT ( embedToken ) is a provider-signed credential the iframe needs to mount the card capture widget. The iframe re-mints it every render, so the JWT's ~30 minute TTL is rarely user-facing. The underlying cart stays open for the whole life of a session. The cart only becomes converted (terminal) when the session completes and the order is created — not when the session is created. So a buyer can return to an in-flight session and keep mutating the cart. If a later cart operation returns 409 cart_not_open , the cart was converted by a previously completed order; start a new cart for the new purchase. checkout.getSession(sessionId) ( GET /api/v1/checkout/sessions/ id ) returns the session with its current status : open or processing while the buyer is in it, then completed , cancelled or expired . Use it to decide whether a session can be resumed or needs cancelling, instead of tracking that on your side. To tear down an in-flight session, call checkout.cancelSession(sessionId) ( DELETE /api/v1/checkout/sessions/ id ). It is idempotent, marks the session cancelled , and re-opens an associated cart still sitting in checkout . A completed session cannot be cancelled ( 422 already_completed ). GET /api/v1/checkout-sessions/ id — unauthenticated, what the iframe itself reads to render — returns recurringTerms when the session carries a recurring intent, otherwise null . Card-network rules require disclosing what a trial converts into and what an installment plan totals before the buyer converts, so the iframe builds its disclosure copy from this object rather than hardcoding it: interval , intervalUnit , intervalCount , totalPayments (installment plans only), firstChargeAmountMinor , currency , trialDays + trialEndsAt , planName , taxApplies — true when renewals of this app are taxed, in which case the disclosure appends "plus applicable tax" to its amounts and total — and planChargedToday (installment plans only): whether the plan itself is on today s charge. It is false on a hybrid/quote/deposit-split cart, where the en"},{"title":"Collection Flags","route":"/developers/embedded-checkout/collection-flags","headings":["Collection Flags","The collect block","Worked combinations","Shipping only (default — physical goods, no separate billing)","Billing only (digital goods + invoicing)","Both (shipping + separate billing)","Neither (replaces ?mode=payment-only)","Flow: which steps render","Complete-session billing fields","Validation errors","Iframe behaviour","Migration: ?mode=payment-only","Next"],"text":"Embedded Checkout Collection Flags Tell Throttle which buyer addresses to collect during the embedded flow. Each session opts into shipping and billing capture independently — useful for digital-only carts, B2B Net 30 invoicing, and storefronts that already collected the address upstream. The collect block POST /api/v1/checkout/sessions accepts an optional collect object on the request body: true , except with subscriptions (below) , html: When true , the iframe renders the shipping address step and /complete requires shippingAddress . , ], [ code: collect.billingAddress , code: boolean , html: false , with two exceptions (below) , html: When true , the iframe emits a step: 'billing' postMessage and /complete requires billingAddress (or billingSameAsShipping: true ). , ] ] /> Omitting collect on a one-off checkout is equivalent to shippingAddress: true, billingAddress: false . Existing integrations that don't opt in keep the old shape. On a checkout with subscriptions, an omitted collect turns the shipping step on only when the cart holds a shippable one-time line or a physical subscription line ( deliveryMethod: shipment ); other subscription lines never ship. A collect object that leaves shippingAddress out still sets it to true . See Mixed Carts . An omitted collect.billingAddress defaults to true in two cases, because otherwise the checkout has no address to tax: the application taxes from the billing address (tax trigger billing_address ); the session shows no shipping step ( collect.shippingAddress resolves to false , for example a subscriptions-only cart), and the application uses a tax provider (tax mode app_based ) with an address trigger and the strict fallback policy, which refuses to complete a checkout it could not tax. In that second case, with the shipping_address trigger, tax is calculated from the billing address, and the order records metadata.taxTrigger: 'billing_address' so the filed tax document uses the same address. It also records metadata.taxTriggerSource: 'checkout' to mark that value as Throttle's: if the buyer retries a declined payment, the order's trigger is worked out again for the new attempt, and a stamp with that mark is replaced or removed. A metadata.taxTrigger you set on the cart or the session (the session's wins, as it does on the order) is never overwritten, and it also replaces a value already on an order that a retry reuses. The quote and the filed document both follow it, so shipping_address on a session with no shipping step is refused with tax_address_missing under the strict policy. An explicit billingAddress: false always wins. Worked combinations Pick the combo that matches your storefront. Every example below is the request body for POST /api/v1/checkout/sessions . Shipping only (default — physical goods, no separate billing) Billing only (digital goods + invoicing) Common for B2B Net 30 flows where shipping is irrelevant but the AR system needs a billing address. Both (shipping + separate billing) The buyer fills shipping, then is asked whether billing matches. If they uncheck "billing same as shipping", a billing form appears. Neither (replaces ?mode=payment-only) Use when your storefront has already collected the buyer's addresses. Send them as customer.shippingAddress and customer.billingAddress : they are stored on the session, returned when you read it, and carried onto the order when the session completes. An address submitted on /complete always wins over the one sent here. Flow: which steps render The two boolean flags fan out into four distinct iframe step machines. Use this to confirm the buyer journey before you ship — especially when a connector requires a billing address regardless of shipping. Complete-session billing fields When collect.billingAddress is true, the iframe POSTs the billing details on /complete . Two new request-body fields: billingAddress — same shape as shippingAddress : firstName , lastName , company? , addressLine1 , addressLine2? , city , stateProvince , postalCode , countryCode (ISO-3166-1 alpha-2), phone? . billingSameAsShipping — boolean, defaults to false . When true , the server copies the shipping address into the order's billing slot and you can omit billingAddress . Validation errors When a required address is missing, the server returns 422 address_required with a field extra so the iframe can highlight the right step. Iframe behaviour The Pay button auto-disables until every collect-flagged field is complete. Parent-set submitDisabled still composes additively — see Parent Controls . With collect.billingAddress: true , the iframe emits an additional throttle.step.changed event with step: 'billing' . With collect.shippingAddress: false and collect.billingAddress: false , the buyer lands directly on the payment step (this combo replaces the legacy ?mode=payment-only URL flag). Migration: ?mode=payment-only The ?mode=payment-only URL query parameter is still honoured client-side for one release window to avoid breaking existing storefronts. New integrators should set collect: shippingAddress: false, billingAddress: false on session create instead — that path is server-authoritative, carries through to /complete validation, and is visible in webhook payloads. Next Embedded Checkout — full embed lifecycle and event reference. Parent Controls — parent-side submitDisabled gate."},{"title":"Parent Controls","route":"/developers/embedded-checkout/parent-controls","headings":["Parent Controls","The submitDisabled prop","Defaults","Composes with collect-flag auto-disable","Without React","Message contract","Security","What does it actually do inside the iframe?","Common uses","Next"],"text":"Embedded Checkout Parent Controls Gate buyer interaction with the embedded checkout from the parent page. Useful when your storefront has fields that must be filled (shipping address, terms acceptance, plan selector) before the buyer should be able to submit the embedded payment. Is: a way for the parent to render an overlay over the entire embed area that swallows clicks until the parent says otherwise. The overlay is light-grey + cursor not-allowed so the buyer sees the form behind but can't interact with it. Isn't: a way to validate the buyer's data from the parent. Field-level validity is up to your form. The parent signals ready or not ready — Throttle just enforces the gate. The submitDisabled prop Both <PaymentEmbed /> and <CheckoutEmbed /> in @usethrottle/checkout-react accept a controlled submitDisabled?: boolean prop. When it flips to true , the SDK posts a parent → iframe command and the iframe renders a full-area overlay. When it flips back to false , the overlay disappears. Defaults Omitting submitDisabled entirely keeps the embed enabled (backward compatible). The SDK never posts a parent command in this case. submitDisabled= false and omitting the prop are equivalent at runtime, but passing false explicitly opts your embed into the parent-control protocol — useful when your form starts valid (e.g., everything pre-filled). The first parent command is sent after the iframe posts throttle.ready . You don't need to wait for this in your component code — the SDK buffers and re-sends. Composes with collect-flag auto-disable The iframe also auto-disables the Pay button until every field required by the session's collect flags (shipping address, billing address) is complete. Parent-set submitDisabled composes additively with that validation gate: the button stays disabled while either the parent says "not ready" or the iframe's own validation hasn't passed. Practically: you don't need to mirror the iframe's address validation in your parent form. Only gate on parent-controlled state (terms checkbox, plan selection, out-of-band eligibility checks). Throttle handles the rest. Without React The SDK is a thin wrapper over postMessage. Vanilla JS, Vue, Svelte, Angular — anything that mounts an iframe can post the same shape. Message contract The parent → iframe channel uses source: 'throttle-parent' to disambiguate from the iframe → parent events (which use source: 'throttle' ). The iframe ignores anything that doesn't match. Future commands will extend the same envelope; the protocol is additive. Security The iframe verifies event.origin matches the merchant-allowed parent origin before reading the message. A different page can't steal control. Setting disabled: false from an attacker doesn't bypass anything — it simply removes the overlay. The buyer still has to actually submit the form, and Throttle still has its own server-side validation on the /complete route. The overlay is cosmetic , not a security boundary. It blocks the buyer's clicks but doesn't prevent scripts. Treat submitDisabled as a UX gate, not a server-side enforcement primitive. What does it actually do inside the iframe? The iframe's EmbedShell listens for the parent command and renders a positioned overlay (semi-transparent white + cursor not-allowed) covering the embed content when disabled. The overlay sits at z-index 50 so it covers both Throttle's native UI and any nested provider iframe (the card form). The buyer sees what they've typed but can't click anything. We chose "overlay over the iframe" instead of "disable the submit button only" because the provider embed renders its own submit button inside a nested iframe we don't control. Greying the entire interaction surface gives the same UX outcome with stable semantics. Common uses Address before pay. Your parent page renders a shipping address form. Pass submitDisabled= !addressValid . Terms acceptance. A "I agree to terms" checkbox above the embed. Pass submitDisabled= !checked . Plan selection. Buyer must pick a plan before paying. Pass submitDisabled= !selectedPlan . Async eligibility. You're waiting on a backend check (e.g., calling subscriptions/eligibility-check ). Disable until the result returns. Next Embedded Checkout — full embed lifecycle and event reference."},{"title":"Workspace Environments","route":"/developers/environments","headings":["Workspace Environments","Environment-scoped keys","Dashboard environment selector","Manage environments","API keys are environment-scoped","Payment provider environments","Webhook environment routing","Cheat sheet","Production is immutable; non-production environments can be named for your flow.","Production:","Include archived environments for settings/audit screens","Create a non-production sandbox-provider environment","Archive a custom non-production environment"],"text":"Reliability Workspace Environments Throttle isolates production from every sandbox-provider environment inside the same workspace. Each workspace has one immutable production environment and can have multiple non-production environments such as test, UAT, and staging. Environment-scoped keys API keys are minted for a workspace environment. The raw key includes the environment slug, and every request is scoped to the key's environment end-to-end: sk_<environment-slug>_* — secret key for server-side requests. pk_<environment-slug>_* — publishable key for browser/frontend code. Publishable keys may hold only stateless compute scopes such as shipping_quotes:write and tax_calculations:write . Carts, discounts/promotions, orders, payments, subscriptions, shipping/tax settings, invoices, Net-N customer terms, dashboard views, webhooks, API keys, and connector state all carry an environmentId . A UAT cart never becomes a production order, and a production webhook endpoint never receives UAT events. Dashboard environment selector The dashboard topbar selector chooses the active workspace environment for reads and setup writes. Production workspaces can switch between production and any active non-production environment. Workspaces that have not completed go-live can work in non-production environments until production is available. Create and archive custom environments from Workspace Settings → Environments . Manage environments Every workspace starts with one immutable production environment. Create additional environments for UAT, staging, or QA; each custom environment is non-production and routes to sandbox-provider credentials. Production cannot be created, archived, or deleted. Archiving a custom environment removes it from normal selectors and prevents new work in that environment; historical rows keep their environment id for audit and reporting. API keys are environment-scoped GET , POST , and DELETE /api/v1/api-keys act only on the request's selected environment. Create separate keys for production, UAT, staging, or any other non-production environment you run. An API key whose environment contradicts an explicit X-Throttle-Environment-Id header is rejected outright with 403 environment_mismatch . Fetching a specific resource (an order, a subscription, and so on) by id from another environment answers 404 not_found instead — the lookup is already scoped to your environment, so a resource that belongs to a different one simply doesn't match, the same as an id that doesn't exist at all. Either way, use a key for the correct environment, or select the matching X-Throttle-Environment-Id for dashboard-session setup calls. Payment provider environments Throttle uses the workspace environment to choose the provider environment. Production routes to production processors; non-production environments route to sandbox-provider wiring such as the Card Simulator or sandbox payment processor. Production payment paths require production entitlement. Non-production environments are available for integration work before go-live. Webhook environment routing Outbound webhook endpoints are scoped to a workspace environment. Events carry environmentId in the delivered envelope and are delivered only to endpoints in the same environment. Cheat sheet Use non-production environment keys such as sk_uat_* for development and UAT. Use environmentId to choose dashboard-session setup scope; API keys already carry their environment. Going live makes the immutable production environment usable for production payment paths without changing the structure of your integration code."},{"title":"Bootstrapping Extension Credentials","route":"/developers/extensions/bootstrap","headings":["Bootstrapping Extension Credentials","The handshake, end to end","1. Issue client credentials","2. Where the client secret goes","3. Receive the install callback","4. Pull your credentials — read the secret, mint the key","Read the signing secret","Mint a fresh API key","Rotate a leaked signing secret","Error codes to distinguish in your handler","5. Recover: enumerate your own installs","The three credentials, and why they rotate differently","Next steps","secret API key holding extensions:write — an extension can never mint this","for itself (not on the extension_client allow-list).","→ { data: {","\"clientId\": \"extc_...\",","\"clientSecret\": \"...\", ← shown once, never again","\"installCallbackUrl\": \"https://your-worker.example.com/throttle/install-callback\"","} }","Calling this again ROTATES the pair — the previous secret stops verifying immediately.`;","public identifiers, wrong for the client secret.","The client secret goes in the deploy's secret store instead, never in a file","that gets committed:","(Vercel: vercel env add · Fly: fly secrets set · Render: dashboard env group)`;","you need the secret in order to verify a delivery in the first place.","\"installationId\": \"inst_01HZX...\",","\"installSequence\": 2,","\"endpointId\": \"we_01HZY...\",","\"url\": \"https://your-worker.example.com/webhooks/throttle\",","\"enabledEvents\": [\"order.created\"],","\"signingSecret\": \"whsec_...\",","\"previousSecretExpiresAt\": null","} }`;","keeps the outgoing one verifying for graceSeconds (default 86400 = 24h), so","deliveries in flight do not start failing before you have stored the new one.","\"signingSecret\": \"whsec_...\", ← the new one","\"previousSecretExpiresAt\": \"2026-09-15T10:00:00.000Z\"","replaces. Call this only after a delivery's signature has verified, and only","when you actually need to write back to Throttle."],"text":"Extensions Bootstrapping Extension Credentials Every credential Throttle mints by default belongs to one install and is shown to the installing merchant, once. That works for an iframe extension a human eventually opens, but it leaves a webhook-only extension with no machine route to its own secrets at all — every delivery fails signature verification until someone copy-pastes a secret out of the dashboard. Client credentials close that gap: a developer issues one pair for their catalog extension, and every install after that bootstraps itself. The handshake, end to end Four exchanges, only the first of which involves a person. The thing to hold on to is which way each arrow points and what it carries: Throttle pushes the fact of an install, and your extension pulls the secret — the push is deliberately worthless to anyone who intercepts it. 1. Issue client credentials From your extension's catalog settings in the dashboard, generate a clientId / clientSecret pair. This is a one-time, developer-facing action — it is never shown to a merchant. The route accepts a dashboard user holding the application:extensions:write permission, or a secret ( sk_ ) API key holding the extensions:write scope — but never the extension's own client credentials, which are deliberately not on the allow-list that reaches this route. An extension cannot mint its own credentials. installCallbackUrl must be https:// — a plain http:// URL is rejected with 422 invalid_callback_url . Calling this route again rotates the pair immediately — the previous secret stops verifying the moment the new one is issued. That is deliberate: a developer rotating their own deploy s secret picks up the new value from their own secret store in the same step, unlike the per-install rotations below, which do carry a grace window because a merchant is not redeploying anything. 2. Where the client secret goes A typical extension worker already keeps a wrangler.jsonc (or equivalent) with a vars block for public values like THROTTLE_EXTENSION_ID . That block is committed to git and readable by anyone with repo access — correct for an identifier, wrong for a secret. The client id is fine alongside it; the client secret is not. Put the client secret in your deploy's secret store — wrangler secret put for a Cloudflare Worker, or the equivalent for whatever you deploy to. Nothing about the existing vars block tells you where the line is; this is that line. 3. Receive the install callback Once you have client credentials and an installCallbackUrl on file, every future install of your extension POSTs a signed notice to that URL — the only human action anywhere in this flow is the merchant clicking Install. The payload carries identifiers only, never secrets : it tells you an install happened and who it belongs to, not how to authenticate as it. The request carries an X-Throttle-Install-Token header — an RS256 JWT signed with the same extension JWKS keypair your bridge already verifies session tokens against. aud is your extension id, sub is the installation id. The JSON body repeats the token's claims for convenience, but a receiver must verify the JWT against https://api.usethrottle.dev/.well-known/extension-jwks.json and trust its claims — never the body. A body is just JSON anyone could send; the signature is what makes workspaceId , applicationId , and the rest trustworthy. An install can never fail because your endpoint was unreachable. Throttle attempts the callback 3 times total (two retries) with backoff, then gives up silently — the merchant's install always succeeds regardless. Retries are status-driven, not blanket: any response below 500 ends delivery immediately — a 4xx is treated as a permanent rejection and is never retried. Respond 5xx for a transient failure you want Throttle to retry, and reserve 4xx for a failure that retrying would never fix (a bad or missing token). If the callback never arrives (dropped, never configured, all 3 attempts exhausted, or you lost your own datastore), enumerate your installs instead of waiting for it. 4. Pull your credentials — read the secret, mint the key Every route below authenticates with the same header: Authorization: Basic base64(clientId:clientSecret) . Nothing else accepts that scheme, and a client credential can reach only these three routes plus the enumeration route below — anything else is 403 before the handler runs, and so is the wrong verb on a route it can reach. But the read and the mint are not interchangeable , and treating them as one call is the mistake this section exists to prevent. A client id/secret pair base64-encodes to well over 76 characters, so GNU coreutils base64 (the default on Linux and most CI images) inserts a newline into the header value — BSD/macOS base64 does not, which is exactly why this ships unnoticed from a Mac. The wrapped header fails validation and comes back as 401 unauthorized , which reads as a wrong secret rather than a formatting bug. Pipe through tr -d \\n (portable) or pass base64 -w 0 on GNU systems. Fetching the signing secret is a read — idempotent, and safe to call before you have verified a request, which you must, because you need the secret in order to verify it in the first place. Minting the API key is not a read: it issues a brand-new key and expires the old one. Call it only after a delivery's signature has verified, and only when you actually need to write back to Throttle. Collapsing both into one ensureCredentials() call hands anyone who can guess an installation id a way to churn your keys from outside — the read is harmless to run on unauthenticated input, the mint is not. Read the signing secret Genuinely readable: the secret lives in cleartext on the installation's webhook endpoint row, so re-reading it changes nothing. Call this as soon as you learn an installation id — before you have verified anything with it — because it is what lets you verify. Mint a fresh API key API keys are hashe"},{"title":"Build an Extension","route":"/developers/extensions/build","headings":["Build an Extension","Design for each installation","Choose where code runs","Browser iframe","Publisher backend","1. Create the extension","2. Create and publish a version","3. Install into an application","4. Wire the iframe bridge","Option A — hosted script (no build step)","Option B — npm package","React pattern","Deep links","Runtime model","Next steps","→ { data: { id: \"ext_...\", name: \"Inventory Sidebar\", lifecycle: \"draft\", ... } }`;","→ { data: { id: \"ver_...\", version: \"1.0.0\", status: \"draft\", ... } }","3. Publish the version (marks it installable)","→ { data: { id: \"ver_...\", status: \"published\", ... } }`;","Response (store the API key immediately — shown once. The signing secret can be read back later.):","\"data\": {","\"id\": \"inst_...\",","\"extensionId\": \"ext_xxx\",","\"versionId\": \"ver_xxx\",","\"status\": \"active\",","\"environmentId\": \"8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0\",","\"environmentSlug\": \"uat\",","\"environmentKind\": \"non_production\",","\"providerEnvironment\": \"sandbox\",","\"apiKey\": \"sk_uat_...\", ← store this securely; shown once","}","}`;"],"text":"Extensions Build an Extension This guide walks you from creating an extension catalog entry to having a working iframe panel with authenticated API access and live event delivery. Start with the Get Started tutorial . This page explains architecture and API mechanics after your first private install. Design for each installation Treat every installation as an independent tenant connection. Store a server-side record keyed by installation ID with the workspace, application, environment, pinned extension version, provider account reference, encrypted credential reference, status, last successful sync cursor, timestamps, and uninstall state. Throttle is the authority for whether an installation is active and which version and scopes it has. Your backend is the authority for provider credentials, mapping state, cursors, retries, and reconciliation. Never infer tenant identity from a provider account alone; validate the signed installation context. Stop queued and scheduled work when an installation is suspended or uninstalled. Choose where code runs Browser iframe Use for safe display settings and operator actions. Verify bridge identity and assume users can inspect all browser-delivered data. Publisher backend Use for provider secrets, event processing, scheduled synchronization, reconciliation, and privileged provider calls. 1. Create the extension The extension manifest declares what surfaces your extension exposes, which scopes it needs, and which events it wants to receive. You can declare an iframeUrl , a webhookUrl with eventSubscriptions , or both. Every scope listed must be extensionAllowed — see the Scopes reference . Every scope in the manifest is validated against the extensionAllowed flag when the extension is created or updated. Human and admin scopes ( team_members:* , billing:* , admin:* , * ) are never grantable to extensions and return 400 invalid_scope . 2. Create and publish a version Versions are immutable snapshots of the manifest. Create a version when you're ready to make an installable release, then publish it. A version must be published before it can be referenced in an install call. 3. Install into an application Installs are application-scoped and environment-scoped. The install response contains a one-time API key and, if a webhook URL is declared, the signing secret. Copy the API key immediately — it is shown exactly once. The signing secret is stable and readable at any time, so losing it is recoverable. The apiKey and webhookSigningSecret are unique to each install. Store them in your secrets manager. The API key is shown exactly once — losing it means uninstalling and reinstalling. The signing secret is stable and readable at GET /api/v1/installations/:id/webhook-secret . Copying either value into your extension by hand is deprecated and will be removed in a future release; use automatic setup . 4. Wire the iframe bridge For UI (iframe) extensions, call createBridge() once at page load. The bridge sends a ready message to the dashboard, which responds with a signed session token and context. The token is automatically refreshed before it expires (10-minute TTL). Option A — hosted script (no build step) Option B — npm package React pattern Always pass targetOrigin: 'https://app.usethrottle.dev' to createBridge() . Without it the bridge falls back to document.referrer or '*' as the postMessage target, which can leak the session token to other origins if the referrer is spoofed. The bridge does not pass configSchema or installed config values automatically. The dashboard consumes configSchema to collect settings during install and stores the resulting config on the installation record. See the configuration schema guide for the full lifecycle. Deep links A link to the launch page can open your iframe at a specific page. Add a page query parameter, and the dashboard loads your iframeUrl with that value as its URL fragment, so ?page=/products?x=1 opens <iframeUrl>#/products?x=1 . Read location.hash in your iframe to route. The value must be URL-encoded in the query string. The dashboard accepts it only when it is a string that starts with / , does not start with // , contains no \ , no # and no control characters, and is at most 512 characters. Anything else is ignored and the iframe opens at its configured URL. An existing fragment on your iframeUrl is replaced. The page is applied when the iframe first loads; changing it later does not reload an open session. Runtime model A UI extension is loaded only after it is installed into an application. The dashboard opens the installed version's iframeUrl , creates a bridge host for that iframe origin, and mints a 10-minute extension-session JWT for the active dashboard user. Your iframe calls createBridge() once, sends a ready message to the parent, receives a session message, and then uses bridge.api for scoped REST calls. The session token carries the installed version's scopes, application, workspace, environment, role, extension id, installation id, and version. It does not carry catalog authoring data such as configSchema , and it does not carry one-time install secrets. Event extensions use the separately generated webhook signing secret and verify incoming deliveries in their backend route. Next steps Identity JWT — the full claim set, TTL, JWKS verification, and using the token as an API bearer. Events — verify the X-Throttle-Signature header and browse the full event catalog with required scopes. Scopes — which scopes extensions may request and how scope ceilings work. Versioning — immutable published versions, upgrades, and rollback. Security model — RS256 JWKS, iframe sandboxing, origin pinning, scope ceilings."},{"title":"Extension Events","route":"/developers/extensions/events","headings":["Extension Events","Payload envelope","Verify signatures","Rotate the signing secret","Process idempotently","Delivery inspection and replay","You are told when your endpoint stops accepting deliveries","Auto-suspend on repeated failures","Event catalog","→ { \"data\": { \"installationId\": \"…\", \"endpointId\": \"…\",","\"signingSecret\": \"whsec_…\", # shown once","\"previousSecretExpiresAt\": \"2026-09-11T09:15:02.101Z\" } }`;","Replay a dead-lettered delivery","Send a verification ping to the installation's webhook URL.","The ping is signed with the installation's signing secret, like every delivery."],"text":"Extensions Extension Events Extensions can subscribe to Throttle events by declaring a webhookUrl and an eventSubscriptions list in their manifest. Throttle creates a managed webhook endpoint for each install and delivers signed events to your URL with the same retry and dead-letter mechanics as standard webhooks. Use this reference for backend and hybrid applications after your first private install , then validate retries and replays with the testing guide . Payload envelope Every delivery is a JSON POST with a stable top-level envelope. The event-specific payload lives under data . Headers: X-Throttle-Signature , X-Throttle-Event-Id , X-Throttle-Event-Type , Content-Type: application/json . Envelope fields: id (unique event ID), type , version (payload-schema version, currently "1" ), workspaceId , environmentId , environmentKind ( production or non_production ; omitted only when the environment could not be resolved, so treat absent as unknown rather than as production), createdAt , data . Additive fields are not a version bump. Throttle adds envelope fields and data fields without changing version , announced in the changelog but not ahead of the first delivery that carries them. Parse the envelope with a schema that strips unknown keys. A .strict() envelope schema rejects every delivery the moment a field is added, and if your handler reports that with the same 401 as a bad signature, the failure is indistinguishable from a rotated secret. Extension starter versions before PR #11 shipped exactly that schema; update and redeploy. Amounts: all money fields are integer minor units (e.g. cents for USD). Environment isolation: an install only receives events from the same workspace environment. The same event is never delivered across environments. The data shape for every event type is typed in @usethrottle/webhook-types . Import the ThrottleEvent discriminated union or ThrottleEventEnvelope generic and narrow on type to get the exact shape per event. Customer contact fields need customer_pii:read Events that involve a buyer carry a customer object under data : cart.* , order.* , payment.* , fulfillment.* , subscription.* and customer.* , where the customer is the subject of the event. The buyer s contact fields are delivered only to extensions granted customer_pii:read . Without the scope, email , phone , firstName , lastName and name arrive as null . The keys are still present, so the payload shape does not change. What stays: the customer id , externalId , externalCustomerId , company and every address, including the order s shipping address, which a fulfilment extension needs to ship. Which scopes count: the scopes of the version the installation is pinned to. customer_pii:read , or the legacy customers:read , which includes it. To start receiving the fields, publish a version that requests the scope and have merchants upgrade to it. Signatures: the fields are removed before the body is signed, so the signature verifies against exactly the body you receive. Replays follow the same rule. Verify signatures Each delivery includes X-Throttle-Signature in the format t=<unix-seconds>,v1=<hex-hmac-sha256> . The signed payload is <t>.<rawBody> — identical to the standard webhook signature scheme. The signing secret is the per-install webhookSigningSecret returned at install time. Signature verification fails if your framework parses and re-serializes JSON before you verify. Capture the raw request body string first, verify, then parse. verifyWebhook rejects deliveries whose t timestamp is more than 300 seconds (5 minutes) from the current clock. Pass toleranceSeconds to override. For 24 hours after the secret is rotated the header reads t=…,v1=<outgoing>,v1=<new> . Check every v1 against the secret you hold and accept if any matches — @usethrottle/extension-bridge 1.1.1+ and the starter's verifier do. A parser that keeps only the last v1 verifies once it holds the new secret, and not before. Rotate the signing secret Either side can rotate: your extension, authenticated as the installation, or the merchant from the dashboard. The old secret keeps verifying for graceSeconds (default 86400, max 604800) while every delivery is signed with both; 0 revokes it now — the leaked-secret case. When the merchant rotates, you are told: a signed extension.webhook_secret_rotated is delivered to the endpoint — dual-signed, so it verifies with the secret you still hold. It never carries a secret. On receipt, fetch the new one with GET /api/v1/installations/:id/webhook-secret and store it before previousSecretExpiresAt ; that read also reports the deadline while a window is open. Like extension.uninstalled , it is delivered to the installation's own endpoint without a subscription and cannot be listed in eventSubscriptions . previousSecretExpiresAt is null when the old secret was revoked immediately — fetch the new secret at once; deliveries in between will have failed and will be retried. Rotating again inside a window replaces the outgoing secret: only two are ever live. Process idempotently Extension event delivery is at-least-once. Retries use the same backoff schedule as standard webhooks: [5m, 15m, 1h, 6h, 24h] (max five attempts). After the fifth attempt, the delivery is marked dead_letter . Always store each event.id before applying side effects. Delivery inspection and replay Use the installation delivery routes to inspect past attempts and replay dead-lettered events. The delivery list and replay require the extensions:read and extensions:install scopes respectively. The delivery list and replays return payments and saved cards in their public shape , so a stored payload that was projected or cleaned no longer matches its original signature. A replay is signed again over the body it sends. The list shows a payload s customer contact fields only to a caller holding customer_pii:read ; for any other key they read as null . The dashboard pings "},{"title":"Get Started with Extensions","route":"/developers/extensions/get-started","headings":["Get Started with Extensions","Before you begin","Your first private install","You are done when"],"text":"Extensions Get Started with Extensions Build and privately install a third-party app using only Throttle's public dashboard, APIs, packages, and documentation. Before you begin A Throttle workspace and application Developer or Admin access to that application Node.js 20 or newer for the recommended starter A publicly reachable HTTPS URL during iframe or webhook development The list of scopes an extension may request — extensions can only hold scopes marked extensionAllowed , which is a subset of the full API scope catalog. Naming any other scope in your manifest is rejected at publish time. You can create, version, install, and test a private extension in your own workspace. Paid-plan or partner eligibility matters when you submit a public marketplace listing. Your first private install You are done when The iframe displays the verified workspace, application, environment, installation, and user context. The webhook returns 2xx only after verifying the signature against the unmodified raw body. Replaying the same event does not create duplicate provider work. Uninstalling stops scheduled work and removes or disables the installation's provider connection. Next, learn how to design the application , test failure cases , and prepare a marketplace submission ."},{"title":"Extension Identity JWT","route":"/developers/extensions/identity","headings":["Extension Identity JWT","Full claim set","How the dashboard delivers the token","TTL and auto-refresh","Verify tokens in your backend","Using the token as an API bearer","Key rotation","Verifying a shopper's customer token","200, Cache-Control: no-store","{ \"data\": { \"active\": true,","\"customerId\": \"c3d4e5f6-0a1b-4c2d-8e3f-4a5b6c7d8e9f\",","\"emailVerified\": true,","\"expiresAt\": \"2026-10-20T12:15:00.000Z\" } }","# Any token that is not live for this application and environment:","{ \"data\": { \"active\": false, \"customerId\": null, \"emailVerified\": false, \"expiresAt\": null } }`;"],"text":"Extensions Extension Identity JWT When the dashboard opens an extension's iframe, it mints a short-lived RS256-signed JWT that identifies the installation, the acting user, and the current workspace and application context. The iframe receives this token through the bridge handshake and uses it as a bearer on every API call. Use this reference after completing the Get Started tutorial . Identity establishes who launched the iframe; scopes establish what it may do. Full claim set The JWT carries both standard claims ( iss , sub , aud , iat , exp ) and the full ExtensionSessionClaims object as top-level custom claims. The claim set is additive: Throttle adds claims without a version bump and without notice, and never removes or retypes one without a changelog entry. Parse the claims you use and ignore the rest. A strict parser that rejects unknown top-level claims will take every one of your panels dark the moment a claim is added, on our deploy, with no change on your side. iss is always "throttle" . Reject tokens with any other issuer. sub is the installationId . aud is the extensionId . environment reflects the install's workspace environment and matches the API key environment. role is the acting user's application-level role ( admin | developer | finance | viewer ). scopes are the scopes from the pinned version — a ceiling on what the token may do. user.name is currently set to the same value as user.email (workspace members do not have a separate display name column today). How the dashboard delivers the token The dashboard creates a BridgeHost (from @usethrottle/extension-bridge ) that wraps the iframe. When the iframe loads and calls createBridge() , the bridge immediately posts a ready message to the parent. The dashboard calls POST /api/v1/installations/:id/launch-token and replies with a session message carrying token , expiresAt , and a context object. The iframe bridge resolves bridge.ready with that context. POST /api/v1/installations/:id/launch-token is Clerk-JWT only — it requires an authenticated dashboard session. API keys cannot mint launch tokens because tokens embed a human user identity. Attempts with an API key return 400 launch_token_requires_dashboard_session . TTL and auto-refresh Tokens have a 10-minute TTL . The bridge tracks the expiry time and proactively sends a refresh message to the host when the token is within 60 seconds of expiry, or immediately on receiving a 401 from the API. The host mints a fresh token and sends a new session message. All pending and in-flight bridge.api.* calls wait for the refresh before proceeding. Verify tokens in your backend If your extension calls your own backend, pass the bridge token in the Authorization: Bearer header and verify it against the public JWKS. The JWKS endpoint is unauthenticated — no key needed. Always return null / reject on any verification failure — expired tokens, unknown key ID, wrong issuer, bad signature. Never attempt to use a partially-validated payload. The Throttle SDK's verifyExtensionSessionToken follows this contract: it returns null on any error and never throws. Using the token as an API bearer The extension-session JWT is also accepted by the Throttle API as a Bearer token on /api/v1/* routes. The token's scopes claim is used as the caller's granted scope set, exactly as with an API key. This means the iframe can call the API directly without the extension's dedicated API key being present in the browser. The token's scopes are the pinned version's scopes, which are themselves a subset of the extensionAllowed scopes. Calling a route that requires a scope not in the token's list returns 403 insufficient_scopes . Key rotation The JWKS endpoint may publish multiple keys (identified by their kid header). Your verification library (e.g. jose 's createRemoteJWKSet ) automatically selects the right key by kid and caches the JWKS response. Key rotation adds new key(s) to the set before retiring old ones — in-flight tokens remain valid through their TTL, and the JWKS cache handles the transition. Create your createRemoteJWKSet instance once at module initialization and reuse it. The jose library caches the JWKS and re-fetches on unknown kid . Recreating it per-request makes an unnecessary HTTP call on every token verification. See also: Security model for the full threat model around JWT issuance and iframe isolation. Verifying a shopper's customer token The JWT above identifies a dashboard user. A shopper signed in to a storefront holds a different credential: the accessToken from storefront customer accounts . When your extension's backend receives one (for example to show a signed-in shopper their own prices), ask Throttle whether it is live and whose it is. Never trust a customer id the browser sends on its own. Scope. Call it with your installation's secret API key holding customer_tokens:read ; list that scope in your extension's scopes . customers:read does not grant it, and you do not need it: introspection is its own narrow scope so an app that only checks sign-ins never holds customer PII, addresses or stored cards. Publishable pk_ keys cannot hold it ( 403 ). Answer. active: true only for a live access token of the calling key's own workspace, application and environment. Then customerId is the shopper, emailVerified is read from their account now (not from the token), and expiresAt is when the token expires. Inactive. An invalid, expired or revoked token, a signed-out session, or a token of another application or environment all get the same answer, { active: false, customerId: null, emailVerified: false, expiresAt: null } , so the answer tells you nothing more about the token. Treat the shopper as signed out. No caching by Throttle. Responses carry Cache-Control: no-store . Rate limit. The route has its own budget, 600 calls a minute per API key and environment, separate from the rest of yo"},{"title":"Installing Extensions","route":"/developers/extensions/install","headings":["Installing Extensions","Install an extension","Read your signing secret","Scope and event consent","Configuration","Configuration schema","Environment-scoped installs","List and inspect installs","Uninstall","Both the dashboard session token (Clerk JWT) and the Application ID header","are required — installs are application-scoped.","─── Response (apiKey is shown once — store it immediately) ──────────────────","Authenticate with the installation's own API key.","─── Response ─────────────────────────────────────────────────────────","The extension catalog entry and other applications' installs are unaffected.","→ 204 No Content`;","→ { data: { id: \"inst_xxx\", config: { ... }, ... } }`;","Response shape: paginated list","\"data\": [ { \"id\": \"inst_...\", \"extensionId\": \"ext_...\", \"status\": \"active\", ... } ],","\"meta\": { \"requestId\": \"...\", \"pagination\": { \"cursor\": null, \"hasMore\": false } }","One installation by id, scoped the same way. 404 not_found when this","application + environment has no installation with that id."],"text":"Extensions Installing Extensions An install pins a specific published version of an extension to an application, mints a dedicated API key (and optionally a webhook signing secret), and begins event delivery if a webhookUrl was declared. Install an extension Pass the versionId of the published version you want to install, or omit it to install the default version. For another workspace's public extension only the approved version may be named; see Versioning . Optionally pass config — a free-form JSON object that your extension reads at runtime. The install's environment is derived from the caller's API key or dashboard-selected X-Throttle-Environment-Id and cannot be overridden by the request body. apiKey is shown exactly once in the install response. If you lose it, the only recovery is to uninstall and reinstall — a new key is minted. The webhookSigningSecret is different: it is returned once on first install, but it is stable and your extension can read it back at any time (see below), so losing it is recoverable without a reinstall. Having a merchant copy the API key and signing secret out of the install wizard and paste them into your extension is manual setup. It still works, but it is deprecated and will be removed in a future release. Use automatic setup instead: your extension fetches both itself when it is installed. Read your signing secret Your extension can fetch the signing secret for its own installation, so it never has to depend on a human copying the value out of the dashboard at install time: Authenticate with the installation's own API key (or its extension session token, which reads the secret only while the installation is active — once a merchant suspends or uninstalls it, a session minted moments earlier gets 404 for the rest of its ten minutes). A dashboard user holding application:extensions:install can also read it, for a suspended or uninstalled installation too. Any other credential gets a 404 . The route needs no particular scope — the credential itself is the authorisation. Do not attach this response to an error. The natural failure handler — wrap the fetch error with the response body — puts a live signing secret into every error log you keep. Raise an error that carries the HTTP status and nothing else, and read the secret only on the success path. Uninstalling and reinstalling reuses the same installation row, and deliberately keeps the signing secret so verification does not silently start failing. Use installSequence — which increments on every (re)install while id stays put — to tell a reinstall from a restart, and to know when to discard credentials you cached against the install. Scope and event consent When you install an extension, you implicitly consent to its declared scopes and event subscriptions. The installer must hold the extensions:install scope (API key) or the application:extensions:install RBAC permission (dashboard user). The issued API key is scoped exactly to the version's declared scopes. If the extension later publishes a new version with additional scopes , upgrading to that version requires re-consent. The upgrade endpoint returns 409 new_scopes_require_consent listing the new scopes, and the caller must retry with acknowledgedNewScopes: true . See the Versioning guide . Configuration The config field is a free-form JSON object passed at install time and updateable any time thereafter. Use it for extension-specific settings such as API keys for third-party services, feature flags, or integration identifiers. Your extension reads it from the installation record; it is not embedded in the iframe URL or automatically included in the bridge session. Do not place ShipStation keys or other provider secrets in installation config when an iframe can read that config. Collect credentials through an authenticated setup flow, encrypt them in publisher-controlled storage, and retain only a non-secret connection status or provider account label in display configuration. bridge.ready resolves with user, workspace, application, environment, installation id, version, role, and scopes. It intentionally does not include config or configSchema . If a browser iframe needs config, give the extension an explicit read scope and fetch the installation record. Keep secrets that should never reach browser JavaScript on your extension backend instead. Do not store the webhookSigningSecret in config — the config object may be readable by anyone with extensions:read scope. Store the signing secret in your own secrets manager. Configuration schema Extension authors can publish a configSchema to describe the settings an installer should provide. The dashboard authoring flow includes a field builder for simple string, number, and boolean settings plus an advanced JSON editor for custom JSON Schema. During install and on the management page, Throttle uses this schema to render the configuration form. Empty schemas are treated as “no configurable options.” The builder emits a JSON Schema object with type: "object" , properties , and optional required . Use the advanced JSON mode for nested objects, arrays, enums, or provider-specific validation. configSchema lives on the workspace catalog extension while you are editing it. Creating a version snapshots that schema into the immutable version record. Installs use the pinned version's schema to collect per-install config values. Publishing a new schema does not rewrite existing installs until they upgrade to a version that carries it. Environment-scoped installs Installs are per environment. A UAT install receives only UAT events and gets an API key for that environment; a production install receives only production events and gets a production-environment API key. The installs are completely independent — each has its own config, its own API key, and its own event delivery endpoint. List and inspect installs Install status values: active , suspended . Suspended installs sto"},{"title":"Line Resolvers","route":"/developers/extensions/line-resolvers","headings":["Line Resolvers","What a line resolver is","Which installation is called","The request","Verify the signature","Test vector","The answer","Refusal codes and what the buyer gets","Timeouts, retries and fail closed","What Throttle stores","Sign-in merges","Checkout","The completion guard","Shipping on resolved carts","Error reference","Related","Remove the resolver from the draft later (allowed only with PATCH):"],"text":"Extensions Line Resolvers A line resolver lets an installed extension own cart lines. Throttle never takes a price from the browser for a line your extension owns: it asks your resolver for the name, price, stock and limits on every add, quantity change, sign-in merge and checkout. What a line resolver is Your extension declares an HTTPS endpoint, lineResolverUrl . A storefront (or a merchant server) creates a cart with lineResolver: { extensionId } , which makes it a resolved cart . Lines added to it by referenceId are owned by your installation, and Throttle calls your resolver to price them. Opt in, per cart. Only a cart created with lineResolver is resolved. Every other cart behaves exactly as before: same status codes, same error envelopes, same response bodies, same webhooks. The one exception is an environment that turns on resolved lines only : there, unresolved cart-session carts are locked too. One owner per line. Each owned line records the installation that priced it, so a cart can hold lines from two resolvers after a sign-in merge, and each line is re-priced by its own resolver. Fail closed. If your resolver cannot answer, the operation is refused and nothing on the cart changes. Throttle never falls back to a browser price. Shoppers create resolved carts through cart sessions ; servers through the Cart API . This page is the contract for the resolver itself. Declare a resolver: lineResolverUrl lineResolverUrl is a manifest field on the extension, accepted by POST /api/v1/extensions and PATCH /api/v1/extensions/:id . A version snapshots it like any other manifest field, and an installation uses the URL of the version it is pinned to. https: URL on the default port (443). An explicit :443 is fine; any other port is not. , html: 400 validation_error : lineResolverUrl must be an https URL on the default port (443) . ], [ A DNS host name: no IP address (IPv4 or IPv6, in any notation), no localhost or *.localhost, no trailing dot. , html: Same 400 validation_error . ], [ No user name or password in the URL. , html: Same 400 validation_error . ], [ html: Requires webhookUrl and at least one entry in eventSubscriptions . Resolver calls are signed with the managed webhook endpoint's signing secret, which only exists for an extension that receives events. , html: 422 line_resolver_requires_webhook . The check runs on the stored extension merged with the PATCH, so a PATCH cannot drop the webhook side and strand a resolver. ], [ html: PATCH with lineResolverUrl : null removes the resolver. After that, eventSubscriptions: [] is allowed again. , — ], ] /> Domain-verified at review. Marketplace review checks the resolver URL's registrable domain against your workspace's verified domains, exactly as it does for iframeUrl and webhookUrl ( domain_not_verified otherwise). A new domain forces review. A version whose resolver domain differs from the approved one is flagged changed_line_resolver_domain and needs review again. Adding a resolver to an approved extension is a domain change too. See Publishing . Signing secret. The resolver is signed with the same secret your installation receives for webhooks. Rotate it with the webhook secret rotation ; resolver calls follow the same grace window. Throttle reads the signing secret from the managed webhook endpoint linked to the installation. An older installation whose managed endpoint does not carry that link is treated as having no secret: creating a resolved cart answers 422 line_resolver_not_available and an existing resolved cart answers 503 line_resolver_unavailable , both with reason: no_signing_secret . It is never called unsigned. Which installation is called lineResolver.extensionId on cart creation names your extension. Throttle maps it to that extension's installation in the cart's application and environment, and stores the installation on the cart. Creation is refused with 422 line_resolver_not_available and details.reason : A resolved cart's currency must be the environment's currency or one of the merchant's extra cart currencies (case-insensitive), or creation answers 422 currency_not_enabled with details: { currency, enabledCurrencies } , where enabledCurrencies lists the environment's currency and the extra ones. Your resolver is asked for prices in the cart's currency, so it may be called in any of them. On a cart in an extra currency a fixed_amount discount is refused with 422 discount_currency_unsupported , and a non-zero shipping rate from a shipping configuration in another currency with 422 shipping_currency_unsupported , so such a cart checks out with no shipping or a zero-rate method. A cart never changes currency: to sell in another one, start a new resolved cart. If the installation later goes away, is suspended, or loses its URL or secret, calls on its existing carts fail closed with 503 line_resolver_unavailable and the same details.reason . The request Throttle sends one POST per resolver per operation, with a JSON body of type cart.lines.resolve . cart.lines.resolve . ], [ code: id , A UUID for this request. The checkout retry sends the same body, so the same id. ], [ code: installationId , Your installation. Use it to find the merchant catalog to price from. ], [ code: applicationId / environmentId , The cart s application and workspace environment. ], [ code: cartId , The Throttle cart. On a create it is the id the new cart will get. ], [ code: intent , html: Why Throttle is asking: add , update , refresh or checkout (below). ], [ code: currency , The cart currency. Every ok line must answer in it. ], [ code: customerId , The signed-in customer of the cart, or null for a guest cart. ], [ code: customerEmailVerified , True when that customer has verified the email on their storefront account. ], [ code: locale , html: The buyer locale given at cart creation (for example en-GB ), or null. ], [ code: lines , html: 1 to 50 lines, each { lineI"},{"title":"Operating Your Extension","route":"/developers/extensions/operations","headings":["Operating Your Extension","Responsibility boundary","Throttle operates","The publisher operates","Baseline telemetry","Incident and lifecycle procedures"],"text":"Extensions Operating Your Extension Marketplace publication starts an operational commitment: monitor the integration, support installations, and release compatible upgrades. Responsibility boundary Throttle operates Catalog, versions, installations, and review systems Scoped extension credentials and identity tokens Signed event delivery and delivery logs Marketplace availability and platform status The publisher operates Application hosting, queues, and persistence Third-party credentials and provider API use Mapping, reconciliation, monitoring, and alerting Customer support, privacy obligations, and incident response Baseline telemetry Record the Throttle request ID, installation ID, event ID and type, extension version, provider account reference, attempt number, duration, result class, and environment. Use structured logs so one installation can be traced without searching payload contents. Do not log API keys, webhook signing secrets, access tokens, authorization headers, or customer-sensitive request bodies. Redact provider responses before attaching them to support cases. Incident and lifecycle procedures Back off and reconcile after provider outages or rate limiting. Surface expired credentials as an actionable installation state. Quarantine poison events and provide a replay procedure. Pause new installs or move the listing private during a material incident. Keep upgrades backward compatible; document and review new scopes. Publish deprecation notice and data export/deletion instructions before retirement. On uninstall, revoke provider access and stop scheduled or queued work safely. Use the Versioning guide for releases and the marketplace guide for listing changes or re-review."},{"title":"Extensions Overview","route":"/developers/extensions/overview","headings":["Extensions Overview","Choose your extension shape","Backend integration","Embedded application","Hybrid application","What Throttle provides—and what you provide","Throttle","Publisher","Two-level model: catalog vs installs","Catalog (workspace-level)","Installs (application-level)","Surfaces","UI iframe","Event webhooks","Lifecycle: draft → private → public","Versioning and pinned installs","Environment isolation"],"text":"Extensions Extensions Overview Extensions let you embed custom UI panels, receive live event webhooks, or do both — all from inside the Throttle dashboard and API surface, with an authenticated identity token, scoped API access, and signed event delivery. Choose your extension shape Backend integration Subscribe to events and process them on your server. Use this for connectors, synchronization, and work that must continue when nobody has the dashboard open. Embedded application Render an iframe and use the bridge for verified identity and scoped API access. Use this for configuration, reports, and operator workflows. Hybrid application Combine a background event processor with an embedded management UI. Most full-service provider connectors eventually use this model. A ShipStation-style connector should synchronize orders, fulfillment, tracking, cancellations, and returns on its backend. An iframe can configure and monitor the connector, but synchronization cannot depend on the iframe being open. What Throttle provides—and what you provide Throttle Catalog, version, installation, and marketplace workflows Scoped credentials and signed iframe identity Signed event delivery and installation configuration forms Publisher Hosting, persistence, queues, retries, and monitoring Third-party credentials and provider-specific behavior Support, privacy terms, incident response, and data lifecycle External developers use public APIs, packages, the dashboard, and these docs. You do not need access to Throttle's private repository. Build your first private install . Two-level model: catalog vs installs Extensions live at two levels. The catalog is workspace-scoped: one extension definition per workspace, with a name, surfaces (iframe URL and/or webhook URL + events), and a list of requested scopes. An extension can be authored by your workspace and used only internally, or promoted to public for any Throttle workspace to install. Installs are per-application. When a dashboard user installs an extension into an application, a single-use API key and (if a webhook URL is declared) a per-install signing secret are issued. Both are shown exactly once at install time; an extension should fetch its own with automatic setup , because having the merchant paste them in is deprecated. Multiple applications in the same workspace can each have their own independent installation of the same extension, with separate API keys, signing secrets, and configuration values. Catalog browsing supports cursor pagination and search. Use q to search extension name, slug, author, and description, and installable=true to return only private/public extensions with a published version before pagination is applied. Catalog (workspace-level) POST /api/v1/extensions — create GET /api/v1/extensions — list PATCH /api/v1/extensions/:id — update manifest POST /api/v1/extensions/:id/versions — create version POST /api/v1/extensions/:id/versions/:vid/publish — publish POST /api/v1/extensions/:id/lifecycle — promote/demote Installs (application-level) POST /api/v1/extensions/:id/install — install (pinned to a version) GET /api/v1/installations — list for this app POST /api/v1/installations/:id/upgrade — upgrade to new version PATCH /api/v1/installations/:id/config — update config POST /api/v1/extensions/:id/uninstall — uninstall Surfaces An extension manifest declares one or both surfaces. You can mix them freely. UI iframe Declare an iframeUrl in the manifest. The dashboard renders a sandboxed <iframe> at that URL when the extension panel is open. Your page calls createBridge() from @usethrottle/extension-bridge (or the hosted script tag) to handshake with the dashboard, obtain a signed identity token, and make authenticated API calls. See the Build guide and the Identity JWT reference . Event webhooks Declare a webhookUrl plus the list of eventSubscriptions your extension wants to receive. Throttle creates a managed webhook endpoint for the install and delivers signed events to that URL automatically. The per-install webhookSigningSecret is returned in the install response, and — unlike the API key — it is not lost if you miss it: GET /api/v1/installations/:id/webhook-secret reads the current value back at any time. See the Events reference . An extension can declare both iframeUrl and webhookUrl . The iframe bridge and the webhook delivery pipeline are independent — the bridge token gives the iframe REST access, while the webhook pipeline delivers async events to your server. Use both when you need a live UI panel and background event processing. Lifecycle: draft → private → public Every extension starts as draft . You iterate freely — create versions, update the manifest — before promoting it. draft — visible only within your workspace; can only be installed by your own workspace's applications; no published version required. private — still workspace-private but signifies the extension is production-ready. Installs within your workspace are in this state during internal testing. public — listed in the global marketplace; any Throttle workspace can install it. Requires the application:extensions:admin permission. Throttle must enable the public marketplace for your workspace. Versioning and pinned installs Installs are always pinned to a specific published version. Publishing a new version does not automatically upgrade existing installs — each install retains the version it was pinned to until explicitly upgraded via POST /api/v1/installations/:id/upgrade . If the new version declares additional scopes, the installer must acknowledge them; Throttle returns 409 new_scopes_require_consent listing the delta. See the Versioning guide . Environment isolation Installs carry the workspace environmentId selected by the installing caller. A UAT install issues a UAT API key and receives only UAT events; a production install issues a production API key and receives only production events. The catalog itself (the extension definit"},{"title":"Publishing to the Marketplace","route":"/developers/extensions/publishing","headings":["Publishing to the Marketplace","Before you submit","Eligibility","Paid plan or partner status","Publisher Agreement accepted","Listing requirements","Review lifecycle","What “Approved” unlocks","Future versions and re-review","Auto-publish (no re-review)","Requires re-review","Launch and rollback","Webhook events","Related guides","Requires: an accepted Publisher Agreement + a completed Listing.","Response:","\"data\": {","\"id\": \"rev_01HZX...\",","\"extensionId\": \"ext_xxx\",","\"status\": \"pending\",","\"versionId\": \"ver_xxx\",","\"createdAt\": \"2026-06-06T10:00:00.000Z\"","}","}`;","(a dashboard session works too). Field name is \"file\"; the response carries the","public URL to put on the extension / listing.","→ { \"iconUrl\": \"https://.../extensions/icons/01J....png\", \"key\": \"...\" }","→ { \"screenshotUrl\": \"https://.../extensions/screenshots/01J....png\", \"key\": \"...\" }","Then reference them: iconUrl on PATCH /api/v1/extensions/:id, and the","screenshot URLs in the listing's \"screenshots\" array on PUT /api/v1/extensions/:id/listing.`;","→ { data: { id: \"ext_xxx\", lifecycle: \"public\", ... } }`;"],"text":"Extensions Publishing to the Marketplace The Throttle marketplace lets any workspace discover and install your extension. Getting there requires meeting eligibility requirements, completing a listing, and passing a staff review. You control when the extension goes live after approval. Before you submit Confirm workspace eligibility and accept the Publisher Agreement. Publish the exact version you tested and document its functionality and upgrade behavior. Complete the listing, support contact, privacy policy, and terms of service. Demonstrate why every requested scope and event subscription is necessary. Complete the testing preflight and security review . Name an operational owner who will monitor errors and respond to customer and Throttle review messages. Review status and feedback are visible in the dashboard. Throttle does not promise a review turnaround in this guide; plan your launch around approval rather than a speculative date. Eligibility Your workspace must meet both of the following conditions before you can submit an extension for review: Paid plan or partner status Your workspace must be on an active paid Throttle plan, or have partner status granted by Throttle. Sandbox (trial) workspaces cannot submit extensions for public marketplace review. If you are on a trial and want to publish, upgrade your workspace first or apply for the partner program . Publisher Agreement accepted A workspace owner or admin must accept the Throttle Publisher Agreement in the dashboard under Extensions → Catalog → [your extension] → Review → Publisher Agreement . Acceptance is recorded once per workspace and covers all extensions you publish. The Submit button is disabled until acceptance is on record. Approval records that a specific version passed review; it does not list you. A workspace admin still has to set the extension's lifecycle to public , and that is only permitted while the approved version is still the one you are serving. Publishing any change afterwards moves you off the approved version and requires a new review before you can go public again. Listing requirements Before submitting for review you must complete your extension's public listing. The preflight check validates all of the following fields. Missing or invalid fields are returned as an array of specific errors with a 422 response. Public name — a human-readable display name for the marketplace listing, separate from the internal extension slug. Tagline — a single sentence (≤ 80 characters) that describes what the extension does. Category — one of the marketplace taxonomy categories (e.g. analytics , fulfillment , finance ). Displayed as a filter on the browse page. Icon — a square PNG, JPEG, WebP or SVG (≤ 2 MB) uploaded via POST /api/v1/extensions/icon-upload . Stored as a public object and referenced by its public URL. At least one screenshot — a minimum of one screenshot or promotional image (PNG, JPEG or WebP, ≤ 5 MB, 1280 × 800 recommended) uploaded via POST /api/v1/extensions/screenshot-upload . Used in the detail page carousel. Support URL — an HTTPS URL to a support page or ticketing system. Privacy policy URL — an HTTPS URL to your privacy policy. Required by the Publisher Agreement. Terms of service URL — an HTTPS URL to your extension's terms of service. The preflight check rejects any support, privacy, or terms URL that does not begin with https:// . Plain HTTP URLs are not accepted. Both image uploads are multipart/form-data and accept a secret API key with extensions:write as well as a dashboard session, so a release script can complete the whole listing without the dashboard. Review lifecycle Submitting for review kicks off an automated preflight followed by a manual staff review. The full state machine is: Submit — call POST /api/v1/extensions/:id/reviews (or use the dashboard Review tab). Throttle runs automated preflight checks (listing completeness, eligibility, scope diff against the previous approved version) and creates a review with status pending . A confirmation notification and email are sent to workspace members with the extensions:write scope. In review — a Throttle staff member claims the review. The review moves to in_review . Staff can see the automated check results and the scope delta relative to the previously approved version. Decision — staff selects one of three outcomes: Approved — the extension passes review. The developer receives a notification and email and can now publish the extension. Changes requested — staff adds feedback in a threaded conversation. The developer receives a notification with the feedback, addresses the issues, and resubmits. Rejected — the extension will not be published to the public marketplace. Staff includes a reason. The developer may fix the underlying issues and submit a new version for review. The review has a comment thread. Staff can add comments at any point during the review and the developer receives notifications for each message. Use the dashboard Review tab or POST /api/v1/extensions/:id/reviews/:rid/comments to reply. What “Approved” unlocks An approved review does not automatically make your extension public. Approval unlocks the Publish to marketplace button in the dashboard Lifecycle tab and the POST /api/v1/extensions/:id/lifecycle endpoint with lifecycle: "public" . You choose when to go live. Once public , the extension appears in GET /api/v1/extensions?installable=true and is browsable in the marketplace at /w/:ws/a/:app/marketplace . You can revert to private at any time via the lifecycle endpoint. Existing installs are not affected by a lifecycle change. Setting the lifecycle to private automatically publishes a version snapshotting the current configuration when no published version matches it, so a private extension is always immediately installable. Installing an extension that somehow has no published version fails with 409 no_published_version . Throttle retains the ability to revert a pu"},{"title":"Extension Scopes","route":"/developers/extensions/scopes","headings":["Extension Scopes","How scopes flow","Scope implication rules","Management scopes for extensions","Management scope summary","Team RBAC for extension management","Extension-allowed scope catalog","{group}","Name a page of known customers: up to 50 ids, comma-separated","200, Cache-Control: no-store","{ \"data\": [ { \"id\": \"c3d4e5f6-0a1b-4c2d-8e3f-4a5b6c7d8e9f\",","\"name\": \"Jane Doe\",","\"email\": \"jane@example.com\" } ],","\"nextCursor\": null }`;"],"text":"Extensions Extension Scopes Extensions operate within a strict scope ceiling. Only scopes marked extensionAllowed may appear in an extension manifest. Human, admin, and management scopes are never grantable to extensions. Choose the smallest scope set that supports demonstrated behavior. Once selected, continue to installation and configuration . How scopes flow Scopes are declared in the extension manifest, snapshotted into a published version, and issued to an install. At each stage Throttle enforces the extensionAllowed ceiling: Manifest declaration. You list the scopes your extension needs in POST /api/v1/extensions or PATCH /api/v1/extensions/:id . validateScopesForExtension runs immediately — any scope with extensionAllowed: false (or an unknown scope) returns 400 invalid_scope . Version snapshot. When you publish a version, the manifest scopes are frozen into the version record. Subsequent manifest edits do not change the published version. Install. POST /api/v1/extensions/:id/install pins the install to a version. The install's API key is granted exactly the version's scopes — no more, no less. Iframe JWT. The launch-token endpoint mints a JWT whose scopes claim is the pinned version's scope list. Every bridge.api.* call is authorized against those scopes. Scope implication rules The same implication rules that apply to API keys apply to extension scopes: * covers all scopes — but * is never extensionAllowed and cannot appear in an extension manifest. resource:write implies resource:read on the same resource . For example, orders:write covers orders:read . Read never implies write. orders:read does NOT grant orders:write . No cross-resource implication. orders:read does not grant customers:read . The buyer s email, name and phone in event payloads need customer_pii:read . Without it they arrive as null ; addresses are still delivered. See customer contact fields . Finding customers: customer_directory:read An extension that shows or picks customers (for example a customer-group member list) needs their names, not their records. customer_directory:read answers only id , name and email , without customers:read and the PII, addresses and stored cards it carries. Scope. List customer_directory:read in your extension's scopes and call with the installation's secret key. The merchant sees it at install as "Find customers by name or email", among the other requested scopes. It is sensitive , so no OAuth connection can hold it. No other scope grants it except full access ( * ), customers:read included; a key without it gets 403 insufficient_scopes . Parameters. Send exactly one of q or ids . q is 2–100 characters, matched as a case-insensitive prefix of the full name, the last name or the email, and answers up to limit customers (1–20, default 10) ordered by email. ids is up to 50 comma-separated customer ids; every id found is answered, in the order given, and an integer limit is ignored. Unknown ids are skipped. A search never lists an erased (anonymized) customer; ids still names one, with name: null . Anything else is 400 validation_error . Answer. Only customers of the calling key's own workspace, application and environment. name is the first and last name joined by a space, or null when there is neither. nextCursor is always null : the directory answers one page. Responses carry Cache-Control: no-store ; do not store the answer either. Rate limit. Its own budget, 300 calls a minute per API key (per installation for calls from your iframe session) and environment. Over it you get 429 rate_limit_exceeded . Management scopes for extensions The management scopes ( extensions:read , extensions:write , extensions:install ) control who can author and install extensions. These are Taxonomy B (human) scopes — they are not extensionAllowed . Extensions cannot manage other extensions. Management scope summary extensions:read — view the extension catalog and installation list. Required for GET /api/v1/extensions , GET /api/v1/extensions/:id , GET /api/v1/extensions/:id/versions , GET /api/v1/installations , GET /api/v1/installations/:id/deliveries . extensions:write — author, version, and publish extensions. Required for POST /api/v1/extensions , PATCH /api/v1/extensions/:id , POST /api/v1/extensions/:id/versions , POST /api/v1/extensions/:id/versions/:vid/publish , POST /api/v1/extensions/:id/lifecycle (draft/private). extensions:install — install, configure, and manage installs. Required for POST /api/v1/extensions/:id/install , POST /api/v1/extensions/:id/uninstall , PATCH /api/v1/installations/:id/config , POST /api/v1/installations/:id/upgrade , POST /api/v1/installations/:id/launch-token , POST /api/v1/installations/:id/deliveries/:deliveryId/replay , POST /api/v1/installations/:id/test . The extensions:install scope is a custom action — it is independent of extensions:write and extensions:read . Granting only extensions:install to a key lets it manage installations without being able to author or list the catalog. Team RBAC for extension management Dashboard (Clerk) callers are additionally gated by RBAC permissions, not just scopes. The relevant RBAC keys are: application:extensions:read — all app roles ( admin | developer | finance | viewer ). application:extensions:write — app admin and developer . application:extensions:install — app admin and developer . application:extensions:admin — required to promote an extension to public or to delete a catalog entry. Only admin role. Workspace owners and workspace admins have implicit admin role on every application. See the Permissions reference for the full role-capability matrix. Extension-allowed scope catalog The following scopes may appear in an extension manifest. They are all extensionAllowed: true and sourced directly from the live scope registry — they update automatically when new scopes are added. extensionScopeGroups.map(( group, scopes ) => ( group scopes.map((def) => ( def.id — def.label"},{"title":"Scripts an Extension Ships","route":"/developers/extensions/scripts","headings":["Scripts an Extension Ships","The two tiers","Declaring a script","Shipping an update","Writing the script","Settings — one script, different merchants","Buyer data","Loading a vendor library","Cookies and storage","Review","Install lifecycle — when your scripts start and stop","Limits","then attach source to it."],"text":"Extensions Scripts an Extension Ships An extension can ship JavaScript that runs on the merchant's buyer-facing pages — hosted checkout, quote pages, and the merchant's own storefront. This is how a pixel, an analytics relay, a fraud signal or an announcement bar reaches real buyers without the merchant pasting code into a theme. Read this after your first private install . For the merchant's view of the same feature — and for a merchant writing their own script rather than installing an app — see Your Own Scripts . The two tiers Pick the tier when you declare the script. It decides where the code can run and how hard it is to get approved. sandbox — runs inside a null-origin sandboxed iframe with no access to the host page, its cookies or its storage. Allowed on every surface, including checkout. This is the default and what almost every app should use. dom — runs directly on the merchant's storefront page with full access to it. Restricted to the storefront surface, refused on checkout by the API, the manifest schema and a database constraint, and it requires a staff review of the source for every change. DOM-tier code is unsandboxed third-party JavaScript on every installed merchant's storefront. Nothing contains it, so it never auto-approves — a human reads the source. Use sandbox unless you genuinely need the page, and expect longer review turnaround when you do not. Declaring a script A script has two halves: a declaration , one per (extension, key) , carrying what the script is allowed to do; and source , which is per extension version. Source is attached to a draft version and frozen when you publish it. key — a stable lowercase handle. It identifies the script forever; merchants see it in their script inventory. surfaces — any of storefront , checkout , quote . consentCategory — essential , functional , analytics or marketing . The script does not mount at all until the buyer has granted that category. protectedData — none or hashed . See Buyer data . externalDomains — hostnames the script may reach. Enforced as a Content Security Policy, which matches hosts exactly : google-analytics.com does not cover www.google-analytics.com . One leading wildcard label is allowed — *.klaviyo.com — for a vendor SDK whose host set is the vendor's to change; it covers every subdomain and not the bare domain. A request the CSP refuses is reported as a blocked outcome with reason: csp , so it is diagnosable from the merchant's script health view. The list is frozen once a version containing the script is published. Shipping an update Pushing a key that already exists replaces its source on the target draft version — that is how you ship a new build. The response is 201 for a key that was new and 200 when an existing one was replaced. Capabilities freeze once the script has shipped. After any version containing the script is published, a push that changes tier , surfaces , consentCategory , protectedData or externalDomains is refused with 409 script_published_capabilities_frozen . Those fields live on the declaration and take effect immediately on every install already pinned to an approved version, so changing one would bypass review. Ship the new behaviour under a new key. Source-only pushes are always fine while the target version is a draft. Bug fixes do not need a new key. A published version is frozen. Attaching to one returns 409 extension_version_published — a sha256 in a merchant's inventory has to be the sha256 that actually ran. A new version inherits the previous version's scripts. Creating a version copies every non-deleted script forward byte-for-byte, so a version that changes nothing keeps shipping what you already shipped. Push only what changed. Deleting the declaration is how you stop shipping a script. A version that adds a script key the merchant does not already run requires them to re-consent, with acknowledgedNewScripts on the upgrade call. A version that drops one does not re-prompt: the merchant ends up running less, which needs no permission. Writing the script A sandbox-tier script runs inside a null-origin iframe. Four bindings exist at the top level: register , analytics , browser and settings . document , window , parent , top , opener , localStorage and sessionStorage are undefined . api.browser at the top level of your file throws api is not defined , and the merchant sees an error in their health panel with no other clue. Use the bare bindings at the top level, or api.* inside the register callback. Settings — one script, different merchants Every install can carry its own values for your script. A merchant sets them on the installed app's Scripts tab, and they arrive as settings in a sandbox script or as a data- attribute on a dom-tier tag. Missing settings are an empty object, never an error. Handle the empty case by doing nothing rather than throwing. The same tab lets a merchant switch one of your scripts off without uninstalling your app. Treat that as normal. They are handed to code running on a public page, and on the dom tier they are an attribute in the page's own DOM. Never ask a merchant to put a secret in them — use your install-time API key and your own backend for anything privileged. Buyer data What a script receives on checkout.completed depends entirely on the protectedData grade it declared. none — no buyer identifiers at all. The default, and correct for most scripts. hashed — emailSha256 and phoneSha256 , each a SHA-256 of the lowercased, trimmed value. This is the form GA4 and Meta's advanced matching want. A third grade, raw , used to be accepted and stored — and delivered exactly what none delivers. An app could declare it, pass review, and then receive no identifiers at all with nothing explaining why. It is now refused with a message pointing at hashed . If your integration genuinely needs plaintext, it needs a grade that does not exist yet, and that is a consent and data-protecti"},{"title":"Security Model","route":"/developers/extensions/security","headings":["Security Model","RS256 + JWKS","Iframe sandboxing and origin pinning","Short-lived tokens with auto-refresh","Scope ceilings","Webhook signature verification","Summary"],"text":"Extensions Security Model The extension security model is built on four pillars: RS256 asymmetric JWT signing with a public JWKS, iframe sandboxing and origin pinning via the bridge protocol, short-lived tokens with auto-refresh, and strict scope ceilings that block human and admin access. Apply these controls from the first private install. Before public submission, complete the testing preflight and the marketplace checklist . RS256 + JWKS Extension-session JWTs are signed with an RSA private key (RS256) held server-side. Throttle publishes the corresponding public key(s) at GET /.well-known/extension-jwks.json in standard JWK Set format. Any out-of-process verifier — your extension backend, a middleware layer, a Cloudflare Worker — can verify tokens without a Throttle-specific SDK or a symmetric shared secret. The protected header always carries alg: "RS256" and a kid . Reject tokens with any other algorithm. The issuer is always "throttle" . Reject tokens with a different issuer even if the signature verifies. The JWKS endpoint may publish multiple keys (key rotation). Your verification library selects the right key by kid automatically. Old keys remain in the set through their last token's TTL. The endpoint is unauthenticated — no API key required. Cache it at module init via createRemoteJWKSet . If token verification fails for any reason — expired, bad signature, wrong issuer, unknown kid , malformed structure — return null and reject the request. Never attempt to use a partially-validated payload. Iframe sandboxing and origin pinning The dashboard renders extension iframes with a restrictive sandbox attribute. All communication between the iframe and the dashboard host goes through the bridge's typed postMessage protocol — the iframe cannot access the parent's DOM or navigate the parent directly (those requests go through the bridge's navigate helper, which the host controls). Origin pinning works on both sides of the bridge: Iframe side — the targetOrigin option passed to createBridge() restricts outgoing postMessage calls to the dashboard origin. Set this to 'https://app.usethrottle.dev' in production. Host side — the bridge host validates that inbound messages come from iframe.contentWindow at the declared targetOrigin . Messages from any other window or origin are silently ignored. Without targetOrigin , the bridge falls back to document.referrer and ultimately to '*' . A wildcard target sends the session token — including the signed JWT — to all windows in the frame stack. Always set targetOrigin to the exact dashboard origin. Short-lived tokens with auto-refresh Extension-session JWTs have a 10-minute TTL . Short TTLs limit the exposure window if a token is intercepted. The bridge handles renewal automatically: When the current token is within 60 seconds of expiry, the bridge proactively sends a refresh message to the host. When a bridge.api.* call receives a 401 , the bridge requests a refresh and retries the call once. The host calls POST /api/v1/installations/:id/launch-token and sends a fresh session message with the new token. All bridge.api.* calls in flight during a refresh wait for the new token before proceeding. Launch tokens can only be minted for active Clerk (dashboard) sessions — API keys cannot mint them. This ensures the token always embeds a real human identity and can be revoked by ending the Clerk session. Scope ceilings Extensions operate under a two-level scope ceiling. First, only extensionAllowed scopes may appear in a manifest — human scopes (team, billing, email, admin) are blocked at registration time. Second, the issued JWT and API key are scoped exactly to the pinned version's declared scopes — even if the workspace's API key holds broader access, the extension never inherits it. See the Scopes reference for the full extensionAllowed catalog and the management scope details. Webhook signature verification Extension webhook deliveries are signed with HMAC-SHA256 using a per-install signing secret. The signature covers the delivery timestamp and the raw body, preventing replay attacks older than 5 minutes (configurable). The signing secret is unique per install and stable — reinstalling does not rotate it, so verification keeps working across an uninstall/reinstall cycle. Read the current value any time with GET /api/v1/installations/:id/webhook-secret . The verifyWebhook helper uses constant-time comparison to prevent timing attacks. Never verify signatures with string equality ( === ) — use the provided helper. Capture the raw body before any JSON parsing. Parsing and re-serializing changes whitespace and key ordering and will break verification. Each installation has its own signing secret. A compromised secret for one install does not affect other installs or other applications. The secret survives an uninstall and reinstall — the installation row is reused — so a merchant reinstalling your extension will not break signature verification. See the Events reference for the full verification workflow and event catalog. Summary RS256 + public JWKS — no shared secret needed to verify tokens; key rotation is transparent to verifiers. Iframe sandboxing — restricts iframe capabilities; no direct DOM access to the parent. Origin pinning — postMessage is locked to the dashboard origin on both sides of the bridge. 10-minute token TTL — limits exposure window; auto-refresh keeps the session alive without user interaction. extensionAllowed scope ceiling — human, admin, and management scopes are never grantable to extensions. Per-install secrets — API key and webhook signing secret are unique per install; a compromised install is isolated. HMAC-SHA256 with replay protection — deliveries older than 5 minutes are rejected; constant-time comparison prevents timing attacks."},{"title":"Extension Starter Repository","route":"/developers/extensions/starter-repository","headings":["Extension Starter Repository","Clone and run","What the starter includes","Extension surfaces","Safe integration foundations","Repository strategy","Required customization"],"text":"Extensions Extension Starter Repository The public starter will provide a production-shaped baseline for iframe, webhook, and hybrid extensions without depending on Throttle's private monorepo. The starter is published at github.com/Epic-Design-Labs/throttle-extension-starter . It implements the hybrid shape (embedded React iframe UI + a Cloudflare Worker backend) and is a template to copy, not a dependency. No private repository access is required. Clone and run The five-minute quickstart runs against a mocked bridge — no Throttle account or Cloudflare deploy needed. When you are ready to build your own integration, customize the template with pnpm run setup and follow the starter README through Test-mode registration, real webhook delivery, and Cloudflare deployment. What the starter includes Extension surfaces Iframe route and bridge handshake Backend identity-token verification Raw-body webhook signature verification Health endpoint and structured logging Safe integration foundations Install-aware persistence Idempotent event processing Environment-variable template Unit tests and deployment guidance Repository strategy Use one repository per provider integration by default. ShipStation, ERP, tax, analytics, and messaging connectors have independent credentials, data models, release schedules, incidents, and support ownership. A monorepo is reasonable only when one team deliberately shares their runtime and deployment lifecycle. The starter is a template, not a hosted runtime dependency. Your publisher workspace owns the catalog entry; your repository and infrastructure own the application code and provider synchronization. Required customization Declare the minimum scopes and event subscriptions, chosen from the scopes an extension may request . Define installation configuration without exposing provider secrets to browser JavaScript. Replace example handlers with provider-specific mapping and reconciliation. Add the provider's rate limits, retry policy, credential rotation, and support runbook. Validate clean installation and uninstall in Test mode. Pair the starter with the Build guide and use the security requirements as the implementation baseline."},{"title":"Testing Extensions","route":"/developers/extensions/testing","headings":["Testing Extensions","Testing ladder","Required scenarios","Delivery and recovery","Lifecycle and data","Marketplace preflight"],"text":"Extensions Testing Extensions Test mapping, delivery, isolation, recovery, installation, and removal before asking another workspace to trust your app. Testing ladder Unit tests — mapping, signature verification, idempotency, and conflict rules. Contract tests — current public Throttle request, response, and event shapes. Local HTTPS tests — iframe handshake and signed webhook delivery through a tunnel. Private install — full setup in a Test-mode application with realistic fixtures. Failure injection — timeouts, rate limits, expired credentials, retries, and provider outages. Production smoke test — a small, non-destructive live test after launch approval. Required scenarios Delivery and recovery Valid, missing, stale, and invalid signatures Duplicate and out-of-order events Timeout, 429, and 5xx retry behavior Poison event and dead-letter recovery Uninstall while work is queued Lifecycle and data Pagination and missing optional fields Version upgrade and new-scope consent Strict Test/Live separation Provider credential expiration Cancellation and return races for fulfillment apps Marketplace preflight All public endpoints use HTTPS and expose a useful health check. Scopes and event subscriptions are limited to demonstrated functionality. A clean install, configuration update, upgrade, replay, and uninstall all succeed. Logs and screenshots contain no credentials or customer-sensitive data. Support, privacy, and terms URLs are public and accurate. Use Test mode throughout development. Verify production behavior deliberately with live credentials only after the extension passes security and marketplace preflight. Review the Security Model before following the publishing procedure ."},{"title":"Extension Versioning","route":"/developers/extensions/versioning","headings":["Extension Versioning","Version lifecycle","Which versions can be pinned","Installs are pinned","Upgrading","Rollback","Versioning strategy","Response:","\"data\": {","\"id\": \"ver_new...\",","\"version\": \"1.1.0\",","\"status\": \"draft\",","\"scopes\": [\"orders:read\", \"customers:read\"],","\"notes\": \"...\",","\"createdAt\": \"...\"","}","}`;","Response: { data: { versions: [ { id, version, status, scopes, ... }, ... ] } }`;","If the new version requests scopes not in the current install:","HTTP 409","\"error\": {","\"code\": \"new_scopes_require_consent\",","\"newScopes\": [\"customers:read\"]","Retry with explicit consent:","→ { data: { id: \"inst_xxx\", versionId: \"ver_new...\", ... } }`;","\"Rollback\" and \"upgrade\" use the same endpoint — just target an earlier versionId.","No consent required if the older version's scopes are a subset of the current scopes."],"text":"Extensions Extension Versioning Extension versions are immutable snapshots of a manifest. Published versions cannot be changed. Installs are always pinned to a specific version, so rolling out a change requires publishing a new version and upgrading each install explicitly. After publishing an upgrade, follow the operations guide to monitor adoption, compatibility, and incidents. Version lifecycle A version starts as draft and becomes published when you publish it. Once published, the version record is frozen — its scopes , iframeUrl , webhookUrl , and eventSubscriptions never change. Which versions can be pinned An install that names a versionId and an upgrade or rollback that names a targetVersionId follow the same rule: The version must belong to the extension. Any other id, including a version of a different extension, returns 404 not_found . A draft version is never pinned in production, which returns 422 draft_not_installable_in_live . Outside production, only the workspace that owns the extension may pin a draft. The workspace that owns the extension may pin any of its other versions, including published versions still awaiting marketplace review and older versions. Any other workspace may pin only the version a fresh install would get: the approved version of a public extension, or its latest published version if it has never been approved. Anything else returns 422 version_not_approved . An install with no versionId pins that same default version. Installs are pinned When an extension is installed, the install row records the exact versionId . Publishing a new version has no effect on existing installs — they continue running on the pinned version indefinitely. This provides stability: a breaking manifest change cannot affect installs that have not opted in to the upgrade. The dashboard may surface an "upgrade available" indicator when a newer published version exists for an installed extension. The installer decides when and whether to upgrade. Upgrading Use POST /api/v1/installations/:id/upgrade to move an install to a different published version. The install's API key is updated to reflect the new version's scopes; the config is preserved. Only an active installation can be upgraded or rolled back. A suspended or uninstalled installation returns 409 installation_not_active and is left unchanged. If the new version's scopes are a subset of the current version's scopes (scope reduction), no consent is required and the upgrade proceeds immediately. If the new version requests additional scopes, the endpoint returns 409 new_scopes_require_consent with the delta. Retry with acknowledgedNewScopes: true to confirm. The target must be a version this workspace may pin (see Which versions can be pinned ). Installers of another workspace's public extension can move to a new version once it is approved. The consent requirement protects against a malicious extension publisher silently acquiring new scopes. The installer must explicitly acknowledge each new scope — the 409 lists exactly which scopes are being added so the operator can make an informed decision. Rollback Rolling back to an older version uses the same upgrade endpoint — just target an earlier versionId . Because older versions have fewer or equal scopes, rollback typically does not require consent. After rollback, the install runs the older version's manifest (including its iframeUrl and event subscriptions). Rollback follows the same pinning rule as an upgrade: the owning workspace can roll back to any of its versions, while installers of another workspace's public extension can only target its approved version. The installation's config object is not changed by an upgrade or rollback. If the new version requires different config keys, update config separately via PATCH /api/v1/installations/:id/config . Versioning strategy Use semantic versioning (e.g. 1.0.0 ) — it is stored as a string, not parsed. Additive changes (new UI features within the same scopes, bug fixes, updated iframeUrl ) can be shipped as a new version without requiring install consent. New scopes always require explicit upgrader consent. Removing scopes does not require consent — it is a reduction. Keep at least the previous published version available to allow rollback without republishing. See also: Security model for how scope ceilings are enforced at the version level, and Installing extensions for the full install lifecycle."},{"title":"Idempotency-Key","route":"/developers/idempotency","headings":["Idempotency-Key","When to use it","Usage","How replay works","Scope","first response byte-for-byte. The header below tells you it's a replay."],"text":"Reliability Idempotency-Key Make every mutating Throttle request safe to retry by attaching an Idempotency-Key header. Throttle will replay the original response if you retry within 24 hours, even if the network blew up midway through the first attempt. When to use it Always, on every POST , PATCH , and DELETE from a server you can't fully trust the network between (i.e. all of them). It costs nothing when the request succeeds first try, and saves you from double- charging, double-creating, or double-cancelling on retry. Generate a fresh key per logical operation. A UUID v4 is the canonical choice. Reuse the same key when retrying the same operation (timeout, 5xx, connection reset). Do not reuse a key for a different operation. Throttle will reject the second call with 409 idempotency_key_reused . Usage How replay works Throttle hashes the canonicalised request body (SHA-256, key-sorted) alongside the key. On the second arrival within 24 hours: Same key + same body → original response replayed byte-for-byte with an extra Idempotency-Replay: true response header. The handler does NOT run again. Same key + different body → 409 idempotency_key_reused . Surfaces the integrator bug instead of double-charging. Scope Per workspace, per (method, path). The same key can be reused across endpoints without colliding — Throttle scopes the cache by tuple (workspaceId, key, method, path) . The path is the concrete request path with its ids filled in (the query string is ignored), so the same key sent to /subscriptions/A/pause and /subscriptions/B/pause is two separate operations. TTL: 24 hours. After 24h, the same key can be used again without replay. GET / HEAD requests ignore the header. They're already idempotent. 5xx responses are not cached. A transient server error doesn't pin a failure response to your key. The whole point is that the SAME key is reused. Generating a new UUID on every retry defeats the cache and risks double-charging. Persist the key in your job queue / outbox alongside the request payload."},{"title":"Bulk imports","route":"/developers/imports","headings":["Bulk imports","Before you start","1. Create and upload","2. Inspect, then map","3. Validate before you commit","What a committed order import writes","Runs are queued, so poll for the outcome","The error report round trip","Undo: what it removes, and what it does not","Full endpoint reference","201","that header, so a different one fails with SignatureDoesNotMatch.","PRE-run status. The run itself happens on a worker — poll for the outcome.","202 — queued. The outcome lands on the import row as undoResult."],"text":"Data migration Bulk imports Move a merchant's historical customers or orders into Throttle from a file their old platform exported. Four steps: upload, map, dry run , commit. The dry run is not optional — a commit rewrites order and customer history, so Throttle refuses to run one until you have been shown what it would do. Before you start CSV and TSV up to 500 MB and 500,000 rows. The extension on fileName selects the parser, so it has to be accurate — it is chosen before a single byte is uploaded. Excel up to 10 MB and 65,536 rows ( .xlsx , .xlsm ; .xls up to 25 MB). The lower ceiling is not arbitrary: a .xlsx is a ZIP whose directory sits at the END of the file, so a sheet cannot be decoded until the workbook, shared-strings and styles entries have all been read — it is parsed whole rather than streamed a row at a time. 65,536 is the .xls format's own ceiling, reused for both so there is one number rather than two. Past it, a sheet is refused with a message naming the cap; save it as CSV, which handles 500,000 rows. Spreadsheet dates cannot be misread. A date cell is a number plus a format, not text, so it is imported as ISO-8601 and the dateOrder choice cannot move it. That ambiguity is real only for CSV, where 03/04/2024 is two different days. A workbook with several sheets reads the first by default. POST /api/v1/imports/ id /inspect returns every sheet in sheets , and re-POSTing with sheetName : Orders reads that one instead. Changing the sheet re-saves the dialect, which retracts any dry run — the preview described a different grid. Naming a provider pre-fills the mapping. bigcommerce and shopify both ship export templates; omit it and the header-name matcher runs alone. The provider also selects the status vocabulary, which is not cosmetic: BigCommerce exports one status column that implies both position and payment, while Shopify splits it across Fulfillment Status and Financial Status and needs both read together — an order can be fulfilled but unpaid, or paid but unfulfilled. Shopify blanks its order columns on continuation rows. Where BigCommerce repeats the order-level values on every line of a multi-item order, Shopify writes them on the first line only. The Shopify template declares that shape, so the blanks are read as the format rather than as an inconsistent export. Its single Billing Name column is split into first and last on the last space. One entity per file. entityType is customer or order . Scopes. Reads need imports:read ; every verb that creates, maps, runs or undoes an import needs imports:write . Publishable ( pk_ ) keys cannot hold either, and extensions are not granted them. Imports are scoped to one workspace environment. An import belongs to the application and environment its credential resolves to, and reading one from a different environment is 403 environment_mismatch . POST /api/v1/imports hands back a presigned PUT valid for one hour and you upload straight to storage. That is why the size ceiling is checked twice: once against the fileBytes you declare (before the URL is issued) and again against the object's real size at inspect time, because a presigned PUT signs the key and the content type — never the body length. 1. Create and upload A PUT that never happened, or one that stored zero bytes, is caught at the next step as 409 file_not_uploaded rather than becoming an empty mapping screen. 2. Inspect, then map POST /api/v1/imports/ id /inspect reads at most the first 1 MB of the file however large it is, so it answers immediately. It returns the sniffed dialect, the headers, a 20-row sample, the platform template it matched, a suggested target field per header, and detectedDateOrder . Those are suggestions — you confirm them. A column you leave out of columns is ignored. That is how you skip one. Two columns mapped to one field is rejected , with both headers named. The mapper takes the first match and would discard the second silently, which is data loss, not a harmless duplicate. The one exception is customer.metadata — see below. A customer may carry up to five addresses. The first group s targets are customer.address.* ; the rest are customer.address2.* through customer.address5.* , with the same ten suffixes ( firstName , lastName , company , addressLine1 , addressLine2 , city , stateProvince , postalCode , countryCode , phone ). A group whose columns are all blank for a row produces no address for that row. The first address becomes the customer s default only if they had none; a customer who already has a default keeps it. A header s trailing group index is parsed, not matched. Address City - 2 , Address City #2 and Address City (2) all suggest customer.address2.city . An index above 5 is left unmapped rather than folded into an existing group. A digit that is part of the field s own name — Address Line 1 — is untouched. An order s products may arrive in ONE column. BigCommerce s control-panel order export gives an order a single Product Details cell holding every product, pipe-separated between products and Key: value within — so there are no per-line-item columns to map. Point that column at order.lineItems.packed and each product becomes its own line item. The keys read are Product ID , Product Qty , Product SKU , Product Name , Product Variation Details (kept as the line item s description) and Product Unit Price . Commas and colons inside a value are preserved. When this target is mapped it replaces the per-line order.lineItem.* reading for that row rather than adding to it. order.coupons.packed reads a packed coupon cell the same way, summing each Coupon Value into the order s discount. It is read only when no order.discountTotal column is mapped, so an explicit discount column still wins. Pick the tax basis deliberately when a file offers both. Exports that ship (inc tax) and (ex tax) variants of the same figure are not auto-mapped in either direction — mapping both would point two columns at one field. An order s t"},{"title":"Customer Invoices","route":"/developers/invoices","headings":["Customer Invoices","One list, three payment types","Auth and trust model","Pagination","Per-application branding","Refund behavior","Net-N invoice status","Amount due","Deposit receipts","Invoice numbers","When tax is filed on invoice terms","REST routes quick reference","Pages in this section","Merchant API: list invoices for a specific customer","Redirect directly to PDF (default)"],"text":"Finance Customer Invoices Every payment Throttle processes — a one-time card capture, a Net-N issuance, or a recurring subscription charge — lands in one unified, cursor-paginated invoice list. Your buyers see a single consistent view regardless of how they originally paid. Customers never hold an API key. Your backend mounts the @usethrottle/invoices/server proxy which resolves the authenticated buyer's identity and forwards only their own invoices. See React Package for the setup. One list, three payment types Throttle invoices are payment-source agnostic. The same GET /api/v1/invoices endpoint returns all three payment types in a single paginated response, ordered by creation time (newest first by default). Single-time card captures ( sourceType: "order" ) — generated when a buyer completes a one-time checkout session with a card. The invoice is marked paid immediately on capture. Net-N issuances ( sourceType: "net30" ) — issued at checkout time with a future dueAt timestamp (N days out). Status starts as open and transitions to paid once the merchant marks it paid or a payment is received. Subscription charges ( sourceType: "subscription" ) — generated on the initial subscribe and on every renewal. The invoice carries periodStart and periodEnd so buyers can see exactly which billing period each charge covers. A checkout that sells several subscriptions, or subscriptions and goods together, produces one order invoice ( sourceType: "order" ) listing every line, each subscription line with its period label (for example "Oct 1 – Nov 1, 2026", or "Oct 1 – Nov 1, 2026 · Payment 1 of 3" on an installment plan), plus shipping and tax; its total equals the capture. No separate first-period subscription invoice is issued for it. Renewals invoice as subscription charges. A single-plan checkout keeps its subscription invoice. Use the type query parameter to filter by source type if your UI needs to show a subset. Auth and trust model The Throttle invoice API is a merchant-facing API: every call requires an sk_* secret key. Buyers cannot authenticate directly. The recommended pattern is a thin backend proxy: Your backend authenticates the buyer (Clerk, NextAuth, session cookie, etc.) and resolves their internal user id. The proxy calls GET /api/v1/customers/by-external/:externalId with your secret key to resolve the Throttle customerId . All reads are forwarded as GET /api/v1/customers/:customerId/invoices so the buyer only ever sees their own invoices. The @usethrottle/invoices/server package ships this proxy handler ready to mount in any framework that accepts a standard Request / Response interface (Next.js App Router, Hono, Remix, etc.). Pagination Invoice list endpoints return a cursor-paginated envelope. Pass the returned meta.pagination.cursor as the cursor query parameter to fetch the next page. When meta.pagination.hasMore is false there are no further pages. Per-application branding Every invoice PDF is rendered with the application's configured logo and brand color. Set these on your application via the dashboard Settings → Branding panel or via PUT /api/v1/embed-config . The PDF renderer reads logoUrl and brandColor from application_env_settings at render time, so changes apply to invoices downloaded after the update (not retroactively). Refund behavior When a payment is refunded via the dashboard or API, the corresponding invoice status transitions automatically: Full refund — status becomes refunded ; the amountRefunded field reflects the full invoice total. Partial refund — status becomes partially_refunded ; the amountRefunded field shows the refunded portion. The re-rendered PDF shows the refunded amount and updated status. Buyers who download after a refund automatically see the current state. Net-N invoice status An invoice raised on Net-N terms ( sourceType: net30 ) starts open and follows the payment behind it: Paid — when the payment is captured (the merchant marks the invoice paid), status becomes paid . Cancelled — when the payment is voided (the invoice is cancelled, the order is cancelled or comped, or an edit leaves it owing nothing), status becomes void . The PDF re-renders on the next download, so it says Paid or Void instead of Due. Amount due A Net-N invoice's total is what the buyer owes on that invoice — the amount of the Net-N payment behind it — not the order total. They differ when part of the order was settled another way, such as a deposit taken by card at checkout with the balance on Net-N terms. The invoice still lists every line, and subtotal , taxTotal , shippingTotal and discountTotal are the order's. When the invoice bills less than the order, billingSnapshot.totals also carries orderTotal and the deductions that close the gap — amountPaid (already paid, e.g. the deposit) and compWaived (waived by a comp) — and the PDF prints them under the order total, with the band reading Amount due . Deposit receipts The card receipt for a deposit ( sourceType: order ) on a deposit + Net-N balance order has total equal to the deposit that was paid, not the order total. The lines and the order's money rows stay itemised; billingSnapshot.totals carries orderTotal and balanceDue (the balance billed on the Net-N invoice), and the PDF prints them under the order total with the band reading Amount paid . Every other receipt is unchanged. Invoice numbers A Net-N invoice has one number: the invoice's number , printed on its PDF and used as the PDF's filename. The Net-N payment carries the same value in metadata.invoiceNumber , so the invoice emails, the dashboard, receivables and the invoice.past_due webhook all show it. It is formed from your Net-N invoice prefix (default INV ), the application slug and the application's invoice sequence, e.g. INV-acme-00042 . Invoices issued before 2026-10-04 were emailed under a separate payment num"},{"title":"React Package · @usethrottle/invoices","route":"/developers/invoices/react-package","headings":["{invoice.number}","React Package · @usethrottle/invoices","Install","Provider","Hooks","useInvoices(params?, options?)","useInvoice(id, options?)","useDownloadInvoice()","Cursor pagination","Cache invalidation","REST routes","Invoice object fields","Boundaries"],"text":"Customer Invoices React Package · @usethrottle/invoices React hooks and a server proxy helper for surfacing a buyer's unified invoice list — card captures, Net-N issuances, and subscription charges — inside your storefront portal. Browser hooks never call Throttle directly. They call your backend proxy, which holds the sk_* key and resolves the authenticated buyer's identity before forwarding the read. Install Backend proxy · /server entrypoint Mount createInvoiceProxyHandler from @usethrottle/invoices/server on a catch-all route. The handler accepts any framework that exposes a standard Request object (Next.js App Router, Hono, Remix, etc.). createInvoiceProxyHandler(options) apiKey: string — your sk_* secret key. getCustomerId(request): string | null | Promise<string | null> — called on every request. Return the buyer's external id (e.g. your auth framework's user id). Returning null causes the proxy to reply 401 unauthorized . baseUrl?: string — defaults to https://api.usethrottle.dev . The proxy allows three read-only paths: /api/v1/invoices (list), /api/v1/invoices/:id (get), and /api/v1/invoices/:id/download (PDF URL). All other paths and non-GET methods return an error. List requests are transparently rewritten to /api/v1/customers/:customerId/invoices so the buyer can only read their own invoices. Provider Mount InvoiceProvider above any component tree that uses invoice hooks. The fetcher prop is the bridge to your backend proxy — every hook routes through it. Hooks useInvoices(params?, options?) Lists invoices for the authenticated buyer. Returns a QueryState<PaginatedInvoices> with data, isLoading, isFetching, error, refetch . params : status? ( paid | open | refunded | partially_refunded | void ), type? ( order | net30 | subscription ), cursor? , limit? , sort? ( asc | desc ). Note: The SDK normalizes the REST meta.pagination envelope into a top-level pagination on the hook result — so with the hooks you read result.data.pagination.cursor / .hasMore , whereas raw REST responses carry it under meta.pagination . useInvoice(id, options?) Fetches a single invoice by id. Skips the fetch when id is null or undefined . useDownloadInvoice() Returns an async function (id: string) => Promise<string> that fetches the signed PDF URL and opens it in a new tab. Returns the raw URL so your UI can also use it as an anchor href . To capture the returned URL for custom usage (e.g., render as an anchor href): Cursor pagination Responses carry a pagination object inside data.pagination . When hasMore is true, pass the opaque cursor string in the next useInvoices call. Cache invalidation All hooks share a cache version. Call useInvoiceInvalidation() to bump the version and cause every mounted hook to re-fetch. Use this after a refund or a payment action that changes invoice state. REST routes The following merchant-facing routes require an invoices:read API key scope. GET /api/v1/invoices — list invoices for the application (all customers, all source types). GET /api/v1/invoices/:id — fetch a single invoice. GET /api/v1/invoices/:id/download — 302 redirect to a 10-minute signed PDF URL; append ?format=json to get data : url : ... instead. GET /api/v1/customers/:customerId/invoices — list invoices scoped to a specific customer (used internally by the proxy). All list endpoints accept cursor , limit , sort (asc | desc), status , and type query parameters. Invoice object fields id — Throttle invoice id. number — human-readable invoice number (e.g. INV-sprinter-001 ). status — paid | open | refunded | partially_refunded | void . sourceType — order | net30 | subscription . currency — ISO-4217 currency code. subtotal, taxTotal, shippingTotal, discountTotal, total — integer minor units. amountRefunded — integer minor units refunded (0 when not refunded). issuedAt — ISO-8601 invoice date. dueAt — ISO-8601 due date for Net-N invoices; null for card captures and subscriptions. periodStart, periodEnd — billing period for subscription invoices; null otherwise. createdAt — ISO-8601 record creation timestamp. Boundaries The package is read-only — it surfaces invoices and PDFs but does not create or mutate payment records. Payment collection stays in @usethrottle/checkout-react . This package renders the resulting invoices. Merchant-wide views (all customers) are available via the REST routes with a secret key; the React hooks are scoped to the authenticated buyer's invoices through the proxy."},{"title":"MCP server","route":"/developers/mcp","headings":["MCP server","Install","Hosted / remote server","What it can do","Catching field-name mistakes before they 400","Writes","Options"],"text":"Tools MCP server @usethrottle/mcp connects Throttle to AI assistants that speak the Model Context Protocol — Claude Code, Claude Desktop, Cursor, and anything else with an MCP client. It reads your commerce data, diagnoses failing checkouts, searches these docs, and checks request bodies against the published contract before you write code against it. Install The server runs locally over stdio and authenticates with a secret sk_ API key, exactly like the CLI. For Claude Desktop or any client that reads an mcpServers config: The key pins the workspace, application and environment — there is no way to switch from inside a tool call. Create a key that holds only the scopes the assistant needs, and start with a non-production environment. Publishable ( pk_ ) keys are rejected: they carry only browser-safe compute scopes and cannot read data. Hosted / remote server For clients that speak MCP over HTTP instead of stdio — n8n's MCP Client Tool node, for example — connect directly to https://mcp.usethrottle.dev/mcp (Streamable HTTP) instead of running @usethrottle/mcp locally. Send the same secret key as an Authorization: Bearer sk_... header — no separate provisioning, no OAuth. The tool surface and scopes are the same as the local server's read-only toolset, with two differences besides transport: the hosted server is strictly read-only, so seed_test_data is not registered there even on a non-production credential (seed from the local CLI instead), and api_get is not registered there either, because a tool that can read any path cannot declare in advance which scopes it uses. A session is created on your first call and reused for the rest of the connection; it is torn down after 30 minutes of inactivity or an explicit client disconnect. Claude's custom-connector static_headers auth type (Bearer token, no OAuth) works with this endpoint but is currently rolled out selectively by Anthropic. ChatGPT's Developer Mode does not support API-key auth for custom connectors at all — only OAuth or no auth — so it cannot use this endpoint yet. As of this change, the server also supports real OAuth 2.1 ( authorization_code + PKCE + refresh) for both platforms' native connector/directory flows, once each platform's OAuth client is registered. This is in addition to, not instead of — the static_headers path above still works too. customers:read and payments:read are OAuth-grantable, so search_customers , get_customer , search_payments and get_payment register on hosted/OAuth sessions too, not only on sk_ API keys. An OAuth connection never sees a shopper's personal data, because an OAuth grant cannot hold customer_pii:read . On every response to an OAuth token, email, phone and first/last name are withheld wherever they appear (orders, subscriptions, carts, invoices, quotes, events, webhook deliveries, the audit log): null in REST responses, and absent from MCP tool results, which drop null fields. Company and the merchant's external ids ( externalId , externalCustomerId ) are withheld too, since a merchant may key customers by email or name. REST addresses keep city, state, postal code and country and lose the street lines. Free-form fields are withheld whole, because a name or email can sit anywhere inside them: metadata , notes, hold and comp reasons, quote comment bodies, acceptance records, client context (IP address, user agent), tax ids and a payment's raw processorResponse . File downloads and exports (orders CSV, invoice PDFs, exemption certificates) return 403 oauth_file_download_forbidden . Nothing can be looked up by a withheld value either: GET /customers/by-external/:externalId and the externalCustomerId filter on subscriptions return 403 oauth_external_id_forbidden , a customer text search answers with no matches ( search_customers refuses q and lists by date instead), and search_orders matches only order ids, numbers and statuses. On top of that, each tool on an OAuth session returns a fixed list of fields chosen for its purpose, so a field the API adds later does not reach the assistant until it is added to that list. A field a buyer can type is not on any list: line-item and quote-item names, SKUs and reference ids (a quote request form takes them from the buyer), company, and every address line but the country. For the same reason get_top_products , which ranks by item name or reference id, is not offered over OAuth. API keys and the dashboard are unaffected. On every credential, API key or OAuth, a tool result never includes the payment processor's raw record: a payment's or transaction's processorResponse , or a saved card's metadata.gr4vy_payment_method . This covers get_payment , search_payments , get_order and api_get . The REST API still returns processorResponse on payments and payment transactions, but the field is deprecated: read the payment's own fields ( status , amount , capturedAmount , cardBrand , cardLastFour , errorCode ) instead. Those lists only hold if the token cannot go around them. An OAuth access token is issued for the MCP server (its aud is https://mcp.usethrottle.dev/mcp ) and works only there: sent straight to the REST API, it is refused with 401 oauth_token_wrong_audience . To call REST yourself, use an API key. (Automation connectors such as Zapier are registered for the REST API instead; see scopes .) get_integration_status reports each webhook endpoint's id, events and health but not its URL, since a destination URL often carries a secret in its path. What it can do Tools are registered per session based on what your key is actually allowed to do, so a read-only key produces a read-only toolset rather than tools that fail when called. whoami — which workspace, application and environment this key is pinned to, and whether it is production. search_docs — full-text search over these docs, with canonical links. describe_endpoint and validate_payload — the request shape of any endpoint, a"},{"title":"Metadata","route":"/developers/metadata","headings":["Metadata","Resources that accept metadata","Customers","Subscriptions","Carts and line items","Fulfillment hints on line items","Tax keys","How the keys travel from a quote to an order","Shipping keys","Orders and payments","Checkout sessions","On checkout sessions","On webhooks","Conventions","Dashboard exposure","Next","Matches the line link (lineItems[].subscriptionId), the order's subscriptionId,","or the legacy metadata.subscription_id pointer. Prefer it over q=$SUB_ID, which","substring-matches the whole metadata blob and can return unrelated orders.`;"],"text":"Cross-cutting Metadata Every major resource accepts a free-form metadata field — a JSON object you attach to capture your own business state. Throttle stores it as jsonb , returns it on every read, and emits it in webhook payloads. Outside documented automation keys, Throttle treats it as opaque storage. Metadata comes back out unchanged. A small set of documented line-item keys also drives fulfillment automation, such as fulfillmentType for digital delivery or access grants. Use the rest to bridge Throttle's data model with your own — sales rep IDs, campaign codes, contract numbers, feature flags, merchant notes. Resources that accept metadata Update support is missing on payments and checkout sessions because those are short-lived single-use records — payments capture once, sessions expire in 30 minutes. If you need to mutate metadata on them, do it at create time. Customers Subscriptions Carts and line items Fulfillment hints on line items When an order is paid and in processing , Throttle reads line-item fulfillment hints. Digital and access-grant items are fulfilled automatically; shipment and service items stay manual in the dashboard. Net30 invoices wait for capture before delivery. Cart items and order line items accept an explicit deliveryMethod field: shipment , digital , access_grant , service , in_person , custom , or none . This is the preferred way to say what an item needs — set it directly and skip the metadata hints below entirely. Omit deliveryMethod and Throttle resolves it once, at insert time, from type and the metadata hints below — then freezes the result on the row. It is not re-resolved on later updates, so changing an item s metadata after creation does not change its delivery method. Every order read returns the resolved value on each line item, so you never have to re-derive it: an order whose items are all digital , access_grant or none has nothing to ship and reaches fulfilled without a fulfillment of your making. Defaults are conservative: unmarked products are shipments, subscriptions and tickets are access grants, services stay manual, and donations are not applicable. Metadata never makes a subscription line ship: only an explicit deliveryMethod: shipment does, which makes a physical subscription. For older integrations, requiresShipping: false is treated as digital unless requiresFulfillment: false is set. Tax keys A handful of metadata keys feed tax calculation and, with Avalara connected, the document Throttle files. They have no typed field of their own: the SDKs type metadata as Record<string, unknown> and the API does not validate them. A misspelled key or a value of the wrong type is silently ignored, and the line is taxed as if you had not sent it. Copy the names exactly. taxCategory means two different things. With the native engine it is matched against the categories on your tax rules: an exempt line is never taxed, and a value no rule lists — including a different case, such as Digital — is taxed only by a rule covering all categories, otherwise not at all. With Avalara it is sent as the line's AvaTax tax code. Native category names are never sent to Avalara, regardless of case: a line carrying standard or exempt falls back to the application's default item tax code. So exempt does not exempt a line under Avalara — send the AvaTax code for the treatment you mean. How codes resolve, and why an item in your AvaTax catalogue beats them, is in the Avalara AvaTax setup guide . taxIncluded must be a JSON boolean. The string "true" is ignored, and the line follows the application setting. transport is case-sensitive. A value outside the five above is never sent to AvaTax, because AvaTax rejects the whole transaction on one. Both the estimate and the filed document use the application default in its place, so a misspelling silently becomes the default. Set it on the cart or the checkout session, not only on the order: the estimate is calculated before the order exists and reads the cart with the session's metadata over it, and the order copies that same metadata for the filed document. The native engine ignores it. Use the camelCase key. Tax calculation and the filed document also accept tax_category and tax_included , but these are kept for older integrations; new code should send taxCategory and taxIncluded . The item identifier is referenceId , not sku AvaTax files each line under an item code, and Throttle sends the line's referenceId as that code. Carts and orders have no sku field: their line schemas are strict, so a top-level sku is rejected with a validation error. When a line has no referenceId , the estimate and the filed document both fall back to metadata.sku , but send referenceId : it is the field the order page shows and the one every integration reads. Quote lines are the exception: they take both sku and referenceId . How the keys travel from a quote to an order Quote → cart, on acceptance. Each line's metadata is copied whole, so taxCategory and taxIncluded come with it. The cart line's referenceId is the quote line's referenceId , or its sku when it has none. The quote's buyerReference lands on cart.metadata.buyerReference — a value given at acceptance wins. Of the quote's own metadata, only transport is carried; any other key, including purchaseOrderNo , stays on the quote. Cart → order. The order's metadata starts from the cart's (the merge order is under On checkout sessions, below), and each line keeps its metadata and referenceId . Estimate and filing. Tax is estimated from the cart and filed from the order. Because the order inherits the cart's keys, the buyer is charged on the same answers the document records. Avalara reads more per-order keys than these — shippingTaxCode and taxTrigger among them. They are covered in Overriding an application default on one order . Shipping keys Line-item metadata keys that size a shipment for a connected carr"},{"title":"Authorize and Capture","route":"/developers/orders/capture","headings":["Authorize and Capture","The routes","Partial capture","amount vs capturedAmount","Full capture","Authorizing","Closing an order","Webhooks","Error codes"],"text":"Orders Authorize and Capture Most Throttle checkouts authorize and capture in one step. Splitting them lets you hold funds at order time and take the money when you actually ship — and take less than you held if the final total came in lower. The routes The order-scoped and payment-scoped routes do the same underlying work. Use the order-scoped pair when you are holding an order id and want Throttle to keep the order status in step; use the payment-scoped pair when you are working from a payment. Partial capture Only POST /api/v1/payments/{id}/capture accepts an amount . Omit it and Throttle captures the full authorized amount; pass one and it captures exactly that, in minor units. Capturing less than you authorized finalizes the payment at the captured amount — the uncaptured remainder is released back to the buyer by the processor, and there is no second capture against that authorization. Decide the final figure before you call this. If you need to bill more later, create a new payment on the order. Over-capturing is rejected with 400 capture_amount_exceeds_authorized , and a zero or fractional amount with 400 invalid_capture_amount . Capturing a payment that is already captured is 409 payment_already_captured — a second capture moves no money, so a success there would be a lie. For network retries, send the original call again with an Idempotency-Key , which replays the first response. amount vs capturedAmount After a partial capture the payment keeps two figures, and they mean different things: amount — what was authorized . It does not shrink to match the capture. capturedAmount — what was actually taken, and what the buyer is charged. Anything that sums money — payouts, revenue reports, refund ceilings — must read capturedAmount . Reading amount overstates revenue on every partially captured order. Full capture The order-scoped route captures the whole authorization and keeps the order status aligned. The order must be in pending , processing , partially_fulfilled , or completed . POST /orders/{id}/capture and /authorize check the payment first and treat an already-captured or already-authorized payment as a no-op, so a duplicated webhook or a retried job is harmless. The payment-scoped routes do not. Calling POST /payments/{id}/capture on a payment that is already captured , or /authorize on one that is no longer pending , fails. If you are driving these from a queue or a webhook handler, prefer the order-scoped pair or send an Idempotency-Key . Authorizing POST /api/v1/orders/{id}/authorize requires the order to be pending — authorization is the first money step, so anything further along is a 400 invalid_order_state . On this route, re-authorizing an already-authorized payment is a no-op; on the payment-scoped route it is not (see above). Cancelling an order voids any open authorization, and any unpaid Net-N invoice ( authorized or pending ), automatically, so you do not need to release a hold or cancel the invoice by hand. See Order states . Closing an order POST /api/v1/orders/{id}/close moves a completed order to closed , the terminal archive state. It is the only transition out of completed , and it moves no money. Webhooks A successful capture emits payment.captured carrying both figures, so a consumer can tell a partial capture from a full one without re-fetching: A processor decline emits both payment.capture_failed and payment.failed . Handle whichever suits you, but expect both to arrive for a single failure. Error codes Full list and envelope shape on Error codes , and payment status meanings on Payment states ."},{"title":"Disputes and Chargebacks","route":"/developers/orders/disputes","headings":["Disputes and Chargebacks","Two ways a payment gets flagged","Flagging a payment","Clearing a dispute","How processor events are classified","Webhooks","Error codes"],"text":"Orders Disputes and Chargebacks A disputed payment is money you booked that is now contested — a card chargeback, or a B2B buyer refusing a Net-N invoice. Throttle surfaces both through the same three fields on the payment, written either by your own call or by the processor. If you are coming from a processor with a first-class Dispute resource, calibrate expectations: Throttle stores disputed , disputeReason , and disputeOpenedAt on the payment, plus disputeOutcome and disputeClosedAt once a card chargeback is decided. There is no evidence submission, no due date, and no dispute id on the payment. The fields exist so a contested payment stops looking like clean revenue in your reporting and your AR views — contest the chargeback in your processor s dashboard. Two ways a payment gets flagged You flag it. POST /api/v1/payments/{id}/dispute — mainly for Net-N receivables, where the dispute arrives as an email from a buyer rather than a processor event. The processor flags it. Inbound dispute.* and chargeback.* webhooks are matched to the payment by processor transaction id and write the same three fields. No configuration needed. Because both paths converge on the same columns, a consumer of payment.disputed does not need to care which one fired. Flagging a payment Scope payment_disputes:write . reason is required, 1–500 characters. disputeOpenedAt is stamped server-side. Flagging an already-flagged payment is a 409 already_disputed rather than a silent overwrite, so you cannot lose the original reason by retrying. Clearing a dispute Clearing sets disputed: false but deliberately leaves disputeReason and disputeOpenedAt in place, so the history of the dispute survives on the payment. It takes no body, and clearing a payment that was never disputed succeeds without emitting an event. How processor events are classified Inbound dispute events are classified from the event suffix and the target s status: disputed: true means a dispute is open right now — build your worklist from it. Once the processor decides a card chargeback, disputed goes back to false and disputeOutcome records won or lost , with disputeClosedAt . A lost chargeback rolls the order s paymentStatus up to charged_back . Net-N invoice disputes you clear by hand don t set an outcome. A resolution event for a payment that is not currently flagged is a no-op, and redeliveries of an open event will not re-fire payment.disputed — it is emitted only on the first transition into the disputed state. Webhooks payment.dispute_cleared carries only paymentId . payment.dispute_lost carries paymentId , reason , amount (minor units), currency , and closedAt , and fires once per loss. Subscribing to any of the three requires payment_disputes:read . Both are worth routing to a human — a chargeback is one of the few events where nobody finds out until a payout is short. See Webhooks . Error codes"},{"title":"Returns and Exchanges","route":"/developers/orders/returns","headings":["Returns and Exchanges","Lifecycle","Open a return","How refundAmount is computed","Transitions","The refund on completion","Exchanges","Webhooks","Error codes"],"text":"Orders Returns and Exchanges A return is an RMA against a subset of an order s line items. It moves through an explicit state machine, and the money only moves at the end — either as a refund on complete , or as the price difference on an exchange. Lifecycle Only requested , approved , received , and completed hold a returned quantity against the order. Rejecting or cancelling releases it, so the buyer can open a fresh return for the same line later. Open a return POST /api/v1/orders/{id}/returns — scope order_returns:write . Pass the line items and quantities coming back. reason (max 1000 chars) and restock are optional. Throttle computes refundAmount at creation time and returns it on the object, so you can show the buyer the exact figure before anyone approves anything. List an order s returns with GET /api/v1/orders/{id}/returns and fetch one with GET /api/v1/returns/{id} — both scope order_returns:read . How refundAmount is computed Throttle refunds what the buyer actually paid for those units, not the list price. Per line that is: the line subtotal, plus the tax charged on it, minus any discount applied directly to that line, minus that line s proportional share of any order-level discount. The result is prorated across the returned quantity and telescopes exactly across repeated partial returns — returning 1 unit three times refunds the same total as returning 3 units at once, with no rounding drift. Transitions POST /api/v1/returns/{id}/transition with an action . Anything not listed here is a 409 invalid_return_state . The refund on completion Completing a return resolves the order s payment in captured or partially_refunded status and refunds refundAmount against it, stamping refundPaymentId on the return. The refund carries the reason return:{returnId} . If the order has no captured payment — an unpaid Net-N invoice, say, or an order captured outside Throttle — the return still moves to completed and no refund is issued. The server logs a warning, but the API returns 200. If you rely on a return implying money moved, assert on refundPaymentId being non-null rather than on the status. If the processor declines the refund, the transition fails with 402 refund_failed and the return stays in received , so you can retry. Exchanges POST /api/v1/returns/{id}/exchange swaps the returned goods for replacements instead of refunding. The return must be in approved or received ; it is an alternative route to completed that settles a difference rather than issuing the standard refund. Throttle then: creates a replacement order carrying metadata.exchange_for_return pointing back at the return, computes delta = replacementTotal − refundAmount , charges the customer s stored card when delta > 0 , or refunds the difference when it is negative, marks the return completed and stamps the replacement order id. Replacements are charged at list price — this path has no address-aware tax quote yet. Because delta compares against the return s paid value (which does include tax), an even-swap exchange produces a small refund roughly equal to the returned line s tax. The difference favours the buyer, never the merchant, but budget for it if you run high exchange volume. A delta > 0 needs someone to bill: if the original order has no customer attached you get 409 no_customer , and a declined card is 402 payment_failed . In both cases no replacement order is left half-settled. Webhooks Opening a return emits return.created . Every transition and every exchange emits return.updated , carrying status and previousStatus so you can react to a specific edge rather than polling. Subscribing requires order_returns:read . See Webhooks . Error codes Full list and envelope shape on Error codes ."},{"title":"Pick the Smallest Package That Does the Job","route":"/developers/packages","headings":["Pick the Smallest Package That Does the Job","Package Boundary"],"text":"SDKs and CLI Pick the Smallest Package That Does the Job Throttle publishes focused packages for checkout embeds, native cart orchestration, discounts, generated REST calls, and command-line setup. Each package card calls out where it is safe to run and whether it is production-ready today. Server SDKs do not take a separate environment option for normal API calls. Generate separate keys for Production, Test, UAT, or any custom non-production environment; the raw key is pinned to exactly one workspace environment. Every non-production environment routes to sandbox provider credentials. Clerk-authenticated dashboard calls that are not using an API key send X-Throttle-Environment-Id instead. packages.map((item) => ( )) Package Boundary For public integrations, use the @usethrottle/* packages above. If a package is not listed here, it is not public API."},{"title":"Upgrading api-client from 2.x to 4.x","route":"/developers/packages/api-client-2-to-4","headings":["Upgrading api-client from 2.x to 4.x","Breaking changes that stop compiling","Breaking changes that still compile","order.completed became order.fulfilled","What you gain","Upgrade checklist"],"text":"Packages Upgrading api-client from 2.x to 4.x Two major versions shipped a day apart in August 2026: 3.0.0 (one order status instead of two) and 4.0.0 (payments that are still in flight). This page lists every breaking change between 2.18.0 and 4.3.0 , taken from a diff of the two published packages. Throttle serves one API version. Staying on 2.x does not keep the old behaviour — it keeps types that describe it. Three things are already true for a 2.x client today: postApiV1OrdersComplete returns 404, an order is never completed (it is fulfilled ), and order responses carry no fulfillmentStatus . Everything else in 2.x keeps working. Breaking changes that stop compiling OrdersService.postApiV1OrdersComplete is gone. The endpoint ( POST /orders/ id /complete ) was removed with the status it set. Use postApiV1OrdersStatus , which moves an order to any lifecycle status. Order status : completed became fulfilled . The full set is now draft , pending , processing , partially_fulfilled , fulfilled , cancelled , closed . Stored orders were migrated; you will not receive completed from any client version. fulfillmentStatus was removed from order responses and filters. Delivery progress is in status itself, with per-item counts in fulfillmentSummary . See Order states . Status unions grew. Order paymentStatus gained processing , disputed and expired ; payment status gained processing and expired . An exhaustive switch over either stops type-checking until it handles them. The delivery-log eventType filter no longer accepts order.completed . See the event rename below. Before 4.0.0 a payment the processor had not yet decided — a 3DS challenge in progress, a redirect the buyer had not returned from, a bank debit still settling — was reported as failed . If your code retries or re-charges on failed , make sure it does nothing on processing . Breaking changes that still compile The generated client takes positional arguments. Two list methods gained filters in the middle of their argument lists, so an existing positional call shifts by one or two places. TypeScript catches the shift where the types differ and misses it where both sides are string . OrdersService.getApiV1Orders — delivery was inserted before source , and subscriptionId before createdAfter . A call that filtered by customerId now passes that id as the free-text search q , and its createdAfter as customerId . Neither is a type error. WebhooksOutboundService.getApiV1WebhookDeliveries — status and eventType were inserted before limit and offset . TypeScript rejects a number where status is expected; plain JavaScript does not. Every other method kept its argument order. Re-check these two by hand; a green build does not prove them. order.completed became order.fulfilled order.completed was renamed to order.fulfilled on 2026-08-22 and is no longer delivered. Stored webhook and extension subscriptions were rewritten to the new name. order.completed is still accepted in enabledEvents when you create or update an endpoint, in 4.x as in 2.x, and is dropped from the saved list. That is deliberate: it keeps a read-modify-write of an old subscription list from failing. It is not a sign the event still fires. order.fulfilled is not in the 2.x enabledEvents type, but the API accepts it from any client. On 2.x, cast the value; on 4.x it is in the union. order.fulfilled and fulfillment.completed are different events — the first is about the order, the second about one fulfillment. Subscribe to whichever your handler is really about. What you gain Orders: postApiV1OrdersStatus , postApiV1OrdersHold / postApiV1OrdersHoldRelease , postApiV1OrdersComp / postApiV1OrdersCompReverse , getApiV1OrdersTimeline , getApiV1OrdersStatusHistory ; a delivery filter ( in_transit , delivered , returned ) and a subscriptionId filter; the same filters on the CSV export. Payments: postApiV1OrdersPaymentsRecord (record a payment taken outside Throttle, including cheque ) and postApiV1PaymentsMarkFailed ; payment responses declare errorCode and errorMessage . Fulfillments: postApiV1FulfillmentsDelivered . Subscriptions: postApiV1SubscriptionsRetryCharge and postApiV1SubscriptionsWaivePeriod . Webhooks: order.fulfilled , order.held , order.hold_released , order.comped , order.comp_reversed , payment.processing , payment.expired , payment.recorded ; the delivery log filters by status and eventType . Customers carry company ; embed config carries checkoutLinkBaseUrl . Upgrade checklist Search for postApiV1OrdersComplete , 'completed' and fulfillmentStatus . These are already wrong on 2.x, so fix them first — before or without the upgrade. Install 4.x and fix what the compiler reports: the removed method, the removed field, and any exhaustive switch over a payment status. Find every call to getApiV1Orders and getApiV1WebhookDeliveries and re-count the arguments against the lists above. The compiler will not find all of these for you. Replace order.completed with order.fulfilled in the event list you send, and in the handler that switches on the delivered type . Make sure nothing treats a processing payment as a failure."},{"title":"Payment Methods","route":"/developers/payment-methods","headings":["Payment Methods","Two-step auth flow","Endpoint reference","Subscription self-service","The SavedCard shape","Error reference","Security notes","Cards must be attached through the embed","React package","Step 1 — backend: mint a client token","Step 2 — frontend: render the component"],"text":"Buyer wallet Payment Methods Let buyers view, manage, and add payment methods in your storefront without exposing your secret key to the browser. A short-lived customer-scoped token gates all buyer-facing card operations; your backend mints it, the browser uses it. @usethrottle/payment-methods wraps the flow below as a headless core, React hooks, and a pre-built component. See the React package section at the bottom of this page, or use the HTTP routes directly if you prefer. Only cards vaulted through the add-card embed — the flow on this page, which finishes at POST /api/v1/me/payment-methods/confirm — can be charged later for subscription renewals, plan changes, or any other merchant-initiated payment. That step is what records the processor buyer reference those charges require. Attaching a card directly with POST /api/v1/customers/ id /payment-methods stores it and lists it correctly, but it cannot establish that reference, so every later charge fails with 402 payment_failed: payment_method_not_chargeable . Use that endpoint only for record-keeping, never as a card wallet. Two-step auth flow The payment-methods API uses a client token pattern that mirrors how Throttle's embedded checkout protects your secret key. Your backend mints a short-lived token scoped to one customer; the browser uses that token for all card management calls. The secret key never leaves your server. Backend mints a client token. Call POST /api/v1/payment-methods/client-token with your sk_* secret key and the customer's Throttle customerId . Throttle returns a short-lived JWT. Browser calls the /me routes. All /api/v1/me/payment-methods* routes accept the token in the X-Throttle-PM-Token header. The token authorizes ONLY that one customer's payment methods — no cross-customer reads are possible. Endpoint reference POST /api/v1/payment-methods/client-token Mints a customer-scoped payment-method client token. Call this from your backend; never expose your secret key to the browser. Field Type Required Description customerId string Yes The Throttle customer ID ( cus_* ) for the authenticated buyer. subscriptionId string No Mint an update-card link for this subscription instead of a portal token. The token carries the subscription, lives 14 days instead of 30 minutes, and the response adds updateCardUrl — the hosted /billing/{token} page. Send it to the buyer yourself, or use it when the renewal-failed email is not enough. The subscription must belong to customerId ; otherwise 404 . Auth: X-API-Key (secret key, sk_* ). The key must have the customer_payment_methods:write scope. The returned token is a signed JWT. Pass it to the browser as a session value or a short-lived cookie; do not persist it long-term. The expiresAt timestamp (ISO 8601) tells the browser when to request a fresh token. GET /api/v1/me/payment-methods Returns the list of saved cards for the customer identified by the PM token. Auth: X-Throttle-PM-Token header (browser-safe) PATCH /api/v1/me/payment-methods/:id Sets a saved card as the default. Currently the only writable field is isDefault: true — you cannot unset a default directly; set another card as default instead. Auth: X-Throttle-PM-Token header Field Type Required Description isDefault boolean Yes Must be true . Promotes this card to the default; the previously-default card is demoted automatically. The customer's subscriptions that renewed on the previous default move to the new default, and each one emits subscription.updated . Subscriptions deliberately on another card stay where they are. The merchant set-default route behaves the same way. To move a single subscription, use PATCH /api/v1/me/subscriptions/:id/payment-method instead. DELETE /api/v1/me/payment-methods/:id Removes a saved card. Returns 200 with data: id , meta on success. Auth: X-Throttle-PM-Token header If the card being deleted is the default payment method and the customer has at least one active subscription, Throttle returns 409 cannot_remove_default . Prompt the buyer to set a different card as the default first, then retry the delete. Deleting a card that a subscription renews on, but that is not the default, is allowed: that subscription falls back to the customer's default card at its next charge. No subscription.updated is emitted for the change. POST /api/v1/me/payment-methods/embed-token Returns an add-only vault embed payload so the buyer can add a new card without going through a full checkout flow. The embed is configured to vault-only (zero-amount); it will not capture a charge. Auth: X-Throttle-PM-Token header The React component (see React package below) calls this endpoint and mounts the embed for you — you never handle the raw payload. Reach for the endpoint below only if you are building a fully custom embed; the provider-specific fields it returns are consumed by the widget, not by your code. Subscription self-service The same X-Throttle-PM-Token also reaches the customer's own subscriptions, so a buyer who has just saved a card can settle a past-due renewal without leaving the page. This is what Throttle's hosted “update your card” link uses; a portal you build can call it the same way. A token minted for a dunning link carries a subscriptionId claim naming the subscription it is about; a token minted through the client-token route does not, and can read any subscription the customer owns. Throttle's own hosted page for this flow is https://checkout.usethrottle.dev/billing/{token} : it shows the plan and the amount due, saves the card through the add-only embed, makes it the card for that subscription only (the customer's default card and their other subscriptions are left alone), and charges a past-due subscription while the buyer is still on the page. GET /api/v1/me/subscriptions/:id One of the authenticated customer's subscriptions, with the card it renews on. A subscription that belongs to another customer "},{"title":"Permissions Reference","route":"/developers/permissions","headings":["Permissions Reference","How permission checks work","Permission catalog","Permission scope notes","Workspace-only permissions","Read vs write split","Check permissions at runtime"],"text":"Workspace Permissions Reference Every dashboard action maps to a named permission. A permission is granted when the caller's workspace role or their application role on the target application satisfies the rule — whichever axis applies first wins. See Team management for how roles are assigned. This page describes role-based permissions (RBAC) — the rules that gate human users authenticating with a Clerk session in the dashboard. They are a separate system from API key scopes , which gate machine-to-machine calls made with an sk_ / pk_ key. A Clerk caller carries the wildcard scope and is gated purely by these permissions; an API-key caller is gated purely by its scopes and never by RBAC. The two systems compose; neither replaces the other. How permission checks work For each request Throttle resolves two values from the auth context: the caller's workspace-level role ( owner | workspace_admin | none) and their application-level role on the specific application being acted on ( admin | developer | finance | viewer | none). A permission is granted if either role set contains the caller. owner and workspace_admin satisfy most application-level permissions without needing an explicit per-application role. They act as supersets of any application role on any application in the workspace. Roles answer “can this person perform the action?” Environment grants answer “can this person use the selected workspace environment?” A caller must pass both checks. Requests outside the member's environment grant return 403 member_env_forbidden . The permission check is exposed at GET /api/v1/auth/permissions . The response lists every permission key and whether it resolves to true for the authenticated caller, optionally scoped to a specific application via the x-application-id header. Permission catalog The table below lists every permission string, which workspace roles satisfy it, and which application roles satisfy it. A dash (—) means that role axis is not checked for that permission. Permission Workspace roles Application roles workspace:delete owner — workspace:transfer owner — workspace:billing owner — workspace:invite-admin owner — workspace:settings owner, workspace_admin — workspace:invite owner, workspace_admin admin workspace:edit-member owner, workspace_admin — workspace:remove-member owner, workspace_admin — workspace:read-team owner, workspace_admin, member — workspace:audit-log owner, workspace_admin — application:settings owner, workspace_admin admin application:api-keys owner, workspace_admin admin, developer application:webhooks owner, workspace_admin admin, developer application:customers:write owner, workspace_admin admin, developer application:customers:read owner, workspace_admin admin, developer, finance, viewer application:orders:write owner, workspace_admin admin, developer application:orders:read owner, workspace_admin admin, developer, finance, viewer application:imports:write owner, workspace_admin admin, developer application:imports:read owner, workspace_admin admin, developer, finance, viewer application:quotes:write owner, workspace_admin admin, developer application:quotes:read owner, workspace_admin admin, developer, finance, viewer application:refunds:issue owner, workspace_admin admin, developer, finance application:payments:read owner, workspace_admin admin, developer, finance, viewer application:edit-app-member owner, workspace_admin admin application:extensions:read owner, workspace_admin admin, developer, finance, viewer application:extensions:write owner, workspace_admin admin, developer application:extensions:install owner, workspace_admin admin, developer application:extensions:admin owner, workspace_admin admin Permission scope notes Workspace-only permissions workspace:delete , workspace:transfer , workspace:billing , and workspace:invite-admin are exclusively for the owner. No application role satisfies these. workspace:settings covers workspace branding, sender domains, and workspace-scoped email settings. workspace:edit-member and workspace:remove-member require workspace_admin or above; no application role grants them. Read vs write split The finance role sees customers, orders, and payments but cannot write to customers or orders. It can issue refunds. The viewer role is fully read-only. It cannot issue refunds or perform any write action. The developer role is the same as finance plus write access to customers and orders, and access to API keys and webhooks. Check permissions at runtime Call GET /api/v1/auth/permissions with a Clerk JWT. Pass x-application-id to resolve application-level role flags for a specific application. The response shape is: workspaceRole : workspace_admin , appRole : developer , permissions : workspace:delete : false, workspace:invite : true, application:api-keys : true, application:customers:write : true, ... The dashboard uses this response to show or hide actions in the UI. Call it once on page load and cache client-side for the session duration."},{"title":"Accept Your First Payment","route":"/developers/quickstart","headings":["Accept Your First Payment","1. Configure your application","2. Create an API key","3. Create a checkout session","4. Render the React embed","5. Subscribe to webhooks"],"text":"Quickstart Accept Your First Payment This flow uses a server-created checkout session and the React payment embed. It is the fastest native Throttle integration path. 1. Configure your application Before any SDK call works, you need two values from the dashboard: the Application ID and at least one allowed origin . Both ship empty on a fresh signup, which is why following the snippets below verbatim returns applicationId is required or renders Embed not authorized . Sign in to the dashboard . A default application is created for your workspace on first login. If you have more than one, switch between them with the app switcher in the top bar. Open App Settings → General and copy the Application ID (the field has a Copy button; it also appears on the API keys page). Stash it as THROTTLE_APPLICATION_ID . Still in App Settings , add your storefront origin to Allowed origins (e.g. http://localhost:3000 in dev, https://shop.example.com in prod). Origins are scheme + host + optional port — no paths, no trailing slash. http://localhost and http://127.0.0.1 are accepted in every environment for local development. In a non-production environment you can also list one wildcard per entry for preview deployments, such as https://*.preview.example.com — see preview origins . Production takes exact origins only. Install the CLI with npm install -g @usethrottle/cli and set your allowlist without opening the dashboard: throttle embed-config set --origins http://localhost:3000 . It reads your secret key from $THROTTLE_API_KEY (or prompts for it); throttle embed-config show prints the current config. See the packages & SDKs page for everything the CLI and libraries expose. The embed enforces an origin allowlist server-side, and the cart/checkout endpoints reject requests without a valid applicationId . Both checks happen before any SDK error path — you'll see the failure as a 400 or an unauthorized embed instead of a hint that you need to set these. Every new application is automatically wired to a dedicated sandbox sub-account during signup, so PaymentEmbed renders (with the Card Simulator method) the first time you mint a checkout session in a non-production environment — no provider setup required. To accept real cards in production you still have to subscribe and add Throttle's production payment connection from Connectors ; details on Workspace environments . 2. Create an API key In the dashboard, open API keys (under Developers in the left nav), click Create key , and choose a secret ( sk_ ) key with the scopes you need. The raw key is shown once — copy it immediately; only the prefix is stored afterward. Keys are minted to one workspace environment, so the prefix carries that environment s segment — sk_live_* for production, or the environment s own slug otherwise, e.g. sk_test_* or sk_uat_* . Switch environments with the selector in the top bar before creating a key. Use a pk_ publishable key for browser-side shipping/tax quotes only; everything else uses the secret key from your backend. Never put an sk_ key in browser code, mobile apps, or public repositories. The browser should only receive a checkout session id or hosted URL from your backend. For everything beyond checkout — orders, customers, subscriptions, discounts, and webhooks — use @usethrottle/api-client instead of raw fetch . It's generated from the OpenAPI spec, so endpoints, event names, and request/response shapes are all typed (no guessing /webhooks vs /webhook-endpoints , or checkout.* vs cart.* event names). 3. Create a checkout session From your backend, create a native Throttle cart, add your line items, then mint a session for that cartId . This two-step native flow is the default — reach for externalCartId only when you’ve connected an external cart provider. The checkout SDK keeps your secret key server-side and returns sessionId , hostedUrl , and embedUrl . 4. Render the React embed Install the checkout SDK for your backend route and the React embed for your checkout page, then mount PaymentEmbed . 5. Subscribe to webhooks Browser callbacks are immediate UI signals. Webhooks are your durable source of truth for fulfillment, receipts, and order state. Read the webhooks guide to verify signatures and handle retries idempotently."},{"title":"Quotes","route":"/developers/quotes","headings":["Quotes","Status","Core concepts","Create and issue a quote","Request a quote from a cart","Add to Quote on your storefront","Hosted request forms","The buyer link","Subscription lines","Accept a quote","Which addresses the checkout asks for","Payment terms are enforced, not advisory","Errors","Emails, expiry, and PDF","Webhooks","The response includes quote.shareToken — the buyer link is","https://checkout.usethrottle.dev/q/{shareToken} (hosted page ships with the","public-link release).`;","The cart itself is unchanged.","No API key: the qrf_ form token is the authority, so this is safe from a browser.","Every line lands on the quote's working revision UNPRICED. targetUnitPrice is","recorded as the buyer's ask and shown to your rep; it never sets what you quote.`;","Response: { \"data\": { \"quote\": {...}, \"checkoutUrl\": \"https://checkout.usethrottle.dev/s/...\" } }","Redirect the buyer to checkoutUrl to complete payment (card or Net-N,","depending on the quote's payment terms).`;","Route quote email to a shared alias instead of the whole team","as it is) or sent as null (hand it back to the derived default).","The same field is accepted on POST /api/v1/quotes.`;"],"text":"Guides Quotes B2B sales quotes: a buyer requests pricing, a rep builds and issues a revisioned proposal, and acceptance converts directly into a Throttle order with card, Net-N, or deposit + balance payment terms. Status Build and issue quotes from the dashboard (Quotes in the app sidebar) or the API, and buyers view their quote at https://checkout.usethrottle.dev/q/{shareToken} — toggle optional lines, comment, request changes, decline, or accept. Accepting materializes a checkout session and converting it into a Throttle order happens automatically once payment succeeds. Lifecycle emails, expiry reminders + auto-expiration, a downloadable PDF, deposit-and-balance terms, and hosted RFQ intake forms are all live. Sales quotes are unrelated to shipping/tax quote tokens. Core concepts Quote — the negotiation record. Carries a Q-{seq} number, customer linkage, an optional buyer-supplied reference number ( buyerReference ), and one share token that will be the buyer's permanent link. Revision — an immutable commercial snapshot. Exactly one working revision is editable at a time; issuing freezes it ( issued ) and supersedes the previous offer. Line items support optional upsells and per-line quantity locks ( qtyEditable ). Payment terms — pay_in_full , net_terms (Net-1..365), or deposit_balance (deposit due at acceptance, the balance becomes a receivable). Deposit amounts are computed and frozen at issue time. Lifecycle — draft → requested → under_review → proposed → accepted → converted , with revision_requested , declined , expired (re-issuable), and archived branches. Transitions are service-enforced. A buyer declines an issued quote from the link; a merchant declines a request it will not price with PATCH /api/v1/quotes/ id status: declined , declinedReason , which emails the buyer the reason. Create and issue a quote Requires an API key with the quotes:write scope (dashboard roles map to application:quotes:* permissions). Prices are integers in minor units. Editing after issue is explicit: POST /api/v1/quotes/{id}/revisions clones the issued revision into a new working revision; PATCH /api/v1/quotes/{id}/revisions/{revId} edits it; issuing again supersedes the prior offer at the same buyer link. Request a quote from a cart Add to Quote on your storefront For buyers who want to browse and collect several products before asking, @usethrottle/quotes ships a quote cart: a persisted list the buyer builds while moving around your site, submitted as one request. It needs no API key — the qrf_ form token is the authority, so it is safe in the browser. Throttle stores no product catalog, so lines come from your own product data, the same way the shopping cart works. Quantities merge when the same SKU is added twice, and the cart survives page navigation. A buyer who already filled a normal cart can ask for a quote on it instead: pass cartId to POST /api/v1/quote-requests and its lines join the request. The cart is resolved inside the form's own application and environment, so an id from anywhere else is simply not found. Cart prices are recorded as the buyer's target, not as the quoted price — a cart is assembled in the browser, and this endpoint authenticates no one but the form. Hosted request forms For inbound RFQs without any integration, create a hosted intake form from the dashboard (Quotes → Request forms) or via POST /api/v1/quote-request-forms . The response reveals a qrf_ form token exactly once (hash-only stored — rotate to mint a new one); the buyer-facing form lives at https://checkout.usethrottle.dev/q/request/{formToken} . Submissions create-or-find the customer by email, open a requested quote (source request_form ) with the buyer's free-text need as the first shared comment, and fire the same quote.requested webhook + rep notifications as every other intake path. A request can carry line items, a written need, or both — at least one of the two. Items land on the quote's working revision unpriced , carrying the name, SKU, quantity, and per-line note the buyer supplied, so your rep prices an itemised sheet instead of transcribing prose. The hosted page renders item rows by default; set fields.lineItems: false for a prose-only form, or fields.targetPrice: true to let buyers name the price they are after. targetUnitPrice is recorded against the line and shown to your rep while they price it, but it never becomes the quoted price. Everything on this endpoint arrives from a browser holding only a public form token, so treating any of it as pricing would let a buyer set what you charge. Bot protection is always on : a honeypot field (trips return a success-shaped response without creating anything), a minimum-fill-time check, and per-IP rate limits. Set the form's fields.requireTurnstile flag to additionally require Cloudflare Turnstile. Turnstile fails closed . If a form requires it and TURNSTILE_SECRET_KEY is not configured server-side, submissions are refused with 503 turnstile_not_configured rather than accepted unverified — a toggle that silently does nothing is worse than no toggle. Forms that do not require Turnstile are unaffected. The buyer link To host this experience on your own storefront instead of Throttle's page, see @usethrottle/quotes — buyer components, hooks, and an optional same-origin proxy. Every quote gets one branded, tokenized link — revisions land at the same URL, and the link keeps working (read-only) after the quote is ordered. Buyers reach it at https://checkout.usethrottle.dev/q/{shareToken} — no account, no API key. The page shows the itemized offer with live recompute for optional lines, the revision history, a comment thread, and Request changes / Decline actions. Branding (logo, accent color, display name) comes from the application's environment settings, matching hosted checkout. That host is configurable. Set checkoutLinkBaseUrl on the applic"},{"title":"React Package · @usethrottle/quotes","route":"/developers/quotes/react-package","headings":["React Package · @usethrottle/quotes","Install","The buyer quote page","Errors","RFQ request form","Payment terms"],"text":"Quotes React Package · @usethrottle/quotes Buyer-facing components, hooks, and a same-origin proxy for the hosted quote link and the RFQ request form — so you can serve the buyer experience from your own storefront instead of sending people to Throttle's hosted page. Everything here is authorized by the qtl_ capability token in the buyer's URL — there is no API key, because this runs in the browser. Merchant operations (create, price, issue, revise, record acceptance) need an sk_* key and live in @usethrottle/api-client 's QuotesService , which must stay server-side. Install React 18+ is a peer dependency. The package ships ESM and CJS builds with type declarations, and exposes two entrypoints: the root (browser) and /server (the optional proxy). The buyer quote page Mount QuoteView on a route that receives the token from the buyer's emailed link. It renders line items with optional toggles and editable quantities, live totals, revision history, the comment thread, a PDF link, and the accept / request-changes / decline actions. On acceptance it sends the buyer to Throttle hosted checkout. QuoteItem.recurring carries a line's subscription terms. lineDueAtAcceptance(item, quantity) and computeSelectionTotal count a free-trial line as 0, the rule the server charges by, and QuoteView prints each subscription line's terms and the split totals (Due at acceptance, Recurring after). The default markup is deliberately plain — it is meant to be restyled or replaced. For your own presentation, use the hook directly. useQuote(token, options?) The hook owns the behaviour that is easy to get wrong: Fires the view beacon once per mount, so the merchant sees first viewed telemetry. The API throttles the resulting quote.viewed webhook to one per revision per 6 hours. Seeds selections from the revision, so a re-issue that adds or locks lines resets the buyer to the new offer. Refetches automatically on revision_superseded — the rep re-issued while the buyer was reading. Sends only the selections the rep actually allows: locked lines and non-optional lines are never included in the acceptance payload. total is computed with the same rule the server applies at acceptance , including scaling a line's tax and discount pro-rata when the buyer changes its quantity. Rolling your own sum risks showing the buyer one number and charging another. Errors Everything throws ThrottleQuotesError , which extends the shared ThrottleError and carries the API's own code . code meaning revision_superseded A newer revision was issued. currentRevisionId names it; useQuote auto-refreshes. quote_expired The offer lapsed (410). Prompt the buyer to request updated pricing. invalid_item_selection A locked line's quantity was changed, a required line deselected, an unknown item sent, or (for deposit terms) the selection fell to or below the deposit. accept_in_progress A concurrent acceptance won the race. Retry shortly. not_found Unknown or rotated token, or a draft/archived quote. RFQ request form Bot protection is wired in: the honeypot field and the mount-time anchor that feeds the API's minimum-fill-time check. A honeypot hit returns a success-shaped response and creates nothing — by design, so a bot cannot learn it was caught. Treat submission success as accepted , not created . If the form requires Cloudflare Turnstile, render the widget yourself and pass its token as turnstileToken — this package pulls in no third-party script. A form that requires Turnstile on a store where it is not configured is refused with turnstile_not_configured rather than silently accepted. Same-origin proxy · /server entrypoint Quote links work fine calling Throttle directly — the token is the credential. Proxy through your own origin if you would rather not have a third-party host in the address bar or CSP. The proxy forwards no API key . It allow-lists the public quote paths, so it cannot be pointed at a merchant endpoint; rejects traversal and any method other than GET/POST; passes the PDF 302 straight through instead of buffering the file; and forwards x-forwarded-for so acceptance evidence and per-IP limits attribute to the real buyer. Payment terms A quote carries one of three modes on currentRevision.paymentTerms : pay_in_full — card at checkout. net_terms — invoice due in netN days. deposit_balance — depositAmount charged today by card, the remainder invoiced balanceNetN days out. depositAmount is frozen when the rep issues the quote, so it does not move when the buyer edits quantities. Selections that would drop the total to or below the deposit are rejected with invalid_item_selection ."},{"title":"Cart states","route":"/developers/reference/cart-states","headings":["Cart states","State machine","States","Mutation rules","Abandoned-cart recovery","Where the recovery link points"],"text":"State reference Cart states A cart s status field is the single source of truth for whether it can still accept mutations. Only open carts are writable. State machine States open — initial state. Accepts items, discount apply/remove, shipping select, tax recompute, and checkout conversion. checkout — cart has been converted to a draft order (POST /carts/ id /checkout ). The cart is frozen; further mutations return 409 cart_already_checked_out . converted — the linked draft order was paid and an order row was finalised ( payment.captured fired). Read- only; archived. abandoned — no activity for 24 hours (or past the cart s expiresAt ), OR the linked draft order was cancelled before payment. An hourly background sweep performs the idle transition and emits cart.abandoned . Read-only. See Abandoned-cart recovery below. Mutation rules Every mutation increments sequence on the cart. Webhook payloads include sequence so subscribers can detect out-of-order delivery. POST /carts/ id /checkout transitions open → checkout . Idempotent within the 24h Idempotency-Key window — same key replays the same draft order id. Direct transitions out of checkout / converted / abandoned via the cart API are not allowed. Cancel the linked order to roll the cart back to abandoned . Mutating a non-open cart returns one of: cart_already_checked_out (409), cart_already_converted (409), or cart_abandoned (410). Abandoned-cart recovery An hourly background sweep marks idle carts as abandoned and emits cart.abandoned . A cart is idle when it has had no mutation for 24 hours — every item, shipping, discount, or tax change resets the clock — or when its expiresAt has passed. Empty carts and externally-managed carts are never swept. Webhook (always): every swept cart emits cart.abandoned to your subscribed webhook endpoints, so you can drive recovery from your own systems — including for anonymous carts you track by your own session. The payload carries the line items, totals, customer (a linked customer, or a guest with id: null when only the cart’s customerEmail was captured; null if neither), shippingAddress / billingAddress (if set), and the recoveryUrl . Recovery email: Throttle sends the customer.cart_abandoned email automatically whenever the cart has a known customer email. The email respects customer opt-out / unsubscribe; in non-production environments it carries a [TEST] subject banner. Cart sessions: a backend-less storefront using cart sessions has no server-side customer record to attach an email to. Set customerEmail with PATCH /api/v1/cart-sessions/ id as soon as the buyer gives it — that alone is what makes the guest cart eligible for the sweep above. Where the recovery link points The link in the recovery email resolves through three tiers, first match wins: cartRecoveryUrlTemplate — a URL on your own site containing a cartId placeholder, e.g. https://shop.example.com/cart?c= cartId . Use this when you want the buyer to land on a specific page you control. checkoutLinkBaseUrl — your own host, e.g. https://shop.example.com . Throttle appends /r/<cartSessionToken> , so serve that route yourself with the cart SDK. Throttle-hosted — when neither is set, the link points at Throttle’s own checkout. Nothing to configure. Both settings live on the application, per environment, and are editable from the dashboard’s Abandoned carts page. checkoutLinkBaseUrl also governs where quote links point, so a merchant hosting their own buyer-facing pages sets it once. A recovery link is single-use . Opening it converts the underlying cart session into a checkout session; a second click lands on the expired page rather than reopening a checkout the buyer may already have paid. Throttle can only recover carts it knows about. To get abandoned-cart recovery, create the Throttle cart (POST /carts ) when the buyer first adds an item and mutate it on every change — the mirror pattern . If you only create the cart at checkout, abandonment happens before Throttle ever sees it and there is nothing to recover."},{"title":"Error codes","route":"/developers/reference/errors","headings":["Error codes","Envelope","Common codes","Subscription and recurring-checkout codes","Resolved cart codes","Order line-item edit codes","Validation errors","SDK errors","HTTP code conventions"],"text":"Reference Error codes Every 4xx / 5xx response carries a stable error.code . Reach for it (not the prose message ) when branching in your handler — codes are versioned with the wire contract; messages are not. Envelope Common codes The list below covers the ~20 codes you'll see most often when integrating cart, checkout, payments, and embed flows. The full set is generated from the OpenAPI spec at api.usethrottle.dev/openapi.json . [ code: code , http, cause, ]) /> Subscription and recurring-checkout codes Codes returned by the subscription routes and by checkout sessions that carry a plan. See Managing Subscriptions and Starting checkout from an existing cart for the behavior behind them. [ code: code , http, cause]) /> Resolved cart codes Codes returned for carts whose lines an installed app prices, and for cart sessions of an environment where browser carts may only hold resolved lines. Each code, its details shape and the SDK error class are on Line Resolvers . [ code: code , http, cause]) /> The two cart pricing settings on PUT /api/v1/embed-config , resolvedLinesOnly and cartCurrencies , answer 403 permission_denied to any caller other than the merchant (an installed app's key, an extension session, an OAuth token). An invalid cartCurrencies answers 400 validation_error with details[].path naming the field or the entry, for example cartCurrencies.1 for a duplicate, the environment's own currency, or a code that is not a currency shown and charged with two decimal places (JPY, KRW, KWD, BHD, HUF, XTS). Extra cart currencies Order line-item edit codes Codes returned by PATCH /api/v1/orders/:id/line-items , which adds, updates or removes line items and settles the change on an order with a captured payment: an increase is charged to the stored card, a decrease refunded. On an order whose balance is on an outstanding Net-N invoice the invoice takes the change instead — its amount rises or falls, no card is charged — and the response reports it in data.adjustment.invoice . Every code below refuses the edit before anything is written, charged or refunded. [ code: code , http, cause]) /> When the application calculates tax with a provider, the edit re-quotes tax with it. Under the lenient fallback policy a failure the strict policy refuses goes through instead, and the 200 response carries data.warnings: [ code, message ] : provider_fallback (the provider could not answer, so your native tax rules priced the edit), tax_address_missing (no address to tax from, so no tax was charged), or tax_zero_from_provider (the provider returned no tax for the address). The field is absent when there is nothing to report. A refund that fails after the items changed does not error either: it comes back in data.adjustment.refundError , and the refund can be retried from the order. Likewise, a Net-N invoice the edit leaves owing nothing is voided, and a void that fails comes back in data.adjustment.invoice.voidError . Validation errors When a zod body validator fails, the envelope carries code: "validation_error" and a per-field details[] array. Strict-input endpoints return only the canonical camelCase contract. Field-level details identify unrecognized keys and may include a suggestion string when the server can identify the intended field: When in doubt about a field name, send the request, then read error.details[].path . The validator names the canonical key it expected. SDK errors Every Throttle SDK throws the same error class — ThrottleError from @usethrottle/errors , re-exported from each SDK. It carries code , statusCode , message , and optional details . The historical per-SDK names still work and are subclasses, so instanceof ThrottleApiError (cart) and instanceof ThrottleCheckoutError (checkout) keep working — and a single instanceof ThrottleError catches errors from all of them. The cart SDK additionally throws typed subclasses for the two lifecycle failures worth branching on, so you don't have to sniff error.code strings. Both extend ThrottleApiError (and therefore ThrottleError ): CartNotOpenError (409 cart_not_open ) and CartNotFoundError (404). HTTP code conventions 400 — request was malformed (validation, missing required field, invalid value). 401 — authentication missing or invalid (key, signature, JWT). 402 — request requires a paid subscription that isn't active (production entitlement). 403 — authenticated but not authorized for this workspace / application / role. 404 — resource doesn't exist (or exists in a different workspace). 409 — request conflicts with current state (cart already checked out, Idempotency-Key reused, slug taken). 410 — resource was permanently removed (revoked invitation, abandoned cart). 422 — semantic validation failed (e.g. payment amount exceeds order total). 429 — rate limit exceeded. 5xx — Throttle problem. Idempotency-Key replays do NOT cache 5xx, so safe to retry with the same key."},{"title":"Fulfillment states","route":"/developers/reference/fulfillment-states","headings":["Fulfillment states","State machine","States","Type-specific details","When the order is considered fulfilled"],"text":"State reference Fulfillment states Fulfillment lifecycles are shared across all 5 fulfillment type s (shipment, digital, access, service, in_person). The terminal definition varies by type. State machine States pending — fulfillment row created, no provider activity yet. in_progress — provider has acknowledged the work (label printed, digital file prepared, access pending). Fires fulfillment.shipment.shipped for shipment type — on the first tracking number, whether it arrives with POST /orders/ orderId /fulfillments ( shipment.trackingNumber ) or a later PATCH .../fulfillments/ id /shipment . fulfilled — terminal. Per type: shipment — carrier marks delivered ( fulfillment.delivered ). digital — file generated and a presigned URL was returned at least once. access — access grant row inserted and active. service / in_person — merchant marks complete via API. cancelled — terminal. Reachable from pending or in_progress . For shipment, cancelling after label creation requires merchant void via the carrier. Type-specific details Each fulfillment carries exactly one detail record, decided by its type , and each detail endpoint only accepts a fulfillment of its own type: shipment → PATCH /api/v1/orders/ orderId /fulfillments/ id /shipment digital → …/digital access_grant → …/access service → …/service Pointing one at a fulfillment of another type returns 409 fulfillment_type_mismatch and writes nothing. digital and access_grant line items are fulfilled automatically when the payment captures, so their fulfillment already exists — creating a second one returns 400 over_fulfillment . Patch the existing one instead. When the order is considered fulfilled The parent order reaches fulfilled when every deliverable line item s quantity is covered by completed fulfillments — not when every fulfillment row happens to be terminal. Line items with nothing to deliver (a donation, a fee) are excluded from the count, so an order made entirely of them is fulfilled as soon as it is paid. Progress is readable on the order as fulfillmentSummary ."},{"title":"Order states","route":"/developers/reference/order-states","headings":["Order states","Why two fields and not one","What each field owns","Buyer and source fields","State machine","The seven statuses","The twelve payment statuses","Setting a status by hand","The whole story, not just the status changes","Money moved by hand","How much, not just whether","Where the goods are","Holds","What a hold blocks","What a hold does not block","Placing and releasing","Endpoint per transition","What you cannot do","Outbound events"],"text":"State reference Order states An order has one lifecycle status and one payment status. The lifecycle status answers how far along is this order? ; the payment status answers how much of the money has arrived? . Nothing else. Why two fields and not one Delivery only moves forwards: once an item ships, it has shipped. Money moves both ways — a captured payment can be refunded, and a refund is not a step backwards through the lifecycle, it is a separate fact about the same order. Folding the two into a single field would mean either losing the refund or corrupting the sequence. So status is a single ordered sequence, and paymentStatus sits alongside it. Read them together and you have the whole picture; that combination is exactly what displayStatus gives you. An order used to carry a third field, fulfillmentStatus , alongside status . It was removed: the two encoded one fact, had to be kept in sync by hand, and drifted. Delivery progress now lives in status itself, with per-item counts in fulfillmentSummary . What each field owns status — the lifecycle. Moved automatically by payments and fulfillments, and settable by hand via POST /orders/ id /status . paymentStatus — how much of the money has arrived. Rolled up across every payment on the order. Never set directly. displayStatus — a human-readable label combining the two, e.g. Processing — Awaiting payment . Derived on every read, never stored, not filterable. Display it; branch on status . fulfillmentSummary — itemsTotal, itemsFulfilled, itemsRemaining . Items with nothing to deliver (a donation, a fee) are excluded from the total. payments[].status — what happened on one transaction. Never a summary of the order. Buyer and source fields Every order read ( GET /orders/ id , the list, and the order every order mutation returns) also carries the stored order row's identity and provenance columns: ipAddress and userAgent — the buyer's IP address and browser user agent recorded at checkout. Null on orders that did not come through a checkout (created by API, imported, or renewals). This is personal data about your buyer: treat it as you would their email address. clientContext — the attribution snapshot copied from the checkout session when the order was created (for example UTM parameters and the referring page). Free-form; null when the checkout recorded none. applicationId , environmentId and cartId — the application and environment the order belongs to, and the cart it was converted from (null when it was not). externalId , externalCustomerId and importId — for an order that arrived by import, the source platform's order and customer ids and the import that created it; null otherwise. Line items on an order read carry their row columns too: orderId , position , cartId , workspaceId , applicationId , createdAt and updatedAt . State machine A failed payment does not move the order. paymentStatus becomes failed ; status stays where it was. The goods side of the order has not changed, and the merchant may retry on another card. PAYMENT_FAILED therefore appears in no row of the automatic-transition table. The seven statuses draft — created, checkout not started. No payment expected. pending — placed, awaiting payment. processing — payment authorized or captured, nothing delivered yet. Fulfillments can now be created. partially_fulfilled — some deliverable items handed over, some outstanding. fulfilled — everything the buyer bought has been handed to the carrier or given to the buyer. This is the happy-path end state. It is handover, not arrival: arrival is a separate, later fact and reads as the Delivered qualifier. An order with nothing to deliver at all reaches it as soon as payment lands. cancelled — called off. Terminal. Authorized payments are voided; captured money is refunded only on explicit opt-in. closed — archived out of active views. Terminal. Reachable from fulfilled or cancelled : closing is bookkeeping, not completion. completed meant delivered and paid — two facts in one word. It is gone; read status === fulfilled together with paymentStatus instead. Orders that held it were migrated to fulfilled . The twelve payment statuses paymentStatus is rolled up across every payment on the order and recalculated on every money movement. You never set it. It is not the same vocabulary as one payments[].status : an order can be partially_paid , which no single payment can express, and disputed and charged_back are derived from a payment s dispute fields rather than from its own status. pending — nothing has arrived and nothing is in flight. authorized — a hold is in place, no money taken. Capture it or it expires. processing — the payment is in flight at the provider: a 3DS challenge, an ACH or iDEAL debit, a bank redirect. It is not a decline. Wait for the provider s webhook; do not retry the charge. partially_paid — some money has arrived, less than the total. A deposit taken with the balance still outstanding. captured — everything owed has arrived. A fully comped order reads this too, with nothing charged. partially_refunded — some of the captured money has been returned. refunded — everything captured has been returned, across every payment on the order. Fully refunding one payment of several — a deposit with its balance paid, or the extra charge from an edit — reads partially_refunded and leaves the order where it is. Only an order that reads refunded can be cancelled by the refund itself (one that has not shipped, has no subscription and no comp). failed — every attempt was declined, and none is still in flight. voided — the authorization was released without ever being captured. disputed — the buyer has charged back. This outranks captured : the money is captured AND at risk, and only the half with a deadline is worth a label. Defend it with your provider. charged_back — a chargeback was decided against you and the money was taken back. The dispute is closed, so this ranks below an open disputed on another payment."},{"title":"Payment states","route":"/developers/reference/payment-states","headings":["Payment states","State machine","States","Refunds","Net30 special cases","Disputes & chargebacks"],"text":"State reference Payment states Payment states mirror the underlying provider (card processor, Net30 for invoice). Throttle normalises them so your handler is provider- agnostic. State machine States pending — payment row created, no provider response yet. UI shows a spinner; back-end has the row id but no auth/capture proof. processing — the provider has neither approved nor declined yet: the buyer is mid-3DS challenge, away on a redirect method, or the debit is still settling. Nothing has failed, so there is no error.code — treat this as keep waiting , not as a decline. Fires payment.processing , and a resolution event ( payment.authorized , payment.captured or payment.failed ) always follows. authorized — provider authorised the amount (funds reserved). Most card flows auto-capture so this is brief; manual-capture flows stay here until the merchant explicitly captures. captured — funds moved. Triggers payment.captured outbound event. Order can now transition to paid . Capture may be partial : pass an amount below the authorized total to POST /v1/payments/ id /capture . The captured value is tracked on capturedAmount (the event carries both capturedAmount and authorizedAmount ); the uncaptured remainder is released by the processor, and refunds are capped at the captured amount. failed — provider declined OR a network/timeout error during processing. Carries error.code + error.message . Terminal. A merchant can also declare it by hand with POST /payments/ id /mark-failed — the promised bank transfer that never arrived, the cheque that bounced — which writes error.code: manually_marked_failed and the supplied reason. Only a pending , processing or already- failed payment is failable by hand; anything resolved returns 422 payment_not_failable . voided — authorisation released before capture. Funds never moved. Terminal. expired — the authorization outlived its usable life and was never captured, so the hold has lapsed at the provider. Throttle sweeps these itself because providers do not reliably notify on expiry, and fires payment.expired when it does. To collect this money you must charge again; capture will not work. Terminal. partially_refunded — at least one refund processed but total refunded is less than captured amount. Still accepts further refunds. refunded — total refunded equals captured amount. Terminal. Refunds POST /v1/payments/ id /refund returns money from one payment; omit amount to return everything still refundable on it, including the rest of a payment that was already partly refunded. POST /v1/orders/ id /refund works on the order: when an order was paid more than once — a deposit and a balance, an instalment plan, a retried charge that settled on a second row — the refund is spread across its captured payments, newest capture first, and amount may exceed any single one of them. Omit amount to return everything still refundable on the order. Asking for more than remains returns 400 refund_amount_exceeds_balance , whose message names the amount that is still refundable. The check runs before any money moves, so a refund that cannot be completed in full sends none of it. A refund claims its amount before the provider is called, so a second refund of the same payment arriving at the same moment gets 409 refund_already_in_progress rather than passing a stale balance check and returning the money twice. Wait for the first to resolve, then re-read what is refundable. Net30 special cases Net30 payments go through pending → authorized on invoice issue, then → captured when the buyer pays the invoice (manual mark-paid OR ACH webhook). invoice.overdue fires when an authorized Net30 payment passes its dueDate ; the payment row stays authorized until paid or voided. Disputes & chargebacks Disputes are tracked out-of-band from the status machine, on the disputed boolean plus disputeReason / disputeOpenedAt , and — once a card chargeback is decided — disputeOutcome ( won / lost ) and disputeClosedAt , all exposed on the payment object. A payment keeps its underlying status (usually captured ) throughout. Card chargebacks are ingested automatically from the payment provider: an inbound dispute.* / chargeback.* webhook sets disputed = true and fires payment.disputed . A merchant-favourable resolution (won / reversed) clears the flag, records disputeOutcome: won , and fires payment.dispute_cleared . A loss clears the flag, records disputeOutcome: lost , rolls the order to charged_back , and fires payment.dispute_lost . Net30 disputes are opened/cleared manually from the dashboard and fire the same two events."},{"title":"API Key Scopes Reference","route":"/developers/scopes","headings":["API Key Scopes Reference","Secret vs publishable keys","How scope checks work","Implication","The wildcard","Key types & grantable scopes","The buyer plane: a publishable key is not a permission","Customer PII: redacted, not rejected","Customer directory: names without the record","Scope catalog","{group}","Migration from coarse scopes","Machine-readable catalog"],"text":"Workspace API Key Scopes Reference Every Throttle API route declares a single required scope — a resource:action string such as orders:read or payment_refunds:write . API keys are gated purely by the scopes they hold; dashboard (Clerk) users are gated by role-based permissions instead. The two systems compose — they never replace one another. Secret vs publishable keys API keys come in two prefixes. Secret keys ( sk_ ) are full-power, server-side credentials — keep them on your backend and never expose them. Publishable keys ( pk_ ) are safe to embed in browser or frontend code. Because a publishable key is application-scoped and exposed in the browser, it is restricted to a small, curated set of publishable-allowed scopes — five today: shipping_quotes:write , tax_calculations:write , storefront_scripts:read , storefront_scripts:write , and customer_accounts:write — and can never read carts, orders, customers, payment methods, or PII directly. That last scope is a deliberate exception to "publishable means stateless compute"; see the buyer plane below. (Listing available payment methods needs no key: GET /v1/payment-methods is public.) Create either type from Developers → API keys in the dashboard. How scope checks work On every API-key request Throttle reads the matched route's required scope and checks it against the key's granted scopes using these rules: Implication Exact match grants the scope. resource:write implies resource:read on the same resource — holding a write scope lets you read it too. There is no cross-resource implication : orders:write does not grant order_returns:write or payment_refunds:write . Each financial or destructive action is its own scope. read never implies write . The wildcard * grants full access to every scope. It is only grantable to secret ( sk_ ) keys. Publishable ( pk_ ) keys can never hold * . Prefer the smallest set of explicit scopes; reserve * for trusted server-side keys. A request whose key lacks the route's scope returns 403 with error.code = "insufficient_scopes" and a details.required field naming the missing scope. Key types & grantable scopes Secret keys ( sk_ ) May hold any non–staff-only scope, or the wildcard. Staff-only scopes (the admin:* family) are not grantable to workspace-minted keys — those routes are gated by Throttle Staff org membership in Clerk, never by an API key. Requesting one returns 400 invalid_scope . Publishable keys ( pk_ ) Browser-safe. May hold only the publishable-allowed subset (marked pk_ ok below) — five scopes today: the two stateless compute scopes shipping_quotes:write and tax_calculations:write ; the storefront-script loader s storefront_scripts:read and storefront_scripts:write ; and customer_accounts:write , which gates the buyer-auth plane rather than granting a data-reading permission — see the buyer plane below. A publishable key can never read carts, orders, customers, payment methods, or PII — those scopes are not publishable. (Listing the eligible payment methods for a session needs no key at all: GET /v1/payment-methods is public.) Any non-publishable scope — or the wildcard — returns 400 invalid_scope . The buyer plane: a publishable key is not a permission A publishable key identifies an application. On the buyer plane it is not a permission. Storefront customer-account endpoints are authorised by the shopper's session, not by the key's scopes — the key only selects which application the shopper is signing in to. The one scope that gates that plane is customer_accounts:write : without it, a publishable key cannot reach customer accounts at all. Every other publishable scope ( shipping_quotes:write , tax_calculations:write , storefront_scripts:read , storefront_scripts:write ) authorises a stateless compute or a public read directly off the key itself. customer_accounts:write works differently: holding it only unlocks the /v1/storefront/* route family (see Storefront Customer Auth ). Every action a buyer actually takes there — reading their orders, changing an address, removing a saved card — is authorised by their own Authorization: Bearer session token, never by the key. Two different applications' publishable keys can both hold customer_accounts:write and still never see each other's buyers; the key only decides which application a login or register request lands on. Customer PII: redacted, not rejected customer_pii:read gates personal fields on a customer record — email , phone , firstName , lastName , and the combined name some endpoints return. A credential that can read (or write) customers but lacks this scope does not get a 403 : the request succeeds and those fields come back null , with every other field (id, externalId, company, addresses, order/subscription linkage) intact — the same "redact, not reject" pattern the picker warning on ⚠ sensitive exists to flag. This applies whether the customer came back from a read, or is the record echoed back by a create/update call. Because customer_pii:read is sensitive, an OAuth connection to an AI assistant (the MCP integration flow) can never hold it — see the MCP docs for which tools therefore always return redacted customer data. It is the one sensitive scope an automation connector (Zapier, Make — an OAuth client registered for the REST API) may be granted, and the consent screen names it. A connector without it gets the same redaction, and creating, changing or replaying a webhook endpoint returns 403 oauth_customer_pii_required , because deliveries carry the full record. Customer directory: names without the record customer_directory:read gates GET /api/v1/customer-directory , which finds customers by a name or email prefix, or names up to 50 customer ids, and answers only their id , name and email . It exists so an extension can show customers in a picker without holding customers:read . It is ⚠ sensitive , so no OAuth connection can hold it, and no other scope grants "},{"title":"Your Own Scripts on Hosted Checkout","route":"/developers/scripts","headings":["Your Own Scripts on Hosted Checkout","What this is","What a script can and cannot do","Loading a vendor tag","Event catalog","Consent","External domains","Scripts shipped by an installed app","Declaring them","The two tiers","Automated review gates","What a script declares is frozen once it ships","Consent on upgrade","Your controls over an app's scripts","Authoring from the CLI","Your own storefront","One script tag","Or from npm","React","Events you publish","The manifest, directly","Managing scripts","API","Create a script","List scripts","Update a script","Delete a script","Kill switch","Health","Limits","What is not in this release"],"text":"Analytics Your Own Scripts on Hosted Checkout Add your own JavaScript to Throttle s hosted checkout page — a pixel, a cookie-based attribution stitcher, a custom analytics relay. It runs in an isolated sandbox with no access to the page, the payment form, or anything Throttle does not explicitly hand it. This page documents exactly what ships today: your own scripts, sandbox-tier only, on the hosted checkout page ( /c and /s ), and never in embed mode. See What is not in this release before you build against this. What this is Throttle mounts every enabled script for a surface inside its own <iframe sandbox= allow-scripts > — no allow-same-origin , so the frame s origin is opaque and it cannot reach the parent document under any circumstance. Your script talks to the host only through postMessage , wrapped by the small API surface described below. checkout , html: Hosted checkout — /c/<sessionId> and /s/<sessionId> , Nothing. Mounted for you. , ], [ html: quote , html: The hosted quote page, /q/<token> , html: Nothing. Consent fails closed here — a quote link carries no consent decision, so an environment that requires consent runs essential scripts only. , ], [ html: storefront , Your own pages, on your own domain , html: Install @usethrottle/scripts and allow-list your origin — see Your own storefront . , ], [ html: Embedded checkout ( ?embed=1 ) , html: Runs no scripts of its own. It forwards its events to your page instead. , html: Install the loader on the page doing the embedding. Without it, checkout-surface scripts never run — the dashboard warns when it has never seen your loader call in. , ], ] /> Embedded checkout runs nothing itself on purpose: a sandbox inside that iframe would write cookies like _ga to Throttle s checkout domain, splitting them from the storefront half of the same shop. Forwarding the events to your own loader keeps every cookie first-party on your domain. What a script can and cannot do checkout.started , .step_changed , .payment_submitted , .completed , .failed ) and publish its own events over analytics.subscribe / analytics.publish — see the event catalog below. , html: Touch the checkout page — no document , window , or parent . Reachable identifiers are shadowed to undefined ; the real containment is the iframe s opaque origin. , ], [ html: Read/write cookies (real names — _ga , _fbp , etc. — with a small reserved list blocked) and namespaced local/session storage, all proxied async through the host. A read settles as null when refused; a write rejects , so a refused write cannot read as a successful one. , Read the payment form, card fields, or anything else rendered on the page. , ], [ html: Call fetch / sendBeacon to hostnames it declared in externalDomains . , html: Make a network request ( fetch , sendBeacon , XHR, images) to any other domain — blocked by the sandbox document s CSP connect-src , with no error the script itself can see; Throttle reports it as a blocked outcome with reason: csp . A script can still navigate its own frame (e.g. location.href = ... ) to any URL — that is not a network request CSP governs. Throttle detects this: a navigated frame is torn down and reported as an error outcome, so it is observable in the script health view even though it is not prevented. , ], [ Read the page context it booted with (URL, referrer, title, user agent) once, at load. , See anything about the buyer or the order that was not explicitly included in an event payload — no PII grading is wired yet, so no event carries customer data in this release regardless. , ], ] /> The register() contract Your source is compiled as a function body and run, so plain JavaScript works — top-level statements execute, and a script that never calls register() is perfectly valid. Use it when you want to subscribe to events; skip it when you just want code to run. At the top level you are handed four bindings: register , analytics , browser and settings . There is no api at the top level — api is the single object passed into the register callback, so api.browser.cookie.set(…) outside register() is a ReferenceError . Write browser.cookie.set(…) instead. What actually constrains you is not the convention. document , window , parent , top , opener , localStorage and sessionStorage are undefined inside a sandbox-tier script, and the network is governed by the CSP built from your externalDomains . Use browser.* for storage and browser.loadScript() for vendor tags. Your script s whole body is one call to the global register() function, which Throttle invokes once per load with analytics, browser, init, settings . The shape below — subscribing to a named event and publishing a derived one — is copied from a real, currently-passing test in packages/scripts-runtime/src/sandbox/bootstrap.test.ts , not invented for this page: This actually fires: the hosted checkout page bridges its own checkout lifecycle onto this bus (a script never sees the underlying postMessage events — only the projected checkout.* names below). Loading a vendor tag browser.loadScript(url) pulls a remote script into the sandbox and resolves when it has run. The host must be in your externalDomains , exactly — declaring example.com does not cover cdn.example.com , because the CSP matches hosts rather than parent domains. It rejects with a real error you can catch, rather than leaving you with a CSP refusal the page cannot see. A vendor tag mutates whenever the vendor redeploys — that is the point of one — so subresource integrity cannot pin it and no review can vouch for what runs tomorrow. What contains it is the sandbox: the code lands in an opaque origin with no cookies, no access to the merchant s DOM, no parent, and no network beyond the same declared list. Judge a vendor on whether you are willing to run their code under those limits, not on having read it once. Event catalog step — one of cart | address | shipping | billing | payment . , ], [ code: checkout.payment_submitte"},{"title":"Mount Card Fields On Your Own Page","route":"/developers/secure-fields","headings":["Mount Card Fields On Your Own Page","How it works","Mint the session","Content Security Policy","React example","Status","3-D Secure","Saved cards","Testing failures","Declines","The rest","Error reference","Field mounting and vaulting (the hook, in the browser)","3-D Secure (the hook)","Fallback codes","Routing -> Decline rules in the dashboard does the same thing.","Remove it again when you are done."],"text":"Checkout surfaces Mount Card Fields On Your Own Page Secure Fields gives you individually mountable, provider-hosted inputs — card number, expiry, security code, postal code — that you place in your own form and style yourself. Raw card data never touches your page or ours; your server never sees it either. Secure Fields has no PayPal, Apple Pay, or Google Pay. It is a card-capture surface, full stop — that is a property of the product, not a v1 gap. A merchant who needs those payment methods keeps using Embedded Checkout or hosted checkout alongside Secure Fields for the card-only case. How it works Four steps, and the only server-to-server call is the mint. Everything after that is either a mount inside the buyer's browser or the same public completion route the Embed and hosted checkout already use. Mint the session Call this from your backend, with your sk_* secret key. Never call it from the browser — there is no publishable-key variant. Field Notes POST /api/v1/checkout-sessions/:id/card-session :id is an existing Throttle checkout session (create one first with the cart/checkout APIs, exactly as you would for the Embed). Auth X-API-Key secret key with the checkout_sessions:write scope. Response sessionId, providerId, environment — pass this object straight into unchanged. Throttle records every card session it mints on the checkout session it was minted for (the newest 20 are kept). /complete authorises only against one of those; a card session minted for another checkout session, or one Throttle did not mint for it, answers 409 transaction_not_for_session before any order or authorisation, with details.retryable: false . Mint a new card session for this checkout session and mount the fields again. A card session minted before 2026-10-05 is refused once in the same way. Content Security Policy The card inputs are iframes served from Throttle's payment provider, not from your own domain. If your checkout sends a Content-Security-Policy header, the fields render as blank boxes until you allow that origin — usually with no error in the page, only a CSP violation in the browser console. frame-src lets the iframes mount; connect-src covers the calls they make. If you also send frame-ancestors elsewhere, it does not apply here — that directive governs who may frame you , not whom you may frame. React example @usethrottle/checkout-react ships a headless provider, a hook, and four unstyled field primitives. Style everything yourself — the whole point of choosing Secure Fields is that you own the pixels around the fields. session.sessionId (from the mint response) is the payment provider's checkout session id. It is what constructs the SecureFields instance and mounts the field iframes. checkoutSessionId (the prop you pass to separately) is Throttle's checkout session id — the same :id you minted against in the step above. It is what pay() uses to call POST /checkout-sessions/:id/complete . Swap them and every payment breaks: the fields will mount and take input fine (they only need the payment provider's id), but complete() will call /checkout-sessions/ provider id /complete , which does not exist on Throttle's side, and every payment 404s at the very last step. Status useSecureCard() exposes an explicit state machine. Render any of it — there is no need to infer state from side effects. Status What it means What to render idle Provider mounted; fields not yet registered. A loading state over the field area. mounting The Secure Fields instance exists; individual fields are registering. Same loading state — fields are not yet interactive. ready All required fields (number, expiry, CVV) are registered. Enable the Pay button. ready is also exposed as its own boolean. vaulting pay() / vault() called; waiting on the payment provider to accept or reject the card. Disable the button: “Saving card…” authorizing The card vaulted; Throttle's /complete call is in flight. Disable the button: “Processing payment…” challenging /complete returned requires_action ; the payment provider's own 3-D Secure iframe is mounted at your registered element and running. Make the registered mount element visible to the buyer: “Confirming with your bank…” complete The order was created. onSuccess already fired. Navigate away, or show a confirmation. error Vaulting, the challenge, or completion failed. Read error.code / error.message (see below) and let the buyer retry. 3-D Secure A payment that needs step-up authentication does not fail — complete() resolves with status: 'requires_action' instead of an order. pay() handles this for you. When that happens, the hook moves to challenging and drives the payment provider's own 3DS iframe (mounted at the element you passed to registerThreeDSecure ) to a result, then re-calls /complete for the authoritative outcome. Register the mount element alongside your card fields — it only actually mounts a challenge when one is needed, so it costs nothing to always include it. If the buyer never resolves the challenge, challengeTimeoutMs (5 minutes by default, overridable via the same-named prop) settles complete() to error.code: 'three_ds_timeout' on its own. Calling cancelChallenge() (also from useSecureCard() ) abandons an in-progress challenge with error.code: 'three_ds_abandoned' . If the challenge resolves but the payment still needs authentication, or if the buyer keeps retrying without ever clearing it, Throttle caps re-entry at 5 attempts and returns 409 three_ds_unresolved . Treat this as terminal: tell the buyer to start a new checkout rather than retrying the same session again. Saved cards Whether a card gets saved is decided entirely by the checkout session you minted the card session against — nothing the browser sends can change it. If that Throttle checkout session carries"},{"title":"Failures with no error","route":"/developers/silent-failures","headings":["Failures with no error","At a glance","Your tenant id is not where you expect","Cancellation fires two different events","A handler for an event you never subscribed to","The gate that never runs","Creation paths without a subscription","Changing a plan through checkout","Cards that save but cannot be charged","Test doubles kinder than production","Start with the delivery log"],"text":"Integration Failures with no error Every trap on this page shares one property: it produces no error. No exception, no 4xx, nothing in your logs. Several of them look actively healthy — a delivery log full of 200 s while nothing at all is happening. This is the single most useful habit for a billing integration. After every step, assert against your own database — not the API response, and not the absence of an exception. Most of what follows was found in production by someone who had green tests. At a glance Your tenant id is not where you expect Subscription and payment payloads carry Throttle s internal customerId — a UUID that means nothing in your system. Your identifier is on the sibling customer object, as externalId . Both appear on the same customer object. externalId is the id you set when you created the customer. externalCustomerId is a per-connection mapping and is null for direct API integrations — so reading it returns nothing, forever, without erroring. The failure mode is total and invisible: every subscription webhook is received, resolves to nothing, and returns 200. Throttle s delivery log shows delivered / 200 for all of them. If your checkout return path also syncs state, the user-present flow keeps working and hides it — until the first renewal, a month later, with no user present. Cancellation fires two different events Handle only subscription.cancelled and a scheduled cancellation is invisible to you: no confirmation email, and your Cancel button stays live because the subscription is still active . The buyer cancels, sees nothing change, and cancels again. A handler for an event you never subscribed to You can write a flawless handler for an event your endpoint does not receive, and nothing will ever tell you. There is an endpoint for exactly this: it reports event types your application emitted that no active endpoint subscribes to. Run it after every integration change — it is cheap enough to put in CI and fail the build on a non-empty unsubscribed array. An endpoint subscribed to * always reports clean. The gate that never runs An entitlement check registered in the wrong place — before tenant context exists, on a router that does not cover your API, behind a condition that is never true — returns early on every request. Both the expiry lockout and all plan gating become dead code, and nothing distinguishes that from no one is over their limit . Reading the code is not evidence. Put a tenant into a blocked state and confirm you get a 403. This is a two-minute test that no amount of code review substitutes for. Creation paths without a subscription Count the ways a tenant can come into existence: signup, invite, admin creation, seed script, import, test fixture. Each one that does not create a subscription row produces an account with permanent free access that never expires and is never asked to pay. Nothing surfaces this. The accounts work perfectly. Enumerate the paths by grepping for your create call and check each one individually. Changing a plan through checkout Checkout always creates a new recurring subscription. Run an existing subscriber through it and they now have two, and are billed for both. Use POST /subscriptions/{id}/change-plan instead, and fall back to checkout only when there is no live subscription to change. Cards that save but cannot be charged Card entry must go through the hosted embed. A card attached by any other route saves successfully, lists successfully, and then fails at charge time with payment_method_not_chargeable — which you discover at the first renewal, not at integration time. Use the embed via @usethrottle/payment-methods or hosted checkout. It is also what keeps card data out of your DOM and your PCI scope. Test doubles kinder than production A mock that returns a friendlier shape than the real API produces passing tests and a broken integration. This is the failure behind more than one bug on this page, on both sides of the boundary. For anything crossing into Throttle, assert against a real captured payload . The delivery log gives you exactly what was sent; replay one as a fixture rather than writing what you think the shape is. Start with the delivery log GET /api/v1/webhook-deliveries?endpointId= shows the exact payload sent and the status your endpoint returned. When something is not happening and nothing is erroring, this is the first place to look — it distinguishes never sent from sent and your handler swallowed it , which are completely different bugs. Next: the build and test checklist turns this page into something you can tick through."},{"title":"Storefront Customer Auth","route":"/developers/storefront-auth","headings":["Storefront Customer Auth","Two planes, not two auth options on one plane","Turning it on","Tokens and sessions","Session lifecycle","Step-up: proving the password again for destructive actions","Email verification and the claim cutoff","Endpoint reference","No session required","Session required","Cart claim and the retirement of X-Customer-Id","Deprecation timeline","Error reference","Webhook events","Theming","Checkout interruption","React and react-dom are peer dependencies of this entry point`;"],"text":"Buyer identity Storefront Customer Auth Throttle can authenticate a merchant's end buyers — the shoppers on their storefront — as a plane entirely separate from the merchant API you already use for orders, payments, and webhooks. Buyers register, log in, verify their email, reset passwords, manage addresses and saved cards, view their own orders, invoices and subscriptions, and bind an anonymous cart to their account at checkout. Every application has auth.enabled: false until a merchant turns it on. Guest checkout is completely unaffected either way — a customer with no credentials is a guest by definition, and nothing about existing carts, orders, or subscriptions changes when this feature is off. Two planes, not two auth options on one plane A buyer session is not a weaker version of a merchant API key, and it can never be used as one. The two are isolated structurally, not by convention: Buyer routes live at a bare /v1/storefront/* — never under /api/ . A buyer access token presented to any merchant route ( /api/v1/orders , etc.) is never even parsed; the merchant auth middleware only runs on /api/ and /mcp paths, so it never sees a storefront request in the first place. The reverse holds too: a merchant sk_* / pk_* key cannot be used against /v1/storefront/* — those routes require a buyer session (or, for the credential routes below, none at all). Turning it on A merchant enables customer accounts per application, per environment, via PATCH /api/v1/applications/{applicationId}/auth-settings (see Customer Accounts Settings in the API reference). Until enabled is true , every /v1/storefront/* route returns 404 auth_not_enabled — a disabled surface does not confirm it exists. Every request also carries the storefront's existing publishable pk_* key as X-API-Key — the same key already used for shipping quotes and tax calculations — plus an Origin header, checked against the application's allowedOrigins list (the same list configured via PUT /api/v1/embed-config , not a separate buyer-auth setting). Reaching this plane at all — not any one action on it — requires the publishable key to carry the customer_accounts:write scope . A pk_* key minted only for shipping quotes or tax calculations does not automatically gain it; request it explicitly when creating the key. Once the key holds the scope, every individual route below is authorised by the shopper's own session, not by any further scope check — see the buyer plane for that model. This is deliberate, not a bug you can work around by leaving the list empty: it lets local development work with zero setup, but it means going live requires configuring allowed origins first . In a production environment, an empty list rejects every request with 403 origin_not_allowed , naming the settings page that fixes it. In a non-production (sandbox) environment, an empty list allows any origin. The links in the verification, password-reset and account-setup emails are built as storefrontBaseUrl + a path. The paths default to /account/verify and /account/reset . If your storefront serves those pages anywhere else — /auth/reset-password , say — the emails send fine and every link in them lands on a 404. Set verifyPath and resetPath with PUT /api/v1/embed-config , once per environment. storefrontBaseUrl is set the same way; left unset, the first non-wildcard entry in allowedOrigins is used, so put your public domain first or set it explicitly. Tokens and sessions Login and registration return a short-lived access token and a longer-lived refresh token . Keep the access token in memory only — never write it to storage — and send it as Authorization: Bearer {accessToken} on every authenticated call. The refresh token rotates on every use, and replaying a spent one revokes the entire session family — not just the one request that used it. If your client fires a refresh per failed request (e.g. ten concurrent 401 s each triggering their own refresh call), nine of those ten calls are replaying a token some other call already rotated, and the buyer gets logged out. Queue concurrent refreshes behind a single in-flight call and share the result — this is the single most important correctness detail in this API, and it is why @usethrottle/auth (below) exists: it does this for you. The server also keeps a 10-second grace window on the winning side of a race: if a straggler presents the exact token a concurrent request just rotated away, and it does so within 10 seconds, it gets that winning rotation s token back instead of tripping reuse detection. This is a safety net for clients without single-flight refresh (two tabs, a mobile app, a plain-fetch integration) — it does not make single-flighting optional, and a replay of a spent token after the window still revokes the family exactly as above. Reads trust the JWT; writes verify the session row. A GET is accepted on signature and expiry alone. Any POST / PATCH / DELETE , plus every verified- or step-up-gated route regardless of method, additionally loads the session row and checks it is not revoked and the credential's session epoch still matches. The accepted tradeoff: after a merchant revokes a buyer's sessions, a stolen access token can still be used to read that buyer's data for up to its remaining 600-second lifetime. It cannot write anything in that window — no order, no address change, no card removal, no subscription action. Session lifecycle Step-up: proving the password again for destructive actions A handful of actions require a fresh step-up proof, obtained by calling POST /v1/storefront/auth/step-up with the current password and presenting the returned stepUpToken as X-Step-Up alongside the normal bearer token. Missing or expired returns 403 step_up_required . Email verification and the claim cutoff Registration issues a session immediately — a buyer does not wait on an email round trip before they can browse or check out. An unverified session can"},{"title":"Subscriptions","route":"/developers/subscriptions","headings":["Subscriptions","What ships in v1","What is intentionally out of v1","How the pieces fit","Tax on renewals","Physical subscriptions","Sequence: auto-subscribe on card capture","Pages in this section"],"text":"Recurring billing Subscriptions Throttle subscriptions turn a one-time embed payment into a recurring billing relationship. The card vaulted during checkout is reused on a 5-minute renewal cron, with built-in dunning and a webhook stream that lets your backend stay in sync. What ships in v1 Recurring on the embed. Pass a recurring block on a checkout session, the embed vaults the card, and (when create: auto is set) Throttle creates the subscription on your behalf. Lifecycle API. Cancel, pause, resume, change plan, list, and fetch subscriptions through @usethrottle/subscriptions/server from your backend, or through the React hooks routed via your backend proxy. Mid-period proration. A plan or seat change sent with effective: now credits the unused portion of the period the buyer already paid for, then charges the net — and skips the charge entirely when the credit covers it. The response carries a proration breakdown ( creditCents , chargedCents , fullAmount ). Send effective: period_end to schedule the change for the next renewal instead, with no charge now. React management package. @usethrottle/subscriptions exposes hooks and primitive components so your buyer-facing portal does not have to re-derive the API plumbing. Any cadence, and installment plans. Send interval for the five named intervals, or intervalUnit + intervalCount for any cadence from 1 hour to 1 year. Add totalPayments (2–60) to make it an installment plan that completes itself after N payments and emits subscription.completed . See Lifecycle and States . Built-in dunning. Retry n waits min(n × cadence, [1, 3, 7][n − 1]) days — weekly and longer cadences always land on the original [1, 3, 7] days; shorter custom cadences retry proportionally faster. The first failed attempt moves the subscription to past_due ; if all three retries also fail it auto-cancels with a dunning_exhausted reason. Trial-fraud protection. Auto-create checks the card fingerprint against prior subscriptions on this merchant; same card already used for a trial → new subscription is created without a trial and a subscription.trial_blocked webhook fires. Pre-flight via POST /api/v1/subscriptions/eligibility-check . See Trial Fraud Protection . Webhook stream. subscription.created , .updated , .activated , .renewed , .past_due , .payment_failed , .paused , .resumed , .cancelled , .completed . What is intentionally out of v1 No-card free trials. Trials always require a vaulted card up front; the embed does not yet support a subscribe now, add card later flow. Trials with a card behave the way you expect — Throttle delays the first charge until trialEnd . Net30 recurring. Renewals charge a stored card via the payment provider. Net30 is supported for one-time orders today; Net30 recurring (invoice every period) is not part of the current subscription package surface. Customer-scoped JWTs. The React package authenticates by proxying through your backend. Direct browser → Throttle calls require a customer token system that is not yet built. See Buyer Portal for the proxy pattern documented for v1. Throttle-sent emails. All notifications flow through your webhook endpoint; you decide which emails to send. Subscribing to subscription.payment_failed is enough to wire the standard your card was declined copy. Recurring discounts. Promotion codes apply to cart-backed checkout totals today. Ongoing subscription-cycle discounts are deferred. Configurable dunning curve. The retry schedule scales down automatically for cadences shorter than a week, but merchants cannot configure their own multipliers or caps. Per-merchant or per-subscription overrides are deferred. Subscriptions touch checkout, customer identity, the renewal cron, and a buyer-facing surface. Start with the Quickstart for the end-to-end flow, then read Customer Identity before you wire your buyer portal. How the pieces fit Subscribe. Your backend creates a checkout session with a recurring block, or puts recurring on cart lines, one subscription per line, up to ten. Buyer pays through <PaymentEmbed /> . Card is vaulted. Throttle auto-creates the subscriptions. onSucceeded returns subscriptionIds . Renew. A cron runs every 5 minutes. For any subscription whose currentPeriodEnd has passed, it charges the stored card with an idempotent chargeStored call and advances the period. On failure it follows the cadence-scaled [1, 3, 7] -day retry curve. Manage. Your buyer portal calls your backend, which proxies to the Throttle API. The React package gives you hooks for the common flows. React. Your webhook handler stays in sync — record renewals, send dunning emails, gate access on past_due . Tax on renewals A renewal has no cart and no shipping address, so there is nothing for a tax provider to quote against. Throttle solves that by freezing an address on the subscription at signup and reusing it on every renewal. taxAddressSnapshot — the address renewals are quoted against. A subscription created through checkout inherits the address the buyer was taxed on: the billing address when the checkout had no shipping step. Each renewal is quoted with it as both the shipping and the billing address, whichever one your tax trigger reads. The renewal order carries it as its shipping address, and also as its billing address when your trigger is billing_address , so the filed document uses it too. taxAddressSource — checkout when it came from the buyer's session, manual once a merchant has edited it. A later checkout never overwrites a manual correction. To correct it, PATCH /api/v1/subscriptions/:id with a taxAddress object ( countryCode is required; the rest are optional). Merchants can do the same from the Tax card on the subscription detail page in the dashboard. Sending null clears the snapshot, which leaves renewals untaxed. The current period was already quoted and charged, and any tax document for it has been filed. Changing the address does not re-rate or re-file that period — it takes effect the nex"},{"title":"Buyer Portal","route":"/developers/subscriptions/buyer-portal","headings":["Buyer Portal","Architecture","Next.js App Router template","Express template","Authorization","Wiring the React provider","What buyers can do","Next"],"text":"Subscriptions Buyer Portal Let buyers manage their own subscriptions. v1 uses a proxy pattern: your backend forwards requests from the browser to Throttle, holding the secret key on your side and enforcing ownership. A proxy keeps your sk_* key off the wire and lets you reuse your existing user authentication. The hook API in @usethrottle/subscriptions is designed around this production path. Architecture Buyer s browser calls /api/throttle/api/v1/subscriptions on your domain. Your backend authenticates the user, verifies they own the resource being mutated, and forwards the call to Throttle with the secret key. @usethrottle/subscriptions s SubscriptionProvider uses a custom fetcher that targets the proxy. Next.js App Router template A single catch-all route handler proxies the relevant subscription endpoints. The helper pins externalCustomerId server-side on every read, rewrites checkout-session creates to the authenticated buyer, resolves payment-method reads from the buyer's customer row, and verifies ownership before reading one subscription or changing it. Since @usethrottle/subscriptions 4.0.0 it also limits what a buyer may send, because it forwards on your secret key: Cancel takes only atPeriodEnd . A refund is refused: a buyer cannot refund themselves. PATCH on a subscription takes only shippingAddress . Plan, cadence and amount need your authorizeUpdate({ request, subscription, body, externalCustomerId }) to return true after checking them against your price list. A direct POST /api/v1/subscriptions is refused unless your authorizeCreate({ request, body, externalCustomerId }) returns true. It names its own price and charges nothing, so prefer a checkout session. Anything else a buyer sends answers 403 field_not_allowed naming the fields; it is never silently dropped. Reading another buyer's subscription answers 404 . Express template Per-route variant. Slightly more code, but easier to reason about route-by-route authorization. Authorization Throttle s API key authorizes your account to act on any subscription you own. It does not know which buyer owns which subscription. That mapping lives in your DB (or you derive it via customer.externalId === user.id ). Without ownership checks on mutations, any logged-in user could cancel any subscription by guessing IDs. Always verify ownership server-side before forwarding a mutating request. Wiring the React provider Every hook in @usethrottle/subscriptions calls through this fetcher. Your proxy is the only thing that talks to Throttle. What buyers can do List their subscriptions and see status, plan, next billing date. Pause an active subscription and resume later. Pause is not offered on a trialing subscription (the API refuses it with 400 invalid_subscription_state ); @usethrottle/auth 0.6.1 and later hide the button there. Cancel at period end (or immediately, if you allow it). Change plan, when your proxy passes authorizeUpdate and it checks the new plan and amount against your own price list (your UI decides which plans are offered, but the browser is not trusted to name the price). Move one subscription to another of the buyer's saved cards: the proxy forwards PATCH /api/v1/subscriptions/:id/payment-method for the buyer's own subscriptions ( setPaymentMethod() on the server client). See Change the card . Change where a physical subscription ships. On the storefront plane that is PATCH /v1/storefront/me/subscriptions/:id/shipping-address (step-up gated), updateSubscriptionShippingAddress() in @usethrottle/auth , and a Change address button in its <SubscriptionsPanel /> . Surface a past_due subscription with an update-card action. The <DunningBanner /> primitive gives you the UI hook; your backend owns the actual card-refresh flow. An installment plan ( totalPayments set) shows its cadence and payment progress — installmentProgress() from @usethrottle/subscriptions gives you paymentsMade , totalPayments , and the remaining amount — but your proxy must hide Cancel, Pause, and Resume for it and refuse to forward those calls. Throttle's subscription API does not itself refuse a pause/resume/cancel called with your merchant secret key. The @usethrottle/subscriptions proxy now refuses every buyer mutation of an installment plan by default ( 403 installment_plan_merchant_only ), except changing its card ( PATCH …/payment-method ), which is not one of the plan's locked terms; if you pass your own authorizeMutation , the default is replaced and your function must check totalPayments != null itself. (Throttle's separate customer-session storefront endpoints, documented under Storefront Auth , enforce this server-side and return 403 installment_plan_merchant_only for a buyer session.) Next React Package — full hook reference."},{"title":"Creating Subscriptions","route":"/developers/subscriptions/creating-subscriptions","headings":["Creating Subscriptions","Pattern A — Server auto-creates (default)","Pattern B — Merchant backend creates explicitly","Pattern C — Webhook-driven creation","Choosing","Direct creation (no embed)","Free and usage-only plans (amount 0)","Linking the order that paid for the first period","Custom cadence and installment plans","Next"],"text":"Subscriptions Creating Subscriptions Three patterns map to three integration trade-offs. Pick the one that fits how much control you want over the create call. The plan , planName , amount , and interval values you pass on the recurring block come from your application — not from Throttle. We treat planReference as opaque text scoped to your merchant and never validate it against any list. Two workspaces can both have a pro_monthly reference without collision. Most teams keep plans in their own database (a plans table or a static config in the repo). That source-of-truth is what your storefront pulls from to render the plan picker, and what your backend forwards into the recurring block. A managed plan catalog inside Throttle is not part of v1. Pattern A — Server auto-creates (default) Set recurring.create: auto (the default) and Throttle creates the subscription atomically when the card is vaulted. Your onSucceeded callback fires with subscriptionIds (and subscriptionId when exactly one was created). Use this when you want the path of least resistance. The merchant writes zero code on the success path — vault, sub create, and notification all happen on Throttle s side. Throttle treats sub-create-after-vault as a single transactional unit. If the sub create fails, the vault row is still written (the buyer s card is on file), but no subscription exists. Throttle emits subscription.create_failed with a reason code. Listen for it and decide your fallback: retry from your own backend, contact support, or surface the error to the buyer. A checkout creates all of its subscriptions together or none of them. If any create fails, none exist, the order still stands (the money was captured), and one subscription.create_failed fires per subscription line, carrying its lineItemId . Pattern B — Merchant backend creates explicitly Set recurring.create: manual when you want full control. The embed only vaults; you call subscriptions.create yourself. Use this when you need to look up plan details from your own DB, run business logic before subscribing, or attach merchant-specific metadata that the embed cannot know about. Pattern C — Webhook-driven creation Use the payment.vaulted webhook. Survives a buyer closing their tab between vault and create. Recommended when reliability matters more than latency. Combine with create: manual on the embed: auto-mode and webhook-driven creation both racing to call create would double-bill (Throttle de-dupes, but it s still cleaner to pick one). Choosing subscriptions.create with a pre-vaulted payment method ID , ] ] /> Direct creation (no embed) For B2B flows where you ve already collected a card via another mechanism (a sales rep enters it through your admin tool), call subscriptions.create directly. A subscription created this way has no card of its own ( paymentMethodId: null ), so it renews on the customer's default card. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the renewal cron has nothing to charge. Subscriptions Throttle creates from a checkout store the card the buyer paid with instead; see Which card a renewal charges . Free and usage-only plans (amount 0) A subscription at amount: 0 renews every period for nothing, so Throttle treats 0 as a mistake unless you say otherwise. POST /api/v1/subscriptions , PATCH /api/v1/subscriptions/:id and POST /api/v1/subscriptions/:id/change-plan refuse amount: 0 with 400 invalid_amount unless the same request sends allowZeroAmount: true . Send it for a free tier or a plan billed only through usage. The flag applies to that one request and is not stored; subscriptions already at 0 keep renewing. A $0 subscription created without an orderId gets no invoice for its first period, the same as a $0 renewal : GET /api/v1/subscriptions/:id/invoices stays empty until a period costs money. With an orderId the first period keeps its $0 invoice, linked to that order. subscription.created fires either way. Checkout cannot start a $0 subscription. A session recurring.amount is the renewal price and must be above 0 when sent ( 400 invalid_amount ), and so must a cart subscription line's unitPrice ( 400 invalid_line_item ). For a free trial, send the price the plan renews at and set trialDays . Linking the order that paid for the first period When Throttle creates the subscription (Pattern A) it links the signup order itself. When you charge the first period and then create the subscription (Pattern B, Pattern C, or a proxy checkout you completed yourself), nothing connects the two — so that order shows no subscription, and the subscription's Orders tab is missing its own signup charge. Pass orderId to fix it: Throttle writes the link both ways: the order gets a subscriptionId of its own (and keeps carrying the legacy metadata.subscription_id pointer), and the subscription's first invoice records the orderId that collected it. So the order links to the subscription and GET /v1/orders?subscriptionId=... returns it. The order must exist in the same application and environment; if it does not, the call returns 404 and no subscription is created. Renewals need no orderId : the renewal cron creates those orders and stamps them itself. An order Throttle created with several subscriptions lists them in subscriptionIds ; subscriptionId is set only when there is exactly one. Passing such an order as orderId — one whose lines already link other subscriptions — creates the subscription but leaves the order's links unchanged, so the order never appears to have only the new one. Custom cadence and installment plans Every create path above accepts a cadence beyond the five named intervals, and an optional fixed number of payments: interval (named), or intervalUnit + intervalCount for any cadence from 1 hour to 1 year, or both together (they must agree). Full guide: Custom Cadence → totalPayments (2–60) makes it an installment plan. The paid signup is payment 1"},{"title":"Custom Cadence","route":"/developers/subscriptions/custom-cadence","headings":["Custom Cadence","Named and custom cadences","Validation","Labels","How renewal dates are computed","Month-end clamping","Hour and day cadences","When the renewal job falls behind","Pause and resume","Trials","Changing the cadence","End to end","Checkout: every 10 days","Direct: every 6 hours","Reading custom cadences","TypeScript SDK","React","Errors","Edge cases","Is "every 4 weeks" the same as monthly?","How is a custom cadence valued for MRR?","What time of day does a plan renew?","My Jan 31 subscription bills on the 3rd. Why?","Does a slow renewal-job run shift an hourly plan?","Can a buyer choose the cadence at checkout?","Next","→ { \"data\": { \"id\": \"c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21\", ... }, \"meta\": { ... } }","2. A checkout session that bills 49.00 USD every 10 days.","Then create the session with no \"recurring\" block: the line carries it.`;","it records the period you pass as already paid, and the renewal job bills","every period after it. The customer needs a default vaulted card.","→ data.pendingInterval \"biweekly\", pendingIntervalUnit \"week\", pendingIntervalCount 2,","pendingAmount 9900. subscription.plan_change_scheduled fires.`;"],"text":"Subscriptions Custom Cadence A subscription can bill every N hours, days, weeks, or months — every 6 hours, every 10 days, every 3 weeks, every 2 months — not only on the five named intervals. This page covers what you can send, what Throttle stores, how it labels a cadence, and how it computes each renewal date. Named and custom cadences Every subscription stores its cadence as a unit and a count: intervalUnit ( hour , day , week , or month ) and intervalCount (how many). The interval field names the cadence when it is one of the five named intervals, and is "custom" for everything else. The named intervals are shorthand: You send one of three shapes, and every response carries all three fields: The server reduces what you send to its largest exact unit, then names it when a named interval matches. Hours become days when the count is a multiple of 24, and days become weeks when it is a multiple of 7. Weeks never become months and months never become anything else, because a month is not a fixed length. So every 24 hours is stored as custom / day / 1 , every 14 days as biweekly , and every 4 weeks as custom / week / 4 (28 days — never monthly ). Each cadence has exactly one stored form, so filtering by interval=biweekly also finds a subscription you created as "every 14 days". A cadence is accepted in five places, with the same rules in each: POST /api/v1/subscriptions — required: send a named interval or a unit and count. recurring on POST /api/v1/checkout/sessions — checked when the session is created, so a bad cadence fails for you, not for a buyer at the end of checkout. recurring on a cart line ( POST /api/v1/carts/{id}/items ) or a quote line — checked when the line is added. Each such line becomes its own subscription; see Mixed Carts . POST /api/v1/subscriptions/{id}/change-plan — required, as on create. PATCH /api/v1/subscriptions/{id} — optional; a term-only edit (see Changing the cadence ). Validation The shortest cadence is 1 hour and the longest is one year, measured in the unit you sent: at most 8,760 hours, 365 days, 52 weeks, or 12 months. The bound is checked before the reduction, so 8,784 hours (366 days) is refused even though it would reduce to days. Every message below is the exact error.message . invalid_interval — A cadence can be at most one year: 8760 hours ], [ code: intervalUnit: day , intervalCount: 366 , html: 400 invalid_interval — A cadence can be at most one year: 365 days ], [ code: intervalUnit: week , intervalCount: 53 , html: 400 invalid_interval — A cadence can be at most one year: 52 weeks ], [ code: intervalUnit: month , intervalCount: 13 , html: 400 invalid_interval — A cadence can be at most one year: 12 months ], [ code: intervalCount: 0, -1, or 1.5 , html: 400 invalid_interval — intervalCount must be a whole number of at least 1 ], [ code: intervalUnit: minute or year , html: 400 invalid_interval — intervalUnit must be one of hour, day, week, month ], [ code: interval: daily or fortnightly , html: 400 invalid_interval — Unknown interval "daily" . There is no named daily interval; send day × 1 . ], [ code: intervalUnit without intervalCount, or the reverse , html: 400 invalid_interval — intervalUnit and intervalCount must be sent together ], [ code: interval: custom alone , html: 400 invalid_interval — A custom interval needs intervalUnit and intervalCount ], [ No cadence field at all (create, change-plan) , html: 400 invalid_interval — Send an interval, or intervalUnit and intervalCount ], [ code: interval: monthly , intervalUnit: week , intervalCount: 4 , html: 400 interval_conflict — interval "monthly" does not match every 4 weeks ], [ code: intervalCount: 3 (a string) , html: 400 validation_error — the body schema requires a JSON number ], ] /> Errors use the standard envelope: Labels @usethrottle/subscriptions exports two helpers so every surface words a cadence the same way. cadenceLabel is a standalone label for an Interval column; cadenceNoun is the period that reads correctly after a slash, as in $49.00 / 10 days . The table shows what the server stores for each input and what the helpers return for that stored row. The helpers read the fields as given; they do not reduce them. Called on a Throttle response they always match the table above. Called on raw input, cadenceLabel({ interval: custom , intervalUnit: day , intervalCount: 14 }) returns "Every 14 days", while the server stores and returns that cadence as biweekly . A value the helpers do not recognise is returned unchanged rather than dropped. How renewal dates are computed Renewal dates sit on a grid measured from billingAnchorAt , which every subscription response carries. Each period end is computed from the anchor, never chained from the previous period, so a rounding or clamping step can never compound. Hour, day, and week cadences advance by exact UTC durations: 3,600 s, 86,400 s, or 604,800 s times the count. Every 10 days is always exactly 240 hours. Month cadences land on the anchor's day of month and UTC time of day, in the month anchor month + k × count , with the day clamped to that month's last day. See Month-end clamping . The first period. A subscription created by checkout starts when the buyer pays and its first period is exactly one cadence long. On POST /api/v1/subscriptions you pass currentPeriodStart and currentPeriodEnd ; billingAnchorAt is set to currentPeriodStart (or to trialEnd when there is a trial). currentPeriodEnd must be after currentPeriodStart and in the future, or the create is 400 validation_error . The renewal job picks up a subscription once its currentPeriodEnd has passed and decides which period the charge opens: Billing restarts when a trial converts, when a failed payment is recovered, and when a scheduled change of cadence applies. Everything else is an ordinary renewal. The anchor stays where it is except in these cases: effective: now ) , "},{"title":"Customer Identity","route":"/developers/subscriptions/customer-identity","headings":["Customer Identity","Pattern A — externalCustomerId (recommended)","Pattern B — store Throttle's customerId","Pattern C — email-only","Keeping your DB in sync via webhooks","Looking up a customer by your own ID","What changes in v1","Next"],"text":"Subscriptions Customer Identity Subscriptions are scoped to customers. Throttle gives you three patterns for identifying a customer; the recommended one means you never store Throttle s IDs. Use Pattern A — externalCustomerId . Pass your own user ID everywhere. Throttle resolves it on every call. No new column on your users table. Pattern A — externalCustomerId (recommended) You give Throttle your own user ID at checkout time. Throttle upserts a customer record keyed on (workspaceId, externalId) . Subsequent checkouts and API calls reuse the same record automatically. No schema change in your DB. Idempotent — calling create twice for the same external ID does not duplicate. Your code reads naturally; user.id is the identifier everywhere. Pattern B — store Throttle s customerId Create the Throttle customer explicitly and persist the returned cus_xyz ID in your user table. Slightly faster on hot paths because Throttle skips the upsert lookup, but you own a new column. Best when you call subscription endpoints often and want to skip the resolve step. Adds one column to your DB ( users.throttleCustomerId ). Requires you to handle the user exists in your DB but not yet in Throttle case at signup time. Pattern C — email-only If your storefront accepts guest subscriptions (no merchant-side user account yet), you can pass customer: email alone. Throttle upserts on (workspaceId, email) . Works for the create flow, but management is awkward — you need to look up by email to act on the subscription later, and the buyer cannot easily prove their identity from a self-service portal. Migrate guests to Pattern A as soon as they create an account. Any checkout with a subscription line needs a customer: customer.email , customer.externalCustomerId , customer.customerId or customerEmail on the session, or session creation answers 422 recurring_customer_required . An accepted quote uses the email of the buyer who accepted it. Keeping your DB in sync via webhooks The customer.created event fires whenever a new customer record is created — directly via API, indirectly via checkout resolution, or anywhere else. If you want to mirror Throttle s customer IDs into your DB without explicit creation calls (Pattern A → Pattern B without extra round trips), subscribe to the event. Looking up a customer by your own ID New endpoint: GET /api/v1/customers/by-external/:externalId returns the canonical customer record (or 404). Use this when you need to fetch the Throttle row but only have your own user ID. What changes in v1 GET /api/v1/subscriptions accepts an externalCustomerId query parameter (mutually exclusive with customerId ). GET /api/v1/customers/by-external/:externalId is new. Pass your own ID, get the Throttle record back. The @usethrottle/subscriptions hooks accept either customerId or externalCustomerId as a scope. Next Buyer Portal — how the proxy pattern uses externalCustomerId end-to-end. React Package — hooks accept both ID forms."},{"title":"Installment Plans","route":"/developers/subscriptions/installment-plans","headings":["Installment Plans","How it works","Lifecycle","A 4-payment plan, start to finish","Counting payments","Creating one","Hosted or embedded checkout","A cart with goods and an installment plan","Directly, with the API","Quotes","Custom schedules","Gap units","When each payment is due","Tax, shipping, orders","Discount codes","What is locked","Reading the schedule","Schedule errors","Reading them","What you can change","Merchant API (secret key)","Creation rules","Buyer surfaces","After completion","Failed installments","Cancelling and refunding","Webhooks and emails","SDK and React","Errors","FAQ","Can a buyer pay the rest early?","Can I turn an ongoing subscription into an installment plan, or the reverse?","What if the renewal job was down for several periods?","Is each installment taxed?","Does pausing extend the plan?","Can a custom schedule have a free trial or an equal-payment count?","Can I show "Payment 2 of 4" myself?","What does an older SDK do with a completed plan?","Next","→ { \"data\": { \"id\": \"c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21\", ... }, \"meta\": { ... } }"],"text":"Subscriptions Installment Plans An installment plan is a subscription with a fixed number of payments — "4 payments of $100 every 15 days". It bills like any subscription, then stops by itself: the moment the last payment succeeds it becomes completed and is never charged again. How it works Set totalPayments (a whole number from 2 to 60) when you create the subscription. It cannot be added, changed, or removed later. The plan uses any cadence, named or custom — see Custom Cadence . The signup charge is payment 1 of N. paymentsMade counts the payments collected so far. When payment N succeeds the status becomes completed , completedAt is set, no further renewal is scheduled, subscription.completed fires, and the buyer gets a paid-in-full email. completed is terminal. The amount, cadence, quantity, and plan are fixed. There is no trial and no metered usage. Only you can pause, resume, or cancel it. The buyer cannot, from Throttle's buyer portal or its storefront API. If your app grants access while status === active , add completed . A completed plan's currentPeriodEnd is the end of the period the final payment covered; whether access continues after it is your decision. Lifecycle A merchant can cancel from active , paused , or past_due . A past-due installment is recovered by a retry, a buyer card update, or a waive; the fourth failed attempt cancels the plan. A plan created by a session intent on a quote or deposit-split cart starts without payment 1: see Counting payments . Everything else about active , past_due , paused , and cancelled is the same as for an ongoing subscription; see Lifecycle and States . A 4-payment plan, start to finish Four payments of 100.00 USD every 15 days, bought on October 1 at 09:00 UTC: The plan completes on November 15, when payment 4 succeeds — not on November 30, when the period it pays for ends. Nothing is charged on November 30. Counting payments POST /api/v1/subscriptions , html: Starts at 1 — the route books period 1 as paid ], [ Checkout with a session recurring intent on a quote or deposit-split cart, whose charge does not include the plan , Starts at 0; the first renewal is payment 1, so all N payments come from renewals ], [ A renewal charge succeeds (the renewal job, a merchant retry, or the buyer paying a past-due installment) , +1 ], [ A past-due period is waived , +1 — a waived installment counts, and a waived final installment completes the plan ], [ A charge is declined , Unchanged ], [ An installment is refunded , Unchanged ], [ Periods skipped because the renewal job fell behind , Never counted ], [ The plan is paused , Waits — nothing is charged or counted while paused ], ] /> installmentProgress in @usethrottle/subscriptions turns those fields into progress. remainingCents is remainingPayments × amount × quantity on an equal plan. Pass the subscription itself (it carries paymentSchedule ) and on a custom schedule it is the sum of the unpaid payments' net amounts instead. Both are before tax. The buyer sees the schedule before paying. The hosted checkout page reads "Pay 100.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 400.00 USD." — with "plus applicable tax" added when the application taxes renewals, and without "Cancel anytime". With a discount code, only the first charge is discounted: "Pay 80.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 380.00 USD." When the charge today does not include the plan it reads "Then 4 payments of 100.00 USD every 15 days, starting at the next billing date. Total: 400.00 USD." Creating one Hosted or embedded checkout Put totalPayments on the session's recurring block. With the default create: auto , Throttle charges payment 1 and creates the plan when the buyer pays; the embed's onSucceeded receives subscriptionIds (and subscriptionId , set when exactly one subscription was created). The session is validated when you create it: a bad totalPayments or a trial fails then, not at the end of checkout. With create: manual , checkout takes payment 1 and vaults the card, and your backend creates the plan. Pass the checkout's orderId so the order and the plan point at each other: A cart with goods and an installment plan Put the plan on a cart line: recurring with totalPayments on POST /api/v1/carts/{id}/items , with the installment amount as the line's unitPrice . Leave recurring off the session. The first charge covers the goods plus installment 1 ( paymentsMade: 1 ); renewals charge the installment only. Each such line becomes its own subscription, so one checkout can sell several plans, up to ten. See Mixed Carts for pricing, shipping, and refunds on that shape. The session recurring block still works for a single plan on a cart of goods. It needs amount whenever the cart holds other lines ( 422 recurring_amount_required ), and it cannot be combined with subscription lines on the same cart ( 422 recurring_source_conflict ). A cart or order line item's recurring.count is read-only display data ("· 4 payments"): it mirrors the line's totalPayments , and the cart item endpoints do not accept it on input. On a line, recurring.totalPayments is the installment count. To cap the number of charges, send totalPayments in a cart or quote line's recurring block, on the session's recurring block, or on POST /api/v1/subscriptions . Directly, with the API The route records period 1 as a paid invoice and sets paymentsMade: 1 , on the basis that you already collected it. If you did not, the buyer is charged only N − 1 times. Charge the signup first, pass its orderId when an order records it, and make sure the customer has a default vaulted card — the renewal job charges that card. Quotes A quote line can carry the same recurring block, with totalPayments . Paying the accepted quote takes installment 1 in the same capture as the rest of the q"},{"title":"Lifecycle and States","route":"/developers/subscriptions/lifecycle-and-states","headings":["Lifecycle and States","Billing intervals","Installment plans","States","State diagram","Trial behavior","Renewal","Which card a renewal charges","Dunning","When a renewal can't complete","Renewal reminder","Grace period — your call, not Throttle's","Plan changes","Upgrades — effective: "now"","Downgrades — effective: "period_end"","Cancelling a pending change","Precedence vs. cancel-at-period-end","Credits and refunds","Next"],"text":"Subscriptions Lifecycle and States How a subscription moves through its life. Understand the state machine, the renewal cron, and the dunning policy before you wire your webhook handlers. Billing intervals Throttle bills on any cadence from 1 hour to 1 year, not only the five named intervals ( weekly , biweekly , monthly , quarterly , yearly ). Send a named interval , or intervalUnit ( hour , day , week , or month ) + intervalCount , on the checkout session's recurring block, on a cart or quote line's recurring block, on POST /api/v1/subscriptions , or on change-plan . The server stores one canonical form — every 14 days comes back as biweekly , every 24 hours as custom / day / 1 — and every response carries interval (the name or "custom" ), intervalUnit , and intervalCount . An invalid cadence is 400 invalid_interval ; a named interval that disagrees with the unit and count is 400 interval_conflict . Periods are anchored, not chained. Each period end is computed from billingAnchorAt . Month cadences keep the anchor's day of month, clamped to short months: a Jan-31 monthly subscription renews Feb 28 (29 in a leap year), then Mar 31. Hour, day, and week cadences advance by exact durations. The anchor moves when billing restarts — trial conversion, a recovered payment, an immediate plan change or a scheduled cadence change, or a resume, which shifts it by the time spent paused. biweekly means every 14 days — 26 charges a year, not twice a month. Mixed-interval pricing (e.g., Pro Monthly + Pro Yearly with a 20% discount on yearly) is your job — you store the two plans in your own catalog and pass the right amount / interval to Throttle. We don't model plan tiers; see Creating Subscriptions for why. Plan changes can swap the cadence via POST /api/v1/subscriptions/:id/change-plan (rejected on an installment plan). An outage or a stalled retry can leave a subscription more than one period behind. Throttle charges it once, for whichever grid period contains the moment of the charge — the periods it missed are skipped, never billed back-to-back, and never counted toward an installment plan's totalPayments . Full guide: Custom Cadence → — every validation rule, the label helpers, the renewal-date algorithm, month-end clamping, hour cadences, trials, and cadence changes. Installment plans Set totalPayments (2–60) on the checkout session's recurring block, on a cart or quote line's recurring block, or on POST /api/v1/subscriptions to make a subscription an installment plan — a fixed number of payments that ends itself. The paid signup is payment 1 of N, and the plan becomes completed the moment payment N succeeds, emitting subscription.completed and a paid-in-full email. Merchants who gate access on status = active must treat completed as paid in full. totalPayments is set at creation only ( 400 total_payments_immutable ), an installment plan cannot have a trial ( 400 invalid_combination ), and its amount, cadence, quantity, and plan are fixed ( 409 installment_plan_locked ). Only the merchant can pause, resume, or cancel it; buyer storefront sessions get 403 installment_plan_merchant_only . Payments can also differ: a custom schedule gives each payment its own amount and gap. Its later due dates are estimates: a late payment that succeeds, a pause, or a waive moves them, and nothing is skipped. A manual period renew of a scheduled plan is refused ( 409 installment_plan_locked ). Full guide: Installment Plans → — how payments are counted, the lock and completed-state matrices, failed installments, cancelling and refunding, webhooks, and SDK examples. States trialing — Created with trialEnd in the future. No charge has been attempted. Card is already vaulted. active — Most subscriptions live here. Renews on the period boundary. paused — Manually paused. Skipped by the renewal cron. Can resume back to active. past_due — A renewal charge failed. The subscription moves here on the first failed attempt and stays while Throttle retries (up to three retries; see Dunning below). While it is past due, currentPeriodEnd holds the time of the next scheduled retry, not the date the period was due. The buyer's renewal-failed email carries an Update your card button to a Throttle-hosted page ( checkout.usethrottle.dev/billing/{token} ) where they save a new card and the past-due period is charged on the spot; a successful charge returns the subscription to active and emits subscription.renewed like any other renewal. The link is signed for that customer and subscription and valid for 14 days. See Payment Methods → Subscription self-service for the routes behind it. cancelled — Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop. completed — Terminal. Reached only from active , when an installment plan's final payment succeeds. Renewals stop; pause, resume, cancel, and plan changes are rejected like cancelled . See Installment plans above. unpaid — Accepted by the status schema and the published SDKs for forward compatibility. The customer-facing renewal engine documented on this page does not transition a subscription to it today. State diagram Trial behavior Trials always require a vaulted card. The embed collects and vaults the card at signup; the cron simply waits until trialEnd passes before attempting the first charge. On trialEnd , the cron flips the row from trialing to active and emits subscription.activated . The first paid period begins immediately after — no second tick is needed. If the buyer cancels during the trial, the subscription transitions directly to cancelled with no charge. Throttle does not yet support trials with no card on file. The card-up-front model is simpler and avoids a second flow to attach a card before trial end. Throttle automatically blocks the same physical card from claiming multiple trials on a single merchant. Checkout checks the card's fingerprint against prior su"},{"title":"Managing Subscriptions","route":"/developers/subscriptions/managing-subscriptions","headings":["Managing Subscriptions","List and filter","Cancel","Immediate cancellation","Cancel at period end","Refund a billing cycle","Pause and resume","Change the card","Change where a physical subscription ships","Change plan","Immediate change","How the credit is worked out","Scheduled change","Cancelling a pending change","Seats and quantity","From the React package","Audit log","Never change a plan through checkout","Handle the 402","Next"],"text":"Subscriptions Managing Subscriptions Pause, resume, cancel, change plans, and change the card. Same primitives whether you re calling from your backend, the dashboard, or the React package. List and filter GET /api/v1/subscriptions returns cursor-paginated results. Filter by status , interval , customerId , or externalCustomerId . Use q to search subscription ids, customer ids, plan fields, status, interval, or metadata. Cancel Two flavors. Pick based on your buyer experience. The backend examples use @usethrottle/subscriptions/server with your secret key. Immediate cancellation Use when the buyer wants to stop right now. Because this takes away access the buyer may have already paid for, it is the one cancellation that can also move money: refund accepts none (default), prorated , or last_cycle . The refund is issued before the subscription is cancelled, so a payment failure leaves the subscription untouched rather than cancelling it with the money stranded. refund is rejected together with atPeriodEnd: true ( 400 validation_error ): at period end the buyer keeps the period they paid for, so there is nothing to give back. A prorated cancel on a period nothing was paid for (a waived period, say) still cancels, with a refund of 0 . A paused or past-due subscription is refunded the unused paid time from when it was paused (paused) or from now (past due). The refund is always capped at the cash paid for the period; if a period was paid but no unused time is left, the call is 422 invalid_state and nothing is cancelled. Asking for a refund on a subscription that is already cancelled or completed returns 409 invalid_state and refunds nothing. Cancel at period end Common pattern: the buyer keeps access until the end of what they paid for. Throttle sets cancelAtPeriodEnd: true . The renewal cron sees it on the next tick after the period ends and finalizes the cancellation. To restore a subscription that was scheduled to cancel, call POST /api/v1/subscriptions/:id/resume (it clears cancelAtPeriodEnd ; PATCH does not accept that field). (Calling cancel again with atPeriodEnd: false does not undo it — that cancels immediately.) After actual cancellation the subscription is terminal — create a new one. Cancelling, either way, also clears any pending plan or seat change on the subscription. Refund a billing cycle Every subscription charge produces a subscription invoice , and each one can be refunded on its own — the signup cycle, a renewal, or a plan-change charge. Refunding a cycle is a money operation only: it does not stop billing unless you say so. intent is required and has two values: money_only — the money goes back and the subscription keeps running. The next renewal charges as normal. refund_and_cancel — the money goes back and the subscription is cancelled immediately, in that order. Omit amount to refund everything still refundable on the cycle. Partial refunds accumulate: the invoice moves to partially_refunded , then refunded once the whole amount is back. A cycle that is already fully refunded, unpaid, failed, or $0 (waived or free) is rejected with 422 . A refund with money_only leaves an active subscription active — the renewal cron will charge again on schedule. If the buyer is leaving, use refund_and_cancel (or cancel separately). The same is true of refunding the cycle s order from the Orders API: the money goes back and the cycle is marked refunded, but the subscription keeps billing. The same holds for a checkout that created several subscriptions: no refund cancels the order or any of them, and an order refund that names no cycle comes out of the goods and shipping first, then across the cycles in proportion to what each still has refundable. Refunds are placed in the order they happened, each against what was left at that moment. Refunding through either surface reaches the same numbers. The amount refunded on a cycle is derived from the payment s own transaction ledger, so a refund taken on the order that collected the cycle updates the cycle and its invoice too — and the refundable amount the next call is offered already has it subtracted. When the order billed more than the cycle — a one-time setup fee charged alongside the first period — a refund pays down the non-recurring part first, and only what is left over reaches the cycle. Refunding a $40 setup fee on a $65 order that also carried a $25 first period leaves the cycle fully refundable; refunding the remaining $25 then closes it. Refunds carry no line-item attribution, so this is the direction that keeps a one-time refund from locking you out of refunding the subscription itself. Both intents emit subscription.invoice_refunded , whose payload carries the intent so your systems can tell a goodwill refund from a cancellation. refund_and_cancel additionally emits subscription.cancelled . Requires the payment_refunds:write scope. Pause and resume Pausing an active subscription transitions it to paused . The renewal cron skips paused rows entirely — no charge attempt, no dunning. Resuming returns it to active and pushes currentPeriodEnd forward by however long the subscription was paused, so the buyer gets back the days billing was stopped instead of being charged for them. Guards: pause requires status active . Any other status (including trialing , which has nothing billed to pause) is 400 invalid_subscription_state . resume on a paused subscription always unpauses it, whether or not it also has cancelAtPeriodEnd: true — the scheduled cancellation is left exactly as it was. On a subscription that is not paused, resume clears a scheduled cancellation ( cancelAtPeriodEnd: true ) in any non-cancelled status ( active , trialing , past_due ) and leaves the status alone; on an active subscription with nothing scheduled it is a no-op that returns the subscription. For cancelled and completed it is 400 invalid_subscription_state . A paused subscription that also has cancelAtPeriodEnd: true is still cancelled at its stored curren"},{"title":"Mixed Carts","route":"/developers/subscriptions/mixed-carts","headings":["Mixed Carts","Selling one","Starting checkout from an existing cart","What the buyer is charged","What you get back","The same plan twice, and seats","Shipping and the address step","Trials","Discounts","Invoices and emails","Refunds never stop billing","Cancelling","The session intent, for one plan","Errors","Limits","Next","2. Each line with a recurring block becomes ONE subscription.","3. The one-time goods, as usual."],"text":"Subscriptions Mixed Carts One cart can hold any number of one-time lines and up to ten subscriptions — the same plan or different ones, each on its own cadence, installment plan or trial. The buyer pays once, shipping is charged once, and the checkout lands on one order linked to every subscription it created. Selling one Put a recurring block on each cart line that should become a subscription. Leave recurring off the checkout session: the lines carry it. Throttle validates every subscription line when the session is created, so the total on the hosted page, the amount authorized in the embed and the amount settled are the same number. The same recurring block works on the Cart API , the browser cart session (whose hosted cart page shows each line s terms) and quotes . Starting checkout from an existing cart A checkout session can also be created from a cart that already exists — Throttle's hosted cart page ( /cart/<id> ), an abandoned-cart recovery link, or a plain cartId hand-off. These hand-offs carry no recurring block of their own, so Throttle recovers the plan's recurring intent from the cart before anything is charged: It reuses the intent of an earlier session on the same cart for the same plan, keeping the merchant's exact fields and create mode; or, if there is none, it rebuilds the intent from the cart's plan line (its plan reference, cadence, amount, and trial days). If the earlier session's cadence disagrees with the plan line the buyer sees and pays, the plan line wins. The shipping step is kept. The session inherits the earlier session's address collection; with none to inherit, it collects a shipping address whenever the cart holds any line besides the plan line. The cart-based default described in Shipping and the address step does not apply to a hand-off. A recurring session needs a buyer identity. A hand-off that supplies no email inherits the earlier session's. If there is none to inherit and the request names no customer.email , customer.customerId , customer.externalCustomerId , or customerEmail , it is refused with 422 recurring_customer_required ("Recurring checkout sessions require a customer email or customer identifier"). If the terms cannot be determined (the plan line has no plan reference or no valid cadence), the session is refused with 422 subscription_terms_undeterminable before the buyer is charged, so a buyer is never charged for a plan whose subscription would not be created. Fix the cart's plan line, or create the session with an explicit recurring block. This applies to native Throttle carts only, and only to the plan line Throttle writes for a recurring checkout ( type: "subscription" with metadata.subscriptionLineItem: true ). A line that carries its own recurring.plan brings its own terms and needs no session intent. A cart that holds both kinds (a legacy plan line and lines with their own recurring.plan ) is refused with 422 recurring_source_conflict , whatever the line order. What the buyer is charged What you get back subscriptionIds lists every subscription on the order, in line order, and each line s subscriptionId names the one it created. subscriptionId is kept for integrations written before this: it is set when the order has exactly one subscription and is null otherwise, so read subscriptionIds . The completion response, the embed s onSucceeded , and the order and payment webhooks carry the same pair. The ?subscriptionId= filter matches an order through any of its lines. The same plan twice, and seats Every subscription line is its own subscription. Two lines with the same plan make two independent subscriptions, which cancel, refund and renew separately. Nothing is merged, not even when a guest cart is merged into a signed-in buyer s cart. For several seats on one subscription, send one line with a higher quantity : quantity is seats, and each renewal bills unit price × seats. Shipping and the address step Subscription lines are never charged shipping. They are left out of weight, item-count and subtotal rules, so a free-shipping threshold is measured on the goods alone, and requiresShipping: true on a subscription line is rejected. A subscription line ships only when it says so with deliveryMethod: shipment : a physical subscription that ships a box every cycle, still with no shipping charge. A type: subscription line with no recurring never becomes a subscription, so it ships by the usual rules. Omit collect and Throttle decides from the cart: on a checkout with subscriptions, the address step is on when the cart holds at least one shippable one-time line or a physical subscription line, and off otherwise; any other checkout keeps asking. A physical subscription line refuses an explicit collect.shippingAddress: false with 400 shipping_address_required . A collect object that leaves out shippingAddress sets it to true . An explicit value always wins. A checkout with no shipping step still needs an address to tax when your application uses a tax provider on the strict fallback policy. In that case an omitted collect.billingAddress defaults to true , and tax is calculated from the billing address. See collection flags . Trials A free-trial line is charged nothing today. Throttle stores it at a unit price of 0 and keeps your price on recurring.amount for the renewals, the same way it stores the plan line for a trial on the session intent. A free-trial line cannot carry taxAmount or discountAmount . Trial eligibility is checked once per checkout, against the card s history before this checkout, and the answer applies to every trial line in it. When it is blocked, the whole checkout is refused with 409 trial_not_available before any order exists: no subscription gets a free first period, nothing is charged, and one subscription.trial_blocked fires with subscriptionId: null . The buyer can pay with another card, or you can offer the plan without a trial. Discounts A discount "},{"title":"Subscribe a Buyer in Five Minutes","route":"/developers/subscriptions/quickstart","headings":["Subscribe a Buyer in Five Minutes","Flow","1. Backend route — create the session","2. Frontend — render the embed","3. Webhook handler — react to lifecycle events","Next"],"text":"Subscriptions · Quickstart Subscribe a Buyer in Five Minutes End-to-end recurring billing with the Throttle embed. Three files: backend session route, frontend embed, webhook handler. You have a Throttle workspace, a secret key for your backend, a configured application, and an allowed checkout origin. If you don't, run through the payment quickstart first. Flow 1. Backend route — create the session The recurring block is the only difference from a one-time payment session. Throttle stores it on the session metadata and uses it to create the subscription when the embed reports a successful vault. 2. Frontend — render the embed The recurring prop on the embed is optional; when omitted, the session s recurring metadata still drives the backend. Pass it explicitly only if you want type-safe disambiguation in your client code. 3. Webhook handler — react to lifecycle events Webhooks are the source of truth for state. The embed callback is convenient but not durable — a buyer who closes their tab still triggers webhooks. Set recurring.create: manual if you d rather call subscriptions.create from your own backend after vault success. See Creating Subscriptions for the three patterns. recurring.interval: monthly above is the common case. Send intervalUnit + intervalCount instead (or as well) for any cadence from 1 hour to 1 year, and totalPayments (2–60) to make it an installment plan that ends itself after N payments — no trial allowed together with it. See Lifecycle and States for the bounds and Creating Subscriptions for the installment rules. Next Customer Identity — why externalCustomerId beats storing Throttle s customer ID. Lifecycle and States — what happens between renewals and what dunning looks like. Buyer Portal — give buyers a way to cancel without emailing support."},{"title":"React Package · @usethrottle/subscriptions","route":"/developers/subscriptions/react-package","headings":["{sub.planName}","React Package · @usethrottle/subscriptions","Install","Backend proxy","Provider","Server-side client","Hooks · queries","useSubscriptions(filters)","useSubscription(id)","useCustomerPaymentMethods(customerId)","Hooks · mutations","useCancelSubscription()","usePauseSubscription() / useResumeSubscription()","useChangePlan()","useCreateCheckoutSession()","useTrialEligibility()","Custom cadence and installment plans","Primitive components","Boundaries"],"text":"Subscriptions React Package · @usethrottle/subscriptions Production React hooks, server proxy helpers, and primitive components for managing Throttle subscriptions end-to-end from your buyer portal. Browser code should use the provider and hooks through your backend proxy. Backend code can import createSubscriptionsClient from the server entrypoint and use it with your secret key. The package never requires putting an sk_* key in browser code. Install Backend proxy The package ships a Next.js-compatible proxy helper at @usethrottle/subscriptions/server . It allowlists the subscription, customer-by-external, trial eligibility, and checkout-session paths, pins subscription reads, customer lookups, direct subscription creates, and checkout-session creates to the authenticated buyer's externalCustomerId , and verifies ownership before forwarding subscription mutations. Provider Mount a SubscriptionProvider high in your tree. The fetcher prop is the contract with your backend proxy — every hook routes through it. Server-side client Backend routes can use the server entrypoint as a typed subscription client without bundling React provider code. Use this for admin tooling, webhook handlers, or custom business logic that should run server-side. Subscription confirmation pages and account portals can also fetch vaulted payment-method summaries without reaching around the package. Hooks · queries useSubscriptions(filters) Lists subscriptions for a customer. Filters: customerId or externalCustomerId (one is required), plus optional status (now including unpaid and completed ), interval ( custom matches every non-named cadence), installment ( true for installment plans only, false for ongoing subscriptions only), cursor , limit . useSubscription(id) Fetches a single subscription. useCustomerPaymentMethods(customerId) Fetches saved payment-method summaries. When routed through the backend proxy, payment method reads are pinned to the authenticated buyer's customer row. Hooks · mutations All mutation hooks return a mutate, mutateAsync, isPending, error, data shape. On success they invalidate the relevant useSubscription / useSubscriptions caches. useCancelSubscription() usePauseSubscription() / useResumeSubscription() useChangePlan() A real plan change — prorated and charged immediately when effective: now , or scheduled for period end with no charge today. For a term-only edit with no proration and no charge (rename a plan, correct the amount on the row), call the client s update(id, input) instead. useCreateCheckoutSession() Bridge from the management package to @usethrottle/checkout-react s <PaymentEmbed /> . Wraps the /api/v1/checkout/sessions POST with recurring metadata. recurring is optional: omit it when the cart s lines carry recurring . useTrialEligibility() Calls POST /api/v1/subscriptions/eligibility-check through your proxy. Use it when your UI needs to decide whether to offer a trial after a payment method has already been vaulted. Custom cadence and installment plans Subscription carries the fields a custom cadence and an installment plan need: intervalUnit ( hour | day | week | month ) and intervalCount alongside interval (which now also accepts "custom" ), plus totalPayments , paymentsMade , and completedAt for installment plans (on an ongoing subscription totalPayments and completedAt are null and paymentsMade is 0 ). SubscriptionStatus adds unpaid and completed . Three helpers turn those fields into copy instead of every app re-deriving its own label map — the exact bug custom cadences were built to retire: cadenceNoun(sub) — the period noun after a slash: "10 days" , "2 weeks" , "month" . cadenceLabel(sub) — a standalone label: "Monthly" , "Every 10 days" . installmentProgress(sub) — null for an ongoing subscription, or paymentsMade, totalPayments, remainingPayments, remainingCents for an installment plan. create() and useCreateSubscription() accept intervalUnit + intervalCount alongside interval , and totalPayments (2–60) to create an installment plan. Full guide: Custom Cadence → — bounds, the label table, and renewal dates. Full guide: Installment Plans → — counting, locks, completion, and a progress component. Primitive components Three presentational components. They take a subscription (or a single field) as a prop and render appropriately. No secret-key API calls. <SubscriptionStatusBadge status= status /> — chip with state-specific colour. Accepts className . <TrialCountdown endDate= date /> — Trial ends in N days . Pass the trial end date. <DunningBanner subscription= sub onUpdateCard= fn /> — shown when status is past_due . Includes an action that calls your update-card flow. Boundaries Payment collection stays in @usethrottle/checkout-react . This package creates recurring sessions and manages the subscription lifecycle around the embed. Direct browser auth via customer-scoped tokens is not exposed yet. Use the proxy helper so your backend owns authorization and secret-key storage. Composite portal layouts are application code. Components here are primitives and accept className / style for your design system."},{"title":"Testing","route":"/developers/subscriptions/testing","headings":["Testing","Sandbox checklist","Manual smoke test","Trial-abuse replay","Webhook assertions","Automated test boundaries","Common gotchas","Prove webhooks before go-live","Next"],"text":"Subscriptions Testing Verify subscription integrations against Throttle's hosted sandbox surfaces and your own staging storefront. Treat Throttle as an external API: configure keys, origins, webhooks, and assertions the same way you will in production. Keep secret keys on your backend, use a non-production workspace/application for test runs, and never put a secret key in browser code. Browser code should only receive session identifiers and embed data returned by your backend. Sandbox checklist Before exercising a subscription flow, confirm the environment pieces below. The exact values come from your Throttle dashboard or onboarding handoff. Manual smoke test A passing smoke test proves that your backend can create the session, the browser can mount the hosted payment surface, and your webhook endpoint can process durable subscription events. Open your staging pricing or subscribe page. Select a plan that creates a recurring checkout session. Confirm your backend creates the session with a recurring block. Mount the Throttle payment or checkout embed from the returned session. Complete the payment with sandbox payment details for your account. Confirm your storefront receives the success callback and renders the subscription confirmation state. Confirm your webhook endpoint receives subscription.created and any payment events needed by your ledger. Trial-abuse replay To validate trial-abuse protection, repeat the subscription flow with the same physical card fingerprint and a different customer identity. The expected result is a subscription without the requested trial and a subscription.trial_blocked webhook. subscription.trial_blocked with a reason such as card_already_used_for_trial . , ], [ Customer experience , Your storefront should explain that the buyer is subscribed without a new trial. , ] ] /> Webhook assertions Subscription state is durable only after your webhook handler processes signed events idempotently. Store event ids before applying side effects and treat delivery as at-least-once. subscription.created plus the payment event your ledger expects. , ], [ Trial ends , code: subscription.activated , ], [ Renewal succeeds , code: subscription.renewed , ], [ Renewal fails , html: subscription.payment_failed and, on the first failed attempt, subscription.past_due . , ], [ Cancellation , html: subscription.cancelled with the expected data.reason . , ] ] /> Automated test boundaries Automated browser tests should validate your application's behavior around the embed: session creation, iframe mounting, event listeners, success routing, and webhook-driven state updates. They should not reach into the hosted payment iframe or depend on private Throttle implementation details. Common gotchas Origin not allowlisted. The iframe refuses to render until the parent storefront origin is configured for the merchant. Webhook signature mismatch. Verify against the webhook secret for the same merchant environment that sent the event. Duplicate webhook side effects. Throttle retries delivery; store event ids and make every handler idempotent. Expired checkout session. Create a fresh session for every buyer attempt instead of reusing old session ids. Testing inside the iframe. The payment surface is hosted and isolated. Assert the events it emits and the state your app persists. Prove webhooks before go-live A broken tenant lookup returns 200 on every delivery. Throttle s log shows delivered / 200 , your endpoint reports no errors, and nothing happens. If your checkout return path also syncs state, the user-present flow keeps working and hides it — until the first renewal, with nobody watching. Do not wait for a real customer to find out. Use a dedicated test tenant: Give it a subscription row and record the baseline. Create a Throttle customer whose externalId is that tenant s real id. Create or modify a subscription through the API. Assert your own database changed. Restore the tenant. Everything except a renewal can be driven headlessly this way — a browser is only needed for card entry. Renewal is the one path you cannot trigger on demand, so diarise the first one for about a month after cutover. Next Quickstart — create the first recurring checkout session. Subscription Webhooks — understand event payloads and delivery behavior. Trial Fraud Protection — see how trial eligibility is evaluated."},{"title":"Trial Fraud Protection","route":"/developers/subscriptions/trial-fraud-protection","headings":["Trial Fraud Protection","How it works","Scope is per-merchant","Pre-flight API","POST /api/v1/subscriptions/eligibility-check","Using it from your backend","The trial_blocked webhook","What this does NOT protect against","Disabling the check","Next"],"text":"Subscriptions Trial Fraud Protection Throttle ships a card-fingerprint check that detects the same physical card being reused across fresh customer accounts. The check runs automatically inside the auto-create flow and is also exposed as a pre-flight API endpoint for explicit gating. How it works Every payment vault writes the processor's stable payment_method.fingerprint onto the customer_payment_methods row. When a checkout grants a trial, Throttle reads the card's fingerprint from the payment before any order exists and checks whether a customer on this merchant with the same fingerprint already had a subscription with trialEnd set. The check runs once per checkout, and its answer covers every trial line. If a match exists, the checkout is refused with 409 trial_not_available ( details.reason: "card_already_used_for_trial" ). No order, no subscription and no saved card are created, and nothing is charged. The hosted checkout says "This card has already been used for a free trial." and shows the card form again, since another card may be eligible; your storefront can offer a plan without a trial. A subscription.trial_blocked event still fires, with subscriptionId: null . On the server-authorize rail (Secure Fields) the check runs after the authorisation and before capture; a non-zero authorisation is voided, and the pending order that rail creates first is reused if the buyer retries. A refused trial never gives a free first period. Before 2026-10-05 a blocked trial created the subscription active with its first period unpaid. If two checkouts with the same card race past the first check, the check after payment catches it: the subscription is created trialing with trialEnd set to now, the renewal job charges the first period on its next run (with tax, invoice, emails and dunning as for any trial conversion), and subscription.trial_blocked fires with the subscription id. For those few minutes subscription.created reports status: trialing . If the engine can't check (no fingerprint on the row, or the card was vaulted before the v1 schema migration), the trial is allowed . We surface this in the eligibility-check response as reason: 'no_fingerprint_available' so you can layer your own checks on top. Failing-closed would be catastrophic during the rollout — many legitimate cards in your existing customer base have no fingerprint yet. Scope is per-merchant A fingerprint that's been on Merchant A's trial does not block the same card from getting a trial on Merchant B. Different businesses, different fraud surfaces. Throttle deliberately scopes the check to (workspaceId, fingerprint) so we don't leak fraud signals across workspaces. Pre-flight API POST /api/v1/subscriptions/eligibility-check Server-to-server endpoint, secured with your API key. Useful when you want to render different UI before subscribing (e.g., hide the "Start free trial" button when the buyer is ineligible). Response: Using it from your backend The trial_blocked webhook When checkout refuses a trial (without you calling the eligibility endpoint), Throttle still emits a webhook so you have a hook point to react. On a refusal no subscription exists, so subscriptionId is null and the event carries the checkout instead: cartId , checkoutSessionId and planReference . Only the race backstop above sends it with a subscription id, in addition to subscription.created . @usethrottle/webhook-types 6.3.0 types subscriptionId and paymentMethodId as nullable. What this does NOT protect against Different cards from the same buyer. The fingerprint changes when the card changes. A buyer with three different cards can claim three trials. Disposable email signups. Block disposable email domains in your signup flow if your business cares; Throttle doesn't. IP / device velocity. Throttle doesn't see the buyer's IP. Add rate-limiting or a CAPTCHA at your edge if needed. Stolen cards. That's a payment-processor concern (provider auth-decline rules) — not a subscription concern. Disabling the check The check runs automatically when customerPaymentMethods schema is wired into the subscription service (default in production). There's no per-merchant toggle in v1 — if your business model wants unlimited trials per card, contact support and we'll discuss the right primitive. Next Lifecycle and States — trial timing semantics + dunning curve. Subscription Webhooks — full event reference (now includes subscription.trial_blocked )."},{"title":"Usage Reporting","route":"/developers/subscriptions/usage-reporting","headings":["Usage Reporting","How it works","Required scopes","Report usage","List usage","Multiple usage kinds","Billing at renewal","Next"],"text":"Subscriptions Usage Reporting Report metered usage against a subscription's current billing period. Usage items are billed on top of the base subscription amount at the next renewal. How it works Each usage item is identified by a kind string (e.g. api_calls , storage_gb ). There is exactly one row per (subscription, current period, kind) combination. Reporting the same kind twice replaces the previous value — it is an absolute upsert, not an increment. The engine computes totalCents = unitCount × unitPriceCents and stores it on the row. At renewal the cron sums all usage rows for the period and adds them to the base subscription charge. The base amount and total usage overage appear as separate line items in the renewal invoice. An immediate plan or seat change ( effective: "now" ) resets the current period to the moment of the change, so any usage already reported for the period it closes out moves forward to the new period start and is billed at the next renewal instead. A waived period forgives everything due for it, usage included — that usage is never billed later. See Lifecycle & states for both. Required scopes subscription_usage:write — report (upsert) a usage item. subscription_usage:read — list usage items. subscription_usage:write also grants read access. Report usage POST /api/v1/subscriptions/{id}/usage Body fields: kind (string, 1–64 chars, required) — usage category identifier. Use a stable, lowercase slug you control. unitCount (integer ≥ 0, required) — the absolute number of units consumed this period. unitPriceCents (integer ≥ 0, required) — per-unit price in the smallest currency unit (cents for USD). Set unitCount to the total consumed so far this period, not the delta. If you called this endpoint earlier with unitCount: 1000 and 500 more units were used, report unitCount: 1500 — not 500 . Usage can't be recorded on an installment plan (a subscription with totalPayments ): it has a fixed total, so the call returns 400 invalid_state and stores nothing. Usage is likewise refused on a cancelled or completed subscription ( 422 invalid_state ), and an unknown or cross-environment subscription id is 404 not_found . List usage GET /api/v1/subscriptions/{id}/usage Returns all usage items recorded for the subscription's current billing period. Response shape per item: id — UUID of the usage row. subscriptionId — parent subscription UUID. kind — usage category string. unitCount — units reported. unitPriceCents — per-unit price. totalCents — computed unitCount × unitPriceCents . periodStart / periodEnd — ISO 8601 timestamps bounding the current billing period. createdAt — when the row was first written or last upserted. Multiple usage kinds A subscription can carry any number of independently tracked usage kinds within a period. At renewal every kind's totalCents is summed and added to the renewal charge. Billing at renewal At renewal the subscription is charged its base amount plus the sum of all usage totalCents for the ending period, and each usage kind appears as its own line item on the renewal order and invoice. No prorations are applied — the full usage overage is billed in the renewal cycle it was accrued in. After a successful renewal all usage rows for that period are finalized; new usage accumulates against the next period. Each usage line item is written with type: "usage" . It needs no fulfilment ( deliveryMethod: "none" ), and its presence does not change the renewal order's own type — the order stays recurring , same as a renewal with no usage on it. usage is written by the renewal job only: it appears in order responses, never as a line item type you can send — POST /api/v1/carts/{id}/items , quotes, and manual order line-item edits all reject it. Usage recorded after the renewal cron runs for a period is attributed to the next period. Send your final tally before the periodEnd timestamp on the subscription. Next Managing Subscriptions — pause, resume, cancel, and change plan. Subscription Webhooks — event types fired at renewal and state changes. Scopes reference — subscription_usage:read / subscription_usage:write scope details."},{"title":"Subscription Webhooks","route":"/developers/subscriptions/webhooks","headings":["Subscription Webhooks","Event types","Example payload","Plan change scheduled (downgrade deferred)","Plan changed (upgrade applied or downgrade finalized)","Completed (installment plan paid in full)","Installment plans: paymentIndex and the schedule fields","The card and the payment","Reference handler","Delivery semantics","Common pitfalls"],"text":"Subscriptions Subscription Webhooks Webhooks are the source of truth for subscription state. Your DB stays in sync without polling, and you decide which events translate to buyer emails or in-app notifications. Throttle sends transactional emails to buyers automatically for the events that matter most — welcome, renewal receipt, renewal reminder, payment-failed, cancellation, plan-changed, and paid-in-full — so you don't need to build these from webhooks. See Lifecycle and States for the full list. Webhooks are still how you drive anything beyond that: your own additional buyer-facing message, delivered by your own provider (Resend, Postmark, SES), or an in-app surface like the <DunningBanner /> component. Event types trialing subscription flips to active at trialEnd . , Optional your trial ended, you are now subscribed email. , ], [ code: subscription.renewed , html: A renewal charge succeeded and the period advanced. Carries data.payment (the public payment ) and data.order when a charge was made. A period that cost nothing carries data.freeRenewal: true and no payment or order : none is created (see Renewal ). , Ledger entry. Receipt email (optional). , ], [ code: subscription.backup_pm_used , html: The card the renewal tried first was declined and another of the buyer's cards paid. data.backupPaymentMethod is cardBrand, cardLastFour of the card that paid. data.repinned: true when the subscription's own card hard-declined (expired, an invalid card number, lost or stolen): the subscription now renews on the card that paid, and data.subscription.paymentMethodId is that card. No separate subscription.updated is sent for that move. , Tell the buyer which card was charged; on repinned, that it is now the card for this subscription. , ], [ code: subscription.invoice_refunded , html: One billing cycle was refunded. data.intent is money_only (the subscription keeps billing) or refund_and_cancel (it was cancelled too). data.amountRefunded is what this refund moved, data.totalRefunded is the cycle s running total, and data.fullyRefunded says whether the cycle is now whole. The same event is recorded in the event feed ( GET /api/v1/events ) with entityType: "subscription" and entityId set to the subscription id, so ?entityType=subscription&entityId=<id> finds it. It is emitted for both refund routes: the dedicated cycle refund and cancel with a refund . , html: Ledger entry and refund receipt. Do not revoke access on money_only — wait for subscription.cancelled . , ], [ code: subscription.payment_failed , html: Each of the first three failed renewal attempts (the fourth cancels instead). Includes attempt , nextRetryAt , and lastError in the payload. , Payment-failed email. First failure is the most important. , ], [ code: subscription.past_due , html: The subscription moved to past_due : fires once, on the first failed renewal attempt. , Mark the account past due and show an update-card prompt. , ], [ code: subscription.updated , html: Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change . Also fires when the card a subscription renews on changes: the merchant picks a card for it ( PATCH /api/v1/subscriptions/:id/payment-method , or Change card in the dashboard), the buyer does ( PATCH /v1/storefront/me/subscriptions/:id/payment-method , or PATCH /api/v1/me/subscriptions/:id/payment-method from the billing link), or a new default card moves the subscriptions that were on the previous default. Picking a card for one subscription fires once, for that subscription only; through the merchant or storefront route, picking the card it already has fires nothing. data.subscription.paymentMethodId is the new card. Deleting a card a subscription uses does not fire it: the subscription silently falls back to the default card. , Confirmation email if buyer-initiated. , ], [ code: subscription.plan_change_scheduled , html: A plan change was deferred to the next renewal via POST …/change-plan with effective: "period_end" . The data.pending object contains the new plan details, and data.effectiveAt is the period-end ISO timestamp. , html: Notify the buyer: "your plan change to X takes effect on [date from effectiveAt ]". Show the pending change in your billing UI. If the change is later cancelled via DELETE …/pending-change , subscription.updated fires. , ], [ code: subscription.plan_changed , html: A plan change was applied. Fires in two situations: (1) an immediate upgrade ( effective: "now" ) charged and applied right away, or (2) a scheduled downgrade applied by the renewal cron at period end. In both cases, the data.subscription payload reflects the new plan fields and all pending* fields are null. An immediate change also carries data.proration ( creditCents , chargedCents , fullAmount ). , Update your local plan cache. Send a plan-change confirmation email. Grant or revoke feature access matching the new plan. , ], [ code: subscription.paused , html: Subscription transitioned to paused . , Suspend feature access (or leave on, depending on your model). , ], [ code: subscription.resumed , html: Subscription transitioned back to active . , Re-enable access. , ], [ code: subscription.cancelled , html: Terminal cancellation. data.reason is one of merchant_action , dunning_exhausted , or period_end . , De-provision. Send subscription-ended email. , ], [ code: subscription.completed , html: Terminal. An installment plan's final payment ( totalPayments ) succeeded — the moment payment N clears, not the end of the period it covers. Requires the subscriptions:read scope, same as every other subscription.* event. , html: Treat completed as paid in full even if your access gate only checks status = active . Send a "paid in full" confirmation. , ], [ code: subscription.trial_blocked , html: Trial-fraud protection refused a requested trial because the card already had"},{"title":"Team Management","route":"/developers/team-management","headings":["Team Management","Workspaces vs applications","Workspace concepts","Application concepts","Roles","Workspace roles","Application roles","Who can invite","Environment grants","Invite lifecycle","Token properties","Invite states","Accept flow","Member management","Workspace-level actions","Application-level actions"],"text":"Workspace Team Management Throttle organises access around workspaces and applications . A workspace is the billing and membership boundary; each workspace can contain multiple applications and multiple workspace environments. Role assignments exist at the workspace and application levels, while environment grants decide which production or non-production environments a member can see. Workspaces vs applications A workspace is the top-level container. It holds subscription and billing state, and every member with a workspace-level role can act across all applications inside it. An application maps to one storefront or integration surface. A workspace environment is the isolation layer shared across applications: production is immutable, and custom environments such as test, UAT, QA, and staging are non-production sandbox-provider environments. Workspace concepts One owner — the Clerk user who created the workspace. Unlimited workspace admins — implicit admin on all applications. Members with per-application roles. Shared billing across all applications. Environment grants for production, all non-production, or selected environments. Application concepts Scoped API keys, webhooks, and checkout sessions. Per-member roles: admin , developer , finance , viewer . Reads and writes are filtered by the active workspace environment. Roles Throttle checks two role axes for every request: the caller's workspace role and, for application-scoped actions, the caller's application role on the specific application being accessed. A permission is granted if either axis satisfies the rule — see the Permissions reference for the full matrix. Workspace roles owner — assigned to the workspace creator; one per workspace; cannot be transferred via the API (use the transfer ownership flow). Holds every permission, including workspace deletion and billing management. workspace_admin — full administrative access across all applications. Can invite members, edit member roles, and manage application settings. Cannot delete the workspace or access billing. Application roles admin — manage application settings, API keys, webhooks, and all member roles within the application. Can invite new members to the workspace with an application-scoped role. developer — access API keys, webhooks, customers, and orders (read and write). Cannot change application settings or member roles. finance — read customers, orders, payments; issue refunds. No write access to orders or customers. viewer — read-only access to customers, orders, and payments. Who can invite Invite rights depend on the inviter's own role. You can never grant a role higher than your own. Inviter role Can invite as Application scope owner Any role (including workspace_admin) Any application in the workspace workspace_admin Member with any application role Any application in the workspace admin (application member) Member with developer , finance , or viewer Only the applications they are admin on Only the owner can grant the workspace_admin role. This maps to the workspace:invite-admin permission. Environment grants Environment grants are workspace-wide. They sit beside roles: a member must have the right role for the action and access to the active environment. Owners and workspace admins can set grants on invites and member edits; application admins can invite members only into their current application and those invites default to all non-production environments. grantType Meaning all All active environments, including production and future custom environments. all_non_production Every active non-production sandbox-provider environment. production_only Only the immutable production environment. selected Only the listed environment UUIDs. A dashboard or Clerk-authenticated API request for an environment outside the member's grant returns 403 member_env_forbidden . Invite lifecycle Invitations are short-lived signed tokens. The flow from creation to membership is: Create — POST to /api/v1/workspaces/ workspaceId /invites with the invitee email and target role(s). Throttle mints a 7-day JWT and emails the invite link. Email sent — the invitee receives a link to /accept-invite?token=... from Throttle. The email address on the invite must match the Clerk account used to accept. Resend — POST to /api/v1/workspaces/ workspaceId /invites/ id /resend . Throttle rotates the token (old token is immediately invalidated) and sends a fresh email with a new 7-day window. Revoke — POST to /api/v1/workspaces/ workspaceId /invites/ id /revoke . The token is killed immediately and the invitation can no longer be accepted. Accept — see the accept flow section below. Token properties Signed JWT — tamper-proof and server-verified on accept. 7-day TTL from creation or last resend. Single-use — accepting invalidates the token. Strict email match — the signing email must equal the Clerk account email. Invite states pending — awaiting acceptance, token valid. accepted — member joined, token consumed. revoked — cancelled by an admin; token dead. expired — TTL elapsed before acceptance. Accept flow Accepting an invitation binds a Clerk user to the workspace membership record. The invitee clicks the link in the email and lands on /accept-invite?token=... . If not already signed in, the invitee authenticates via Clerk. The Clerk email must exactly match the address the invite was sent to — mismatches are rejected with invite_email_mismatch . The browser POSTs the token to POST /api/v1/invites/accept with the Clerk session JWT in the Authorization header. On success the member record is created, role assignments are applied, and the invitee is redirected to the workspace dashboard. If the accepting user is already a member of the workspace, the API returns 409 already_member . Existing roles are not modified. Member management After a member joins, their roles and access can be adjusted at any time by users with sufficient permissions. Workspace-level actions List member"},{"title":"Use Webhooks as Your Source of Truth","route":"/developers/webhooks","headings":["Use Webhooks as Your Source of Truth","Create an endpoint","Verify signatures","Rotate the signing secret","Process idempotently","Recommended fields","Event families","Abandoned carts & cart lifecycle","Payload envelope","Event payload reference","{group.label}","Payments and saved cards in payloads","No longer sent","Every read surface uses the same shape","Event scope reference","Retries","Manage endpoints","Inspect deliveries","Send a test event"],"text":"Event delivery Use Webhooks as Your Source of Truth postMessage events make the browser feel instant. Signed webhooks tell your backend what actually happened, even if the buyer closes the tab. Create an endpoint Register an HTTPS endpoint and choose the events your backend can process. Store the returned signingSecret securely; it is shown once. enabledEvents must be exact event-type strings from the catalog — Throttle uses payment.captured and subscription.cancelled , not Stripe-style payment.succeeded or subscription.expired . Fetch the full list from GET /api/v1/event-types . An unknown name is rejected with 400 ; the error’s details[].allowedValues enumerates every accepted event type. /api/v1/webhook-endpoints is the canonical, signed outbound webhook system documented on this page ( enabledEvents , a whsec_ signing secret, X-Throttle-Signature , plus /deliveries , /replay , and /test ). The older /api/v1/webhooks family ( events , no signed delivery) is deprecated and no longer delivers events — its delivery worker has been decommissioned. Responses carry Deprecation + Sunset: 2026-10-11 headers; the routes will be removed after 2026-10-11 . Migrate any integration still pointing at it to /api/v1/webhook-endpoints . Verify signatures Each delivery includes X-Throttle-Signature (format t=<unix_seconds>,v1=<hex> ). Compute HMAC-SHA256 over <timestamp>.<raw body> . The verifier below also rejects deliveries whose timestamp falls outside a tolerance window (default 300s) so a captured delivery cannot be replayed indefinitely, and guards against a malformed v1 so a bad header returns false instead of throwing in your handler. Signature checks fail if your framework parses and reserializes JSON before verification. Capture the raw request body first, then parse JSON after the signature passes. Verify the signature, then parse the envelope, and report the two failures with different status codes. If a strict schema rejects a field it has not seen and your handler answers with the same 401 it uses for a bad signature, a routine additive change looks like a rotated secret from the outside. That is exactly how an extension lost every delivery for hours on 2026-09-08 when environmentKind shipped. Throttle retries a 401 on the normal ladder; a 4xx that names a schema problem is far easier to find in the delivery log. Rotate the signing secret POST /api/v1/webhook-endpoints/:id/rotate-secret mints a new whsec_ and returns it once. For graceSeconds (default 86400, max 604800) every delivery is signed with both secrets — the header reads t=…,v1=<outgoing>,v1=<new> — so you can swap the secret in your config on your own schedule without a failed delivery. Send graceSeconds : 0 to revoke the old secret immediately when it has leaked. The response carries previousSecretExpiresAt . The verifier above accepts the header if any digest matches your secret, which is what makes the window seamless. A verifier that keeps only the last v1 still works — it verifies from the moment you deploy the new secret, and rejects until then, exactly as before the window existed. Process idempotently Webhook delivery is at-least-once. Store each event id before applying side effects so retries do not double-fulfill, double-email, or double-book revenue. Deduping on event id covers retries . It does not cover a genuine repeat: order.created fires exactly once per order, but every other order lifecycle event is at-least-once by design, and a repeat carries a new event id. Moving an order backwards is a supported merchant action, so fulfilled → processing → fulfilled sends order.fulfilled twice. That is deliberate — a merchant who rewinds and re-fulfils has usually re-shipped, and suppressing the second event would be the one case where a shipping notification must not go missing. Make handlers that must not act twice idempotent on the entity and target state, not on the event id alone. If a paid order has line items marked fulfillmentType: 'digital' or fulfillmentType: 'access_grant' , Throttle creates and completes those fulfillment rows after the order enters processing. Listen for fulfillment.completed and order.fulfilled in addition to payment events when you need to mirror delivery state. fulfillment.shipment.delivered now fires when the arrival is recorded — a call to POST /fulfillments/ id /delivered — and no longer when the shipment fulfillment is completed. Completing a fulfillment is carrier handoff, so the old timing announced a delivery while the parcel was still moving. Merchants who complete a fulfillment and never call the new endpoint will not see this event at all; listen for fulfillment.completed if what you want is handover. The payload gained the shipment object, so data.shipment.actualDelivery carries the arrival timestamp. The buyer-facing customer-order-delivered email follows this event, and so moved with it. Recommended fields event_id unique identifier from the delivery envelope. event_type for routing and debugging. event_version from the delivery envelope for payload migrations. environment_id from the delivery envelope for environment-scoped processing. processed_at timestamp after side effects finish. payload raw JSON for audit and replay. Event families OUTBOUND_EVENT_GROUPS.map((group) => ( group.label ( group.events.length events) )) Abandoned carts & cart lifecycle Carts are authoritative in Throttle. When your storefront creates a native cart ( POST /api/v1/carts ), Throttle tracks its whole lifecycle and detects abandonment server-side — you don't build your own timer. cart.abandoned — a nightly sweep marks an open cart abandoned once it sits idle past the application's configured threshold (or you can react immediately on delivery). Throttle also sends the buyer a customer.cart_abandoned recovery email when it can resolve an email and the app has recovery enabled — subscribe to the event only if you want to ru"},{"title":"Zapier","route":"/developers/zapier","headings":["Zapier","Connecting your account","Triggers","What a trigger hands the next step","Searches","Actions","When something goes wrong"],"text":"Tools Zapier The Throttle Zapier integration puts your orders, payments, subscriptions, quotes and invoices in front of every other app Zapier connects to. It authenticates with OAuth — no API key to copy, and no key to rotate when someone leaves. Zapier speaks REST and webhooks, not the Model Context Protocol. This is a separate integration over the same public API, and it needs no MCP setup. If you want Throttle inside Claude, Cursor or another AI assistant, use the MCP server instead — the two are independent and can both be connected at once. Connecting your account Add Throttle as a step in any Zap and choose Connect a new account . You'll be sent to Throttle to sign in, pick the workspace, application and environment the connection should use, and approve the permissions it asks for. A connection is pinned to the workspace, application and environment you picked at approval time, and nothing inside a Zap can move it. Connect a sandbox environment first and verify the Zap end to end there; to run the same automation against production, add a second Throttle account in Zapier. The account picker labels each one Workspace · Application · environment so the two are never confused. The permissions requested cover exactly the triggers and actions listed below — read access to orders, customers, subscriptions, quotes, invoices, payments, carts and the event log, write access to customers, quotes and carts, and permission to manage the webhook endpoints your triggers create. It also asks to read customers' personal data (names, emails, phone numbers and addresses, customer_pii:read ): that is what most Zaps move, and every trigger needs it, because a webhook delivery carries the full record. Throttle's AI-assistant connections can never be granted it; Zapier can because it is registered as an automation connector. Refunds, payment capture and other money-moving verbs are not requestable over OAuth and are deliberately absent from this integration. Triggers Every trigger is a real-time webhook, not a poll — a Zap fires within seconds of the event, and there is no polling interval to wait out. Turning a Zap on creates a webhook endpoint in your workspace subscribed to that one event type; turning it off deletes it. New order — order.created Payment captured — payment.captured Payment failed — payment.failed New subscription — subscription.created Subscription cancelled — subscription.cancelled Quote accepted — quote.accepted Cart abandoned — cart.abandoned Invoice past due — invoice.past_due You'll see these endpoints in your dashboard alongside any you made by hand. They're marked as owned by Zapier — delete the Zap rather than the endpoint, or the Zap will go quiet with no error. What a trigger hands the next step Throttle delivers this envelope: Zapier flattens it so the entity's own fields are available directly in the field mapper: id is the event id, because that is what Zapier deduplicates on and what stays stable when a delivery is retried. The order, payment or subscription's own id is resourceId — map that one when the next step needs to look the record up. Searches Search customers — by name, email or your own external id. Search orders — newest first, with status and payment-status filters. Search quotes — filter by status, e.g. to chase quotes about to expire. List invoices — issued invoices, filterable by status. Receivables aging — outstanding receivables bucketed by age. List subscriptions — filterable by customer and status. Actions Create a customer — email and external ID are each unique per application, so a buyer who already exists makes the step fail with customer_already_exists . Run Search customers first when the buyer may already exist. Update a customer — only the fields you fill in are touched; everything else is left alone. Create a quote — drafts a quote with line items and terms. It is not sent to the buyer. Create a checkout link — builds a cart from line items and returns a hosted checkout URL. Every amount is an integer in the smallest currency unit: 2500 is $25.00, and 25 is 25¢. A checkout link created on a production connection is buyer-facing and takes real payment. Actions carry an idempotency key derived from the Zap step and its inputs, so a retried run lands on the record the first attempt created rather than making a second one. When something goes wrong “Reconnect your Throttle account and approve this permission” — the connection was approved without a permission the step needs. A connection's permissions are fixed at approval time, so reconnecting is the only fix. “Allow access to customers' personal data” ( oauth_customer_pii_required ) — turning on a trigger creates a webhook endpoint, and deliveries include customer details, so the connection must hold that permission. Reconnect and approve it. An environment mismatch — the step is reaching for data in an environment this connection isn't pinned to. Connect the other environment as a second Throttle account. A Zap goes quiet — check whether its webhook endpoint still exists in your dashboard under Webhooks . Turning the Zap off and on again recreates it."},{"title":"Internal Support Module","route":"/internal/support","headings":["Internal Support Module","Routes","Auth","Workspace identity model","Webhook payload contract","See also"],"text":"Internal Internal Support Module Powers the in-dashboard support feature backed by dispatchtickets.com . These endpoints are not part of the public API surface for merchant integrations — only the Throttle dashboard consumes them. This page is intentionally excluded from the docs sidebar and the search index. Merchant API keys cannot call these routes and they are subject to change without a developer changelog entry. Routes All routes are mounted under /api/v1/workspaces/:wsId/support/* and require a Clerk JWT that resolves to a member of :wsId . Method Path Permission Notes GET /tickets support:read List tickets for the workspace. GET /tickets/:id support:read Ticket detail + comments. POST /tickets support:write File a ticket. Audit-logged. Idempotent. POST /tickets/:id/comments support:write Reply to a ticket. Audit-logged. Idempotent. POST /tickets/:id/read support:read Mark ticket read for the current user. GET /unread-count support:read Badge count for the current user. POST /attachments/presign support:write Issue a presigned upload URL. POST /attachments/:attachmentId/confirm support:write Finalise an upload once the PUT completes. Plus the public webhook receiver POST /api/v1/support/dispatch-webhook , which has no Clerk auth and is gated solely by the signed X-Dispatch-Signature header. Auth The workspace-scoped routes accept Clerk JWTs only . API-key callers receive 403 application_key_workspace_route because these endpoints sit under a workspace-scoped router rather than the application-scoped one. POST /tickets and POST /tickets/:id/comments require an Idempotency-Key header (UUID v4 recommended). The server falls back to a body-hash synthetic key if the header is missing, but stable client-supplied keys are required to make retries safe across browser refreshes and React strict-mode double-effects. Workspace identity model Each Throttle workspace maps to exactly one Dispatch customer record. The synthetic customer email follows the pattern workspace-<workspaceId>@support.throttle.internal . The real human filer identity is captured separately so support staff can reply to a person rather than the workspace: Ticket body footer line: Filed by: … Each comment carries authorName and authorId for the Clerk user that posted it. Ticket customFields include filer_user_id , filer_email , type , and browser_info . Webhook payload contract Dispatch posts events to /api/v1/support/dispatch-webhook with an HMAC signature header: X-Dispatch-Signature: t= ,v1= The signed payload is $ unixSeconds .$ rawBody . The server enforces a 5-minute replay window. Event types handled: comment.created — when authorType === 'STAFF' , the corresponding ticket row is marked unread for all workspace members. ticket.created — no-op locally; the filer already saw the ticket they just filed. ticket.updated / ticket.closed — propagated to the dashboard via the next list/detail read; no local caching layer needs invalidation. Other event types return 200 as a no-op. A bad signature returns 401 invalid_signature . For properly signed events, downstream errors are logged and swallowed with a 200 response to avoid Dispatch retry storms. See also Design spec: docs/superpowers/specs/2026-05-17-dispatch-support-feature-design.md Implementation plan: docs/superpowers/plans/2026-05-17-dispatch-support-feature-plan.md Runbook: docs/superpowers/runbooks/2026-05-17-support-module.md"}]}
|
|
1
|
+
{"generatedAt":"2026-11-07T12:38:11.801Z","entries":[{"title":"Docs","route":"/","headings":["Docs","Product docs","{c.title}","Developer docs"],"text":"Documentation Docs Look up how one thing works. Product docs explain what a feature does and where its edges are; developer docs are the API contract. New to Throttle? What Throttle stores → Integrating? Developer quickstart → Doing a job end to end? Guides by role → Product docs totalProduct pages on how each feature behaves — for operators, finance, and admins. productCards.map((c) => ( c.title c.blurb c.links.map((l) => l.label ) )) Developer docs The API, SDKs, webhooks, and extension platform. Building something new? Start with the developer overview . developerCards.map((c) => ( c.title c.blurb && c.blurb c.links.map((l) => ( l.label isExternal(l.href) && ↗ )) c.more && c.more.label )) Looking for what Throttle connects to? See integrations . For what each part of the product does, see features . .hub max-width: 1180px; margin: 0 auto; padding: 48px 24px 96px; .hub-head margin-bottom: 56px; .hub h1 font-size: clamp(34px, 4vw, 48px); line-height: 1.1; letter-spacing: -0.03em; margin: 8px 0 16px; .hub-lead font-size: 18px; line-height: 1.6; color: var(--muted); max-width: 62ch; margin: 0 0 28px; .hub-start display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; .hub-start a display: flex; flex-direction: column; gap: 4px; padding: 16px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); text-decoration: none; .hub-start a:hover border-color: var(--teal); .hub-start strong font-size: 15px; color: var(--ink); .hub-start span font-size: 14px; color: var(--teal); font-weight: 600; .hub-band margin-bottom: 64px; .hub-band-head display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; padding-bottom: 14px; border-bottom: 1px solid var(--line); margin-bottom: 22px; .hub-band-head h2 font-size: 24px; letter-spacing: -0.02em; margin: 0; .hub-band-head p font-size: 15px; color: var(--muted); margin: 0; .hub-band-head a color: var(--teal); .hub-grid display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; .hub-card padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: var(--paper); .hub-card h3 font-size: 17px; letter-spacing: -0.01em; margin: 0 0 6px; color: var(--ink); .hub-card > p font-size: 14px; line-height: 1.5; color: var(--muted); margin: 0 0 14px; .hub-card ul list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid var(--line); display: grid; gap: 8px; .hub-card li a font-size: 14.5px; color: var(--ink); text-decoration: none; .hub-card li a:hover color: var(--teal); .hub-more display: inline-block; margin-top: 12px; font-size: 13.5px; font-weight: 600; color: var(--teal); text-decoration: none; .hub-foot font-size: 15px; color: var(--muted); .hub-foot a color: var(--teal); @media (max-width: 960px) .hub-grid grid-template-columns: repeat(2, minmax(0, 1fr)); @media (max-width: 640px) .hub padding: 40px 16px 80px; .hub-grid, .hub-start grid-template-columns: 1fr;"},{"title":"Build a checkout that settles cleanly.","route":"/developers","headings":["Build a checkout that settles cleanly.","Choose Your Path","{title}","Canonical URLs"],"text":"Native checkout docs Build a checkout that settles cleanly. Throttle gives your storefront a hosted payment surface, server-side checkout sessions, signed webhooks, native carts, and shipping/tax calculation. Start the quickstart View API reference 1. Merchant backend Creates a checkout session with a secret API key. 2. Buyer browser Mounts the payment iframe and receives postMessage events. 3. Merchant webhook Receives signed durable payment and order events. Choose Your Path V1 focuses on native Throttle checkout and carts. Start with a payment-only embed, then add carts, shipping, tax, and webhooks as your integration grows. Building an app for other merchants to install rather than integrating your own store? That path starts with extensions. paths.map(( href, title, body, icon ) => ( title body Open guide )) Canonical URLs Examples use the branded Throttle URLs below. Keep these values on your backend unless the variable is explicitly prefixed for browser use."},{"title":"Analytics","route":"/developers/analytics","headings":["Analytics","Which one","Pages in this section"],"text":"Analytics Analytics There are two ways to get analytics off a Throttle checkout: Throttle s first-party tracking settings for GA4 and Meta, or your own script. Pick based on whether a vendor secret is involved. Which one Track conversions — the built-in path. Secrets (GA4 s Measurement Protocol API secret, Meta s Conversions API token) stay server-side. , ], [ Something Throttle does not build in — a custom collector, cookie-based attribution stitching, a vendor with no secret to protect , html: Your own scripts — merchant-authored JavaScript Throttle runs in an isolated sandbox on hosted checkout. , ], [ A product feed for Google Merchant Center / Meta Commerce Manager , html: Product feeds — Throttle stores no catalog, so this is a recipe for building one from your own catalog source. , ], ] /> Pages in this section Track Conversions — GA4 + Meta purchase events, server-side forwarding, clientContext attribution. Your Own Scripts — the register() contract, consent, external domains, and the management API. Product Feeds — feeds live with your catalog; keep referenceId equal to the feed item id."},{"title":"Product Feeds Live With Your Catalog","route":"/developers/analytics/product-feeds","headings":["Product Feeds Live With Your Catalog","Why Throttle does not generate feeds","Reference: Next.js feed route from a CMS","The ID-consistency contract"],"text":"Analytics Product Feeds Live With Your Catalog Throttle stores no product catalog by design — feeds for Google Merchant Center, Meta Commerce Manager, and other ad platforms belong to your catalog source. Why Throttle does not generate feeds Products are not stored in Throttle. Product and line item data arrives from your cart/checkout payload and is saved as cart and order line items — Throttle is the system of record for transactions , not for your catalog. A product feed needs the full catalog (every product, in stock or not, with images, descriptions, and category data), which only your catalog source has. Reference: Next.js feed route from a CMS A compact route handler that renders a Google Merchant Center RSS 2.0 feed from a CMS query. Meta Commerce Manager accepts the same format, so one feed URL usually serves both platforms. The ID-consistency contract Dynamic remarketing only works when the ids in your purchase events match the ids in your feed. Throttle carries your catalog id through checkout untouched — but only if you send it. The referenceId your cart integration sends Throttle on each line item (surfaced as sku on throttle.completed and used as content_ids / item_id in purchase events ) must equal the feed item id ( <g:id> ). If they diverge, Google and Meta cannot join purchases back to catalog items and dynamic remarketing silently degrades. Use one canonical id — typically the SKU — in both places. Same id everywhere: feed g:id , cart line item referenceId , and any pixel ViewContent / AddToCart events your storefront fires. Variants: if your feed lists variants as separate items ( g:item_group_id grouping), send the variant id as referenceId , not the parent product id. Server-side too: Throttle's server-side conversions build content_ids from the order line items' referenceId — a missing referenceId drops that item from the join."},{"title":"Track Conversions in GA4 and Meta","route":"/developers/analytics/track-conversions","headings":["Track Conversions in GA4 and Meta","How it works","Embed recipe","Hosted setup","Server-side conversions","Attribution capture (clientContext)"],"text":"Analytics Track Conversions in GA4 and Meta Fire purchase events from the embed with zero extra fetches, let the hosted page inject your pixels for you, or forward conversions server-side — with attribution captured end to end. How it works Where the purchase event fires depends on which checkout surface you use. In the embed, your page hosts the pixels; on the hosted page, Throttle injects them. throttle.completed (and onSucceeded in the React SDK) carries total , currency , and lineItems so you fire the purchase event yourself — no extra fetch. Throttle never injects pixels inside the iframe; that would double-count. , ], [ Hosted checkout page , Throttle — gtag.js and/or the Meta pixel are injected when tracking settings are configured and consent passes. , The page fires the purchase events automatically right before the post-completion redirect, using beacon transport so the events survive the navigation. , ], [ Server-side (either surface) , Nobody — Throttle forwards the purchase from its backend. , html: When enabled, Throttle POSTs the purchase to the GA4 Measurement Protocol and Meta Conversions API at the payment moment, using the attribution snapshot on order.clientContext . , ] ] /> Embed recipe Protocol v1.4 additively extends throttle.completed with total (minor units), currency , and lineItems . Convert minor units to major with toDecimalString(total, currency) from @usethrottle/money before handing values to analytics; dividing by 100 is wrong for JPY and KWD. Each line item's sku is the merchant catalog id (the cart line item's referenceId ). Use it as item_id / content_ids so purchase events join against your product feed for dynamic remarketing. The analytics fields are optional — payment-only sessions with no cart snapshot omit them, so guard with fallbacks as above. Hosted setup For the hosted checkout page, configure tracking once per application and environment via GET/PUT /api/v1/tracking-settings . Only the public ids reach the buyer's browser; secrets stay server-side and come back masked from GET . requireConsent defaults to true : without an explicit consent=granted parameter on the checkout URL, no pixels are injected and no browser events fire. An explicit consent=denied always wins, even when requireConsent is off. Server-side forwarding honors the same signal via clientContext.consent — analytics gates GA4 and marketing gates Meta. Server-side conversions Browser pixels miss buyers with ad blockers and tabs closed mid-redirect. Set serverSideEnabled plus the per-channel secrets and Throttle forwards the purchase from its backend at the payment moment: card payments at capture, Net-N invoices at authorization (issuance), and subscription renewal charges skipped — renewals are not new acquisitions. GA4 Measurement Protocol — sends a purchase event with transaction_id set to the order id, using the captured gaClientId / gaSessionId when available. Requires ga4MeasurementId + ga4ApiSecret (create the API secret under the GA4 data stream's Measurement Protocol settings). Meta Conversions API — sends a Purchase event with event_id set to the order id and user_data built from the SHA-256-hashed buyer email, server-stamped IP address and user agent, and the fbp / fbc cookies from clientContext . Requires metaPixelId + metaCapiAccessToken . or server — Measurement Protocol and gtag purchases do not reliably dedup. When server-side GA4 is on, the hosted page automatically suppresses its browser purchase ; embed integrators should likewise skip the browser GA4 purchase. transaction_id = orderId guards against page-level double-fires. , ], [ Meta , html: Both fire — the browser pixel and CAPI event are deduplicated natively by Meta via eventID = orderId . Keep the browser fbq Purchase in place; it improves match quality. , ] ] /> Forwarding failures are logged and not retried in v1. Treat server-side conversions as an analytics signal, not an accounting source of truth — use webhooks for durable side effects. Attribution capture (clientContext) POST /api/v1/checkout/sessions accepts an optional clientContext attribution blob. The hosted page also auto-captures what it can at completion (UTMs, click ids, GA and Meta cookies, landing page, referrer) and Throttle merges the two per key — completion-time values win — then server-stamps ipAddress and userAgent . The merged snapshot lands on order.clientContext (merchant order GET ) and on the order.created webhook payload. Accepted keys: landingPage , referrer , utmSource , utmMedium , utmCampaign , utmTerm , utmContent , gclid , fbclid , msclkid , ttclid , gaClientId , gaSessionId , fbp , fbc , and consent ( analytics?, marketing? ). Unknown keys are rejected; ipAddress / userAgent are server-stamped and rejected as client input. The buyer's first-party attribution cookies live on your domain, not the checkout domain. Capture UTMs, click ids, and the _ga / _fbp / _fbc cookies on your storefront, persist them with the cart, and send them as clientContext when your server mints the session. The hosted page's completion-time capture is a best-effort fallback."},{"title":"API Surface by Integration Task","route":"/developers/api-reference","headings":["API Surface by Integration Task","{group.title}"],"text":"Curated reference API Surface by Integration Task Start here for the endpoints most native checkout integrations need. Use the full OpenAPI reference when you need every schema and response field. Open full Swagger docs Download OpenAPI JSON endpointGroups.map((group) => ( group.title group.description group.endpoints.map((endpoint) => ( )) ))"},{"title":"Build Cart State That Checkout Can Trust","route":"/developers/cart-api","headings":["Build Cart State That Checkout Can Trust","0. Configure your application","Who owns each step","End-to-end cart flow","Shipping and tax","The cart is the source of truth for shipping and totals","Addresses","Discounts","Subscription lines","Resolved carts","Two add-item shapes","Owned lines","Open the hosted cart page for a cart","Native cart endpoints","{title}","unitPrice is per period, per seat; quantity is seats.","Stored as: type \"subscription\", name \"Barista Club — 14-day free trial\",","unitPrice 0, recurring.amount 2000, subscriptionId null (until checkout completes).`;","Add a line the app prices","The body is optional. 201 (new) or 200 (reused):","\"data\": {","\"sessionId\": \"cart_9f2c…\",","\"cartUrl\": \"https://checkout.usethrottle.dev/cart/cart_9f2c…\",","\"expiresAt\": \"2026-10-27T12:00:00.000Z\",","\"urls\": {","\"returnUrl\": \"https://shop.example.com/thanks\",","\"cancelUrl\": \"https://shop.example.com/cart\",","\"storefrontUrl\": \"https://shop.example.com\"","}","}`;"],"text":"Native carts Build Cart State That Checkout Can Trust Use @usethrottle/cart when Throttle owns the cart, totals, and checkout handoff for your application. 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 ) to allowedOrigins . 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 like sk_live_* . Every field on the wire is camelCase ( applicationId , unitPrice , referenceId ). Single shape — no snake_case aliases. See Conventions for the full rule. You don't have to plug in a payment processor to test cart → checkout end-to-end. Every new Application gets a sandbox payment sub-account on signup, so a checkout session minted with an non-production environment key renders the Card Simulator out-of-the-box. See Workspace environments for provider routing details. 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. 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 . The full request/response schemas for every endpoint on this page live in the public OpenAPI 3.1 document at api.usethrottle.dev/openapi.json . Open it in Swagger UI to try requests against a sandbox key, or pipe it into openapi-typescript / openapi-fetch for typed client generation. The pre-generated client lives at @usethrottle/cart . Most applications use native Throttle carts : create a cart with 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. Quote tokens start with qt_ (tokens minted before this change start with pk_ and remain valid indefinitely) and are scoped by application and allowed origin. They can estimate totals, but they cannot mutate carts or create orders. POST /api/v1/shipping-tax/quotes also accepts an X-API-Key carrying shipping_quotes:write instead of a quote token — see the API reference . 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. Pass customerId: null on update to detach the customer; omit it to keep the customer. Detaching does not clear customerEmail ; send customerEmail: null too. 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 same public 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 w"},{"title":"Cart sessions","route":"/developers/cart-sessions","headings":["Cart sessions","How it works","Set your allowed origins first","Create a cart session","Build the cart","Cart badge: just the number","Buyer details","Shipping & discounts","Hand off to checkout","Skip the UI: Throttle's hosted cart page","One hook for the whole flow: useThrottleCheckout","Resolved carts: lines priced by an installed app","Mobile apps: no Origin needed","Adding and changing lines","Refresh","Shipping on resolved carts","Checkout hand-off","Signing in","Switching currency: absorb a cart","In @usethrottle/cart","Browser carts may only hold resolved lines","Extra cart currencies","Endpoints","Add a line by the app's id for the item. The app sets name, price and image.","Re-check every line the app owns (send {} as the body)","→ { \"data\": { \"cartSessionId\": \"cart_<id>\", \"cart\": { … }, \"changed\": false } }`;","Update an item","Remove an item","Read the current cart","\"data\": {","\"cartSessionId\": \"cart_<id>\",","\"status\": \"active\",","\"unitCount\": 4,","\"total\": 12497,","\"currency\": \"USD\"","}","}`}","Apply a discount code"],"text":"Guide 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. Prefer a typed client? 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 or POST /api/v1/shipping-tax/quote-tokens ) from an allowed origin. 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 . 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. in localStorage ) 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. Creation is authorized by your publishable token + the application origin allowlist. Every later call is authorized by possession of the opaque 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 . A resolved cart also accepts a request that sends neither Origin nor Referer , as a mobile app does. Create a cart session 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 /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_ /items/ \\\\ -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_ /items/ \\\\ -H Origin: https://shop.example.com # Read the current cart curl https://api.usethrottle.dev/api/v1/cart-sessions/cart_ \\\\ -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: /summary \\\\ -H Origin: https://shop.example.com # # data : # cartSessionId : cart_ , # 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. 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. A cart the abandoned-cart sweep has taken reports its real 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. \\\\ -H Origin: https://shop.example.com -H Content-Type: application/json \\\\ -d customerEmail : buyer@example.com , notes : Leave at the door /> A guest cart is invisible to abandoned-cart recovery until it has a known email. Setting customerEmail here — even without a full customer record — is what makes the cart eligible for th"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","2026-11-06 — Customer search matches company; carts can detach a customer","2026-11-02 — Quotes: catalog lines","2026-11-01 — Customer carts follow the buyer","2026-10-27 — Internal notes on orders and customers","2026-10-26 — Buyer-portal proxy limits what a buyer may send","2026-10-26 — Physical subscriptions","2026-10-24 — Line items on the order list","2026-10-23 — Customer directory for extensions","2026-10-23 — Extra cart currencies for resolved carts","2026-10-20 — Line resolvers: extensions set the price of a cart line","2026-10-19 — Manual extension setup is deprecated","2026-10-19 — Read one installation by id","2026-10-19 — Uninstall deliveries name the install they ended","2026-10-10 — Signup invoices carry their transaction id","2026-10-10 — A free first period with no order writes no invoice","2026-10-09 — $0 renewals are quiet","2026-10-09 — Subscriptions at amount 0 need an opt-in (breaking)","2026-10-09 — Quote subscription lines need a price","2026-10-07 — Native tax correctness","2026-10-07 — Lost chargebacks have their own state","2026-10-07 — Change one subscription's card","2026-10-05 — Payment correctness","2026-10-04 — Invoice and order follow-ups","2026-10-04 — Net-N invoices follow their payment","2026-10-04 — Editing an order bills its outstanding Net-N invoice","2026-10-03 — Custom installment schedules","2026-10-03 — Editing a provider-taxed order re-quotes its tax","2026-09-30 — Declined embed cards answer 402, checkout-react under vite dev","2026-09-30 — Switched-off email templates stay off","2026-09-29 — Several subscriptions in one checkout","2026-09-29 — Stalled-renewal reason, send-test filtering, chained-change credit, hosted-cart subscriptions","2026-09-28 — Metered usage lines, stuck-renewal visibility, paid-based credits","2026-09-26 — Custom billing cadence and installment plans","2026-09-25 — Automation connectors get their own OAuth audience","2026-09-25 — OAuth tokens work only through the MCP server","2026-09-23 — OAuth connections never see shopper personal data","2026-09-23 — OAuth tokens bound to the MCP resource; hosted server strictly read-only","2026-09-21 — Order confirmation emails restored; receipts list what was bought","2026-09-21 — Forgot password now works for guests and imported customers"],"text":"Release notes Developer Changelog Public, developer-facing contract changes for the Throttle API, embedded checkout, webhooks, and SDKs. Internal refactors and UI-only changes are excluded. 2026-11-06 — Customer search matches company; carts can detach a customer Customer search. GET /api/v1/customers?q= also matches a customer's company , and % , _ and \\ in q match literally instead of acting as wildcards. Detaching a cart's customer. PATCH /api/v1/carts/ id accepts customerId: null to detach the customer (omit it to keep the customer). @usethrottle/cart and @usethrottle/api-client type it as string | null . Cart API 2026-11-02 — Quotes: catalog lines SKU-only lines. With the Product Catalog extension installed and active, POST /api/v1/quotes and PATCH /api/v1/quotes/ id /revisions/ revId accept a line with only its sku . Throttle fills its name, description, image and price from the catalog and records the link in metadata.catalogLink . Lines with a name are unchanged. Catalog lines Generated plan IDs. A recurring catalog-linked line may leave out recurring.plan ; Throttle names it <sku>:<cadence> . Every other recurring line still needs a plan . Changing a quote's currency. PATCH /api/v1/quotes/ id accepts currency until the first revision is issued, then answers 409 quote_currency_locked . Buyer view. Items in the quote-links responses carry linkedSku : the SKU of a catalog-linked line, or null . New endpoints. GET /api/v1/quote-catalog/status , GET /api/v1/quote-catalog/search and POST /api/v1/quote-catalog/lines ( quotes:write ). New error codes. catalog_not_available , sku_not_found , price_missing_for_currency , currency_not_enabled , catalog_unavailable , quote_price_missing and quote_currency_locked . 2026-11-02 — @usethrottle/auth 0.12.0 Cart and claim. account.getOpenCart({ currency }) reads the buyer's open cart, and auth.whenClaimSettled() resolves when a sign-in's cart claim has finished. Both are optional on the interfaces, so the release is non-breaking. Storefront auth Reorder. onReorder targets carry sku and subscriptionId per line, and reorderOnPaid offers "Order again" on paid orders. Split build. The provider loads the sign-in and account modals on their first open (one chunk fetch each) and the package declares sideEffects for CSS. The demo store's /shop went from 195,876 B to 184,633 B gzip, 11,243 B lighter. Import paths are unchanged. 2026-11-01 — Customer carts follow the buyer New endpoint. GET /v1/storefront/me/cart?currency=USD (buyer session, not gated on a verified email) answers the signed-in buyer's cart with a cart session to write through, or null . Responses are no-store . Storefront auth Sign-in merge. The claim merges the customer's newest same-currency cart, restores idle-abandoned carts, leaves other-currency carts untouched, and moves the merged cart's lines (it is left empty and abandoned ). Sign-in merges Order lines carry referenceId . GET /v1/storefront/me/orders/ id lines add referenceId (the SKU, or null ), for reorder only. Line metadata stays merchant-only. 2026-10-27 — Internal notes on orders and customers New endpoints. GET / POST /orders/ id /notes ( orders:read / orders:write ) and GET / POST /customers/ id /notes ( customers:read / customers:write ). Notes are visible only to your team and are append-only. The order timeline is unchanged. Order states 2026-10-26 — Buyer-portal proxy limits what a buyer may send @usethrottle/subscriptions 4.0.0 (breaking). The proxy helper forwards on your secret key, so it now refuses buyer fields that set money or terms: a refund on cancel, plan or amount on PATCH , and direct creates, with 403 field_not_allowed / create_not_allowed . New authorizeUpdate and authorizeCreate options let you approve them after checking your price list. Reading another buyer's subscription answers 404. Buyer portal 2026-10-26 — Physical subscriptions Buyers can change where their box ships. New PATCH /v1/storefront/me/subscriptions/ id /shipping-address (session plus step-up), updateSubscriptionShippingAddress() in @usethrottle/auth , and a Change address button in its <SubscriptionsPanel /> . Storefront subscriptions now carry deliveryMethod and shippingAddress . A subscription can ship a box every cycle. A cart line with recurring and deliveryMethod: shipment was refused with 400 validation_error ; it is now accepted and creates a physical subscription. Its signup order and every renewal order carry a shipment line and wait in the fulfillment queue, shipped to the subscription's new shippingAddress . No shipping is charged. Access subscriptions are unchanged. Physical subscriptions New subscription fields deliveryMethod ( access_grant or shipment ) and shippingAddress , on responses and subscription.* webhooks. POST /api/v1/subscriptions accepts both; PATCH /api/v1/subscriptions/ id accepts shippingAddress ( 422 subscription_not_physical on a subscription that does not ship). The address step is on for a physical subscription. An explicit collect.shippingAddress: false on such a cart answers 400 shipping_address_required . 2026-10-24 — Line items on the order list New optional include=lineItems on GET /api/v1/orders . Each row then carries lineItems , exactly as GET /api/v1/orders/ id answers them (an order with no lines answers [] ), in the same query count. Without it the answer is unchanged. Any other value answers 400 validation_error . The CSV export ignores it. 2026-10-23 — Customer directory for extensions New scope customer_directory:read and GET /api/v1/customer-directory . Find customers by a name or email prefix ( q ), or name up to 50 customer ids ( ids ), answering only id, name and email of the key's own application and environment. The scope is sensitive and extension-allowed; customers:read does not grant it. Finding customers 2026-10-23 — Extra cart currencies for resolved carts New per-environment sett"},{"title":"API Conventions","route":"/developers/conventions","headings":["API Conventions","Field naming: camelCase, single shape","Errors","Typed errors in the SDK","Request IDs","Pagination","Identifier prefixes","Timestamps and timezones","Quote and order numbers"],"text":"Reference API Conventions One section captures the rules that apply across every Throttle endpoint: naming, error shapes, pagination, idempotency, and workspace environments. When in doubt, this page wins over the per-endpoint docs. Field naming: camelCase, single shape Every request and response field on the wire is camelCase . One name per concept — no aliases, no alternate forms. Type generation from the OpenAPI spec is the intended workflow. Throttle public request and response bodies use camelCase. Use applicationId , customerId , lineItems , unitPrice , and externalId ; stale snake_case or store aliases are rejected with a validation error. Generated api-client@1.3.0+ already uses the current shape. Historical store terminology may still appear in dashboard labels or internal database migrations, but it is not exposed on the public wire. Errors Every non-2xx response follows the same envelope: error.code is the contract — use it for programmatic handling. Reach for error.message only for surfacing a human-readable string in your UI. Field-level details identify unrecognized keys and may include a suggestion when the server can identify the intended canonical camelCase field: Typed errors in the SDK The envelope is published as a named ErrorEnvelope model, so @usethrottle/api-client exposes it as a type rather than leaving you to re-parse the body: ApiError extends ThrottleError , so one catch covers every Throttle SDK. err.message is the message the server sent; if a response carries no message, it falls back to a description of what that status means on this API, and the raw description stays available as err.statusDescription . Request IDs API responses carry an x-request-id response header, and it s readable cross-origin (it s in Access-Control-Expose-Headers ) — hijacked and streamed replies are the exception. Where the response body is a JSON envelope, meta.requestId is the same value, where present: If your own request already carries an x-request-id header matching ^[A-Za-z0-9_-] 8,128 $ , Throttle honours it and echoes it back unchanged — useful for correlating a request across your own systems. Anything that doesn t match (too short, too long, or containing characters outside A-Za-z0-9_- ) is ignored, and a fresh req_<uuid> is generated instead. ThrottleError s requestId field — available in @usethrottle/errors 1.1.0 and later — exposes the same value across every Throttle SDK: quote it when contacting support, since it s the fastest way for us to find the exact request in our logs. It s read from meta.requestId when the response body carries one; for a hand-built error body without a meta envelope (a 401, 403, or 500), it falls back to the x-request-id response header instead, so it s populated either way. On earlier versions, read x-request-id or meta.requestId directly from the response. Pagination List endpoints accept ?limit + ?cursor and return: , // pass back as ?cursor= to fetch next page hasMore : true, limit : 25 language= json label= Paginated response /> When hasMore is false , cursor is null . You can use if (cursor) as a more pages? check without re-reading hasMore . Identifier prefixes Resource ids are UUID v7 strings. API keys, embed tokens, and session ids carry a short prefix that encodes their type and, for keys, the workspace environment slug: sk_<environment-slug>_* — secret key for one workspace environment. pk_<environment-slug>_* — publishable key for one workspace environment. sk_live_* and pk_live_* — production environment keys. whsec_* — webhook signing secret. Timestamps and timezones Every timestamp in an API request or response is ISO 8601 in UTC , always, and is unaffected by any setting. Parse it as an instant and render it wherever you like. Separately, each application carries a timezone — an IANA zone name such as America/Los_Angeles . That setting is what Throttle s own rendered surfaces use: the dashboard, quote PDFs, and transactional emails. null means UTC. Setting a timezone never changes an API payload. If you need local time in your own UI, read timezone from the application and format the UTC instant yourself. Like currency and branding, the timezone is stored per workspace environment , so a store can read its test data in one zone and its production data in another. Quote and order numbers quoteNumber and orderNumber are human-facing labels that count up per application, per workspace environment . Two applications each issue their own Q-1 ; neither ever sees a gap caused by the other. Both the prefix and the next number are configurable, from the dashboard or over the API: quoteNumberPrefix / orderNumberPrefix — 1–12 characters of letters, digits, - or _ . Defaults are Q- and ORD- . nextQuoteSeq / nextOrderSeq — the next number to issue. It must be greater than every number this environment has already issued under that prefix; otherwise the request fails with 409 starting_number_already_used . A document number is a display label, not a cursor. Sequences are per store, restart under a new prefix, and can be moved forward deliberately. Sort and paginate on createdAt plus the id instead, and never infer volume from a number. Test environments keep their own counters, so QA traffic never consumes a number a live document would otherwise get."},{"title":"Discounts for Cart-Backed Checkout","route":"/developers/discounts","headings":["Discounts for Cart-Backed Checkout","Supported types","Percentage off","Fixed amount off","Use the SDK","Preview without mutation","Customer limits","List and search codes","Cart and order records","Payment flow","Metadata","Apply to checkout","Pre-applying at session create","Discounts in more than one currency","Current boundaries"],"text":"Promotion codes Discounts for Cart-Backed Checkout Throttle supports percentage and fixed amount promotion codes. The payment processor does not validate or calculate codes; Throttle computes the discounted cart total before checkout hands the final amount to payment. Supported types Percentage off type: "percentage" , with value: 25 for 25% off. Fixed amount off type: "fixed_amount" , with value: 500 for $5.00 off in minor units. Free shipping and buy-X-get-Y are no longer active product surfaces. Existing historical rows may remain inactive, but new code should only create percentage or fixed amount discounts. Use the SDK @usethrottle/discounts is a server-side, headless SDK. It does not ship a coupon input UI; your checkout owns that field. Preview without mutation Use preview when the buyer types a code in your order summary. It returns the discount amount and total without creating or changing a cart. Include a customer ID when you want the preview to check the customer redemption limit. When a code has a minimum order total and the order is under it, the validate and preview responses add minimum (the code's minimum in the order's currency) and shortfall (how much more the order needs to reach it). Both are money values: amount, currency, display , where amount is in minor units and display is the formatted string, for example €12.50 . They are absent when the order meets the minimum. A cart that applies such a code answers discount_invalid with the same minimum and shortfall in details . Format amount and currency with formatMoney from @usethrottle/money ; see Showing money . Customer limits New discounts default to one redemption per customer. Set maxRedemptionsPerCustomer to another positive integer for repeat use, or null for unlimited customer redemptions. Usage is recorded when a discounted cart becomes an order. A cart can hold one applied code. Applying a valid new code replaces the previous one; invalid replacement attempts leave the existing code unchanged. List and search codes GET /api/v1/discounts uses cursor pagination and accepts q , type , isActive , code , and sort filters. Use meta.pagination.cursor for the next page until hasMore is false. Cart and order records Applied coupons are visible on cart reads and are preserved on the order snapshot. A cart response includes appliedDiscount with the code, discount ID, type, amount, currency, validity, and snapshot hash. When the cart becomes an order, the order stores discountId , discountCode , and metadata.appliedDiscount . Calculated tax is charged on the price after the code: a cart-wide code lowers every line s taxable amount in proportion, and a code limited to certain products lowers only those lines. Native tax, the Avalara quote and the filed Avalara document agree, and the stateless quote nets its discountsTotal the same way. A tax rule set to tax before discounts still taxes the full price. An order with a discount carries metadata.taxDiscountBasis: net_of_cart_code ; editing its line items re-taxes it the same way. Orders placed before this behaviour shipped have no such key and keep the tax basis they were placed on when edited. The key is managed by Throttle, so do not set or remove it yourself. The lines a code applied to are recorded on the order when it is placed, as metadata.discountScope (the code, its type and value, its product scope, and the order line ids it reduced). Editing the order's line items and the filed Avalara document read that record, not the discount as it is now: narrowing, widening, re-pricing or deleting a code after an order was placed does not change that order's tax. A line added by an edit shares the code when the recorded product scope covers it. Orders placed before this record existed read the live discount, as before. Throttle manages this key too: a value you send is ignored. Global and per-customer usage counts are recorded when the discounted cart is converted to an order. Preview does not mutate usage. Apply validates customer limits when the cart has a customer ID. Payment flow Throttle applies discounts before payment. The payment processor receives the final amount to authorize or capture; it does not receive a coupon code to validate. Metadata Discounts accept arbitrary JSON metadata for campaign IDs, channels, attribution, or merchant notes. Metadata is stored with the discount and returned by the REST API, GraphQL API, and SDK. Apply to checkout Cart application is the authoritative step. Apply the code to a native Throttle cart before you create or render the checkout session. Pre-applying at session create POST /api/v1/checkout/sessions accepts a top-level discountCode: string field. When provided, Throttle validates the code synchronously against the session's cart and applies it before returning. The response cart total reflects the discount. An invalid or expired code returns 422 discount_invalid . The session is not created. Your storefront should call /api/v1/discounts/preview first if you want to surface the error inline, then retry the session create with the validated code (or omit it). The preview endpoint computes the discounted total without mutating any cart or session. Discounts in more than one currency A discount carries its amounts per currency in amounts , keyed by currency code, in minor units. value is the fixed amount (fixed-amount codes only) and minOrder is the minimum order total. No entry means not offered. Throttle never converts. A code with no entry for the order's currency answers 422 discount_currency_unsupported ("This code can't be used for orders in GBP.") on every cart kind, and validate returns it as code . Percentage codes with no minimum anywhere keep amounts: {} and work in every currency. A minimum in one currency makes the other currencies need an entry. Legacy writes mean the main currency. value and conditions.minOrder without amounts set the main-"},{"title":"Embed Throttle Without Owning Payment UI","route":"/developers/embedded-checkout","headings":["Embed Throttle Without Owning Payment UI","PaymentEmbed","CheckoutEmbed","Raw iframe","Origin allowlist","Preview origins","Choosing the right surface","Session lifecycle","Sequence: postMessage handshake","Query parameter reference","postMessage event reference","Complete-session payloads","Completion errors","Payment-method filtering"],"text":"Checkout surfaces Embed Throttle Without Owning Payment UI Use an iframe-backed surface for payment capture while your backend controls session creation and your webhook endpoint controls durable state. PaymentEmbed Use PaymentEmbed when your storefront already owns cart, address, shipping, and order summary UI. Throttle renders the payment form and posts lifecycle events to the parent page. If you show a promotion-code field in that order summary, preview it on your server and apply it to the Throttle cart before creating the session. Both components build their own iframe URL and load the /c page by default. Pass route="s" to load the unified /s page instead — for example to match the route you give buildCheckoutEmbedUrl on your server. The component does not see a URL you built yourself. Card-vault (add-a-card) sessions are only served by /c . CheckoutEmbed Use CheckoutEmbed when you want a fuller hosted checkout flow inside your page. It supports the same terminal events plus checkout step changes. Raw iframe If you are not using React, render the hosted checkout iframe directly and validate the postMessage envelope before trusting events. Origin allowlist Embedded checkout refuses to render unless the parent origin is allowlisted. Configure production and staging origins before mounting the iframe. Preview origins Preview deployments get a new hostname per branch, so they cannot be listed one by one. In a non-production environment, an entry may use one wildcard as its leftmost label: https://*.preview.example.com matches https://pr-42.preview.example.com and any deeper subdomain. It does not match https://preview.example.com itself; list that separately if you need it. The wildcard must sit under a domain you own. One over a shared or public suffix ( https://*.vercel.app , https://*.pages.dev , https://*.co.uk ) is refused with 400 invalid_origin , because anyone can obtain a hostname there and could then host your checkout. Point a subdomain of your own at the preview host instead. Production takes exact origins only. A wildcard sent to the production environment is refused, and creating an application never accepts one, since that seeds every environment at once. For a domain cutover, list the old and the new origin side by side and remove the old one afterwards. A wildcard entry applies to embedded checkout, cart sessions, storefront sign-in and the script manifest. One exception: the sandbox that runs extension scripts only ever lets exact origins frame it, so sandboxed scripts do not run on a preview origin matched by a wildcard. A wildcard is also never used to build a link. If your allowlist holds only wildcards, set storefrontBaseUrl so buyer emails and relative image paths have a real host to point at. Throttle sends iframe events with a specific target origin. Your parent page should also verify event.origin , source: "throttle" , and version: 1 . Choosing the right surface The hosted checkout app exposes two route shapes. Both honour the same origin allowlist and emit the same postMessage events; they differ in who owns the buyer-data form (Throttle vs. the payment provider) and which payment methods can co-exist. /c/[sessionId] Provider proxy mode , The provider embed collects email, address, and card inside its own iframe. Throttle wraps it with merchant branding and a Pay button. , Card (provider) , code: PaymentEmbed , ], [ html: /s/[sessionId] Unified flow , Throttle renders the cart, address form, and a payment-method picker. Card and Net 30 can appear side-by-side as tiles in the same iframe. , Card (provider) and/or Net 30 , code: CheckoutEmbed , ] ] /> Session lifecycle Every embedded checkout follows the same four-step rhythm. Sessions are server-side state; embed tokens are short-lived JWTs that the iframe re-mints on demand. The session id ( sess_xxx ) is the durable handle stored in checkout_sessions . The embed JWT ( embedToken ) is a provider-signed credential the iframe needs to mount the card capture widget. The iframe re-mints it every render, so the JWT's ~30 minute TTL is rarely user-facing. The underlying cart stays open for the whole life of a session. The cart only becomes converted (terminal) when the session completes and the order is created — not when the session is created. So a buyer can return to an in-flight session and keep mutating the cart. If a later cart operation returns 409 cart_not_open , the cart was converted by a previously completed order; start a new cart for the new purchase. checkout.getSession(sessionId) ( GET /api/v1/checkout/sessions/ id ) returns the session with its current status : open or processing while the buyer is in it, then completed , cancelled or expired . Use it to decide whether a session can be resumed or needs cancelling, instead of tracking that on your side. To tear down an in-flight session, call checkout.cancelSession(sessionId) ( DELETE /api/v1/checkout/sessions/ id ). It is idempotent, marks the session cancelled , and re-opens an associated cart still sitting in checkout . A completed session cannot be cancelled ( 422 already_completed ). GET /api/v1/checkout-sessions/ id returns currency : the session s currency, which is its cart s. Every amount the checkout page shows is in minor units of it, so a custom checkout formats its totals with it (for example formatMoney(total, session.currency) from @usethrottle/money ) without waiting for a card token. It is null only when the cart can no longer be read. GET /api/v1/checkout-sessions/ id — unauthenticated, what the iframe itself reads to render — returns recurringTerms when the session carries a recurring intent, otherwise null . Card-network rules require disclosing what a trial converts into and what an installment plan totals before the buyer converts, so the iframe builds its disclosure copy from this object rather than hardcoding it: interval , intervalUnit , intervalCount , totalPayments (installment plans only), firstCha"},{"title":"Collection Flags","route":"/developers/embedded-checkout/collection-flags","headings":["Collection Flags","The collect block","Worked combinations","Shipping only (default — physical goods, no separate billing)","Billing only (digital goods + invoicing)","Both (shipping + separate billing)","Neither (replaces ?mode=payment-only)","Flow: which steps render","Complete-session billing fields","Validation errors","Iframe behaviour","Migration: ?mode=payment-only","Next"],"text":"Embedded Checkout Collection Flags Tell Throttle which buyer addresses to collect during the embedded flow. Each session opts into shipping and billing capture independently — useful for digital-only carts, B2B Net 30 invoicing, and storefronts that already collected the address upstream. The collect block POST /api/v1/checkout/sessions accepts an optional collect object on the request body: true , except with subscriptions (below) , html: When true , the iframe renders the shipping address step and /complete requires shippingAddress . , ], [ code: collect.billingAddress , code: boolean , html: false , with two exceptions (below) , html: When true , the iframe emits a step: 'billing' postMessage and /complete requires billingAddress (or billingSameAsShipping: true ). , ] ] /> Omitting collect on a one-off checkout is equivalent to shippingAddress: true, billingAddress: false . Existing integrations that don't opt in keep the old shape. On a checkout with subscriptions, an omitted collect turns the shipping step on only when the cart holds a shippable one-time line or a physical subscription line ( deliveryMethod: shipment ); other subscription lines never ship. A collect object that leaves shippingAddress out still sets it to true . See Mixed Carts . An omitted collect.billingAddress defaults to true in two cases, because otherwise the checkout has no address to tax: the application taxes from the billing address (tax trigger billing_address ); the session shows no shipping step ( collect.shippingAddress resolves to false , for example a subscriptions-only cart), and the application uses a tax provider (tax mode app_based ) with an address trigger and the strict fallback policy, which refuses to complete a checkout it could not tax. In that second case, with the shipping_address trigger, tax is calculated from the billing address, and the order records metadata.taxTrigger: 'billing_address' so the filed tax document uses the same address. It also records metadata.taxTriggerSource: 'checkout' to mark that value as Throttle's: if the buyer retries a declined payment, the order's trigger is worked out again for the new attempt, and a stamp with that mark is replaced or removed. A metadata.taxTrigger you set on the cart or the session (the session's wins, as it does on the order) is never overwritten, and it also replaces a value already on an order that a retry reuses. The quote and the filed document both follow it, so shipping_address on a session with no shipping step is refused with tax_address_missing under the strict policy. An explicit billingAddress: false always wins. Worked combinations Pick the combo that matches your storefront. Every example below is the request body for POST /api/v1/checkout/sessions . Shipping only (default — physical goods, no separate billing) Billing only (digital goods + invoicing) Common for B2B Net 30 flows where shipping is irrelevant but the AR system needs a billing address. Both (shipping + separate billing) The buyer fills shipping, then is asked whether billing matches. If they uncheck "billing same as shipping", a billing form appears. Neither (replaces ?mode=payment-only) Use when your storefront has already collected the buyer's addresses. Send them as customer.shippingAddress and customer.billingAddress : they are stored on the session, returned when you read it, and carried onto the order when the session completes. An address submitted on /complete always wins over the one sent here. Flow: which steps render The two boolean flags fan out into four distinct iframe step machines. Use this to confirm the buyer journey before you ship — especially when a connector requires a billing address regardless of shipping. Complete-session billing fields When collect.billingAddress is true, the iframe POSTs the billing details on /complete . Two new request-body fields: billingAddress — same shape as shippingAddress : firstName , lastName , company? , addressLine1 , addressLine2? , city , stateProvince , postalCode , countryCode (ISO-3166-1 alpha-2), phone? . billingSameAsShipping — boolean, defaults to false . When true , the server copies the shipping address into the order's billing slot and you can omit billingAddress . Validation errors When a required address is missing, the server returns 422 address_required with a field extra so the iframe can highlight the right step. Iframe behaviour The Pay button auto-disables until every collect-flagged field is complete. Parent-set submitDisabled still composes additively — see Parent Controls . With collect.billingAddress: true , the iframe emits an additional throttle.step.changed event with step: 'billing' . With collect.shippingAddress: false and collect.billingAddress: false , the buyer lands directly on the payment step (this combo replaces the legacy ?mode=payment-only URL flag). Migration: ?mode=payment-only The ?mode=payment-only URL query parameter is still honoured client-side for one release window to avoid breaking existing storefronts. New integrators should set collect: shippingAddress: false, billingAddress: false on session create instead — that path is server-authoritative, carries through to /complete validation, and is visible in webhook payloads. Next Embedded Checkout — full embed lifecycle and event reference. Parent Controls — parent-side submitDisabled gate."},{"title":"Parent Controls","route":"/developers/embedded-checkout/parent-controls","headings":["Parent Controls","The submitDisabled prop","Defaults","Composes with collect-flag auto-disable","Without React","Message contract","Security","What does it actually do inside the iframe?","Common uses","Next"],"text":"Embedded Checkout Parent Controls Gate buyer interaction with the embedded checkout from the parent page. Useful when your storefront has fields that must be filled (shipping address, terms acceptance, plan selector) before the buyer should be able to submit the embedded payment. Is: a way for the parent to render an overlay over the entire embed area that swallows clicks until the parent says otherwise. The overlay is light-grey + cursor not-allowed so the buyer sees the form behind but can't interact with it. Isn't: a way to validate the buyer's data from the parent. Field-level validity is up to your form. The parent signals ready or not ready — Throttle just enforces the gate. The submitDisabled prop Both <PaymentEmbed /> and <CheckoutEmbed /> in @usethrottle/checkout-react accept a controlled submitDisabled?: boolean prop. When it flips to true , the SDK posts a parent → iframe command and the iframe renders a full-area overlay. When it flips back to false , the overlay disappears. Defaults Omitting submitDisabled entirely keeps the embed enabled (backward compatible). The SDK never posts a parent command in this case. submitDisabled= false and omitting the prop are equivalent at runtime, but passing false explicitly opts your embed into the parent-control protocol — useful when your form starts valid (e.g., everything pre-filled). The first parent command is sent after the iframe posts throttle.ready . You don't need to wait for this in your component code — the SDK buffers and re-sends. Composes with collect-flag auto-disable The iframe also auto-disables the Pay button until every field required by the session's collect flags (shipping address, billing address) is complete. Parent-set submitDisabled composes additively with that validation gate: the button stays disabled while either the parent says "not ready" or the iframe's own validation hasn't passed. Practically: you don't need to mirror the iframe's address validation in your parent form. Only gate on parent-controlled state (terms checkbox, plan selection, out-of-band eligibility checks). Throttle handles the rest. Without React The SDK is a thin wrapper over postMessage. Vanilla JS, Vue, Svelte, Angular — anything that mounts an iframe can post the same shape. Message contract The parent → iframe channel uses source: 'throttle-parent' to disambiguate from the iframe → parent events (which use source: 'throttle' ). The iframe ignores anything that doesn't match. Future commands will extend the same envelope; the protocol is additive. Security The iframe verifies event.origin matches the merchant-allowed parent origin before reading the message. A different page can't steal control. Setting disabled: false from an attacker doesn't bypass anything — it simply removes the overlay. The buyer still has to actually submit the form, and Throttle still has its own server-side validation on the /complete route. The overlay is cosmetic , not a security boundary. It blocks the buyer's clicks but doesn't prevent scripts. Treat submitDisabled as a UX gate, not a server-side enforcement primitive. What does it actually do inside the iframe? The iframe's EmbedShell listens for the parent command and renders a positioned overlay (semi-transparent white + cursor not-allowed) covering the embed content when disabled. The overlay sits at z-index 50 so it covers both Throttle's native UI and any nested provider iframe (the card form). The buyer sees what they've typed but can't click anything. We chose "overlay over the iframe" instead of "disable the submit button only" because the provider embed renders its own submit button inside a nested iframe we don't control. Greying the entire interaction surface gives the same UX outcome with stable semantics. Common uses Address before pay. Your parent page renders a shipping address form. Pass submitDisabled= !addressValid . Terms acceptance. A "I agree to terms" checkbox above the embed. Pass submitDisabled= !checked . Plan selection. Buyer must pick a plan before paying. Pass submitDisabled= !selectedPlan . Async eligibility. You're waiting on a backend check (e.g., calling subscriptions/eligibility-check ). Disable until the result returns. Next Embedded Checkout — full embed lifecycle and event reference."},{"title":"Workspace Environments","route":"/developers/environments","headings":["Workspace Environments","Environment-scoped keys","Dashboard environment selector","Manage environments","API keys are environment-scoped","Payment provider environments","Webhook environment routing","Cheat sheet","Production is immutable; non-production environments can be named for your flow.","Production:","Include archived environments for settings/audit screens","Create a non-production sandbox-provider environment","Archive a custom non-production environment"],"text":"Reliability Workspace Environments Throttle isolates production from every sandbox-provider environment inside the same workspace. Each workspace has one immutable production environment and can have multiple non-production environments such as test, UAT, and staging. Environment-scoped keys API keys are minted for a workspace environment. The raw key includes the environment slug, and every request is scoped to the key's environment end-to-end: sk_<environment-slug>_* — secret key for server-side requests. pk_<environment-slug>_* — publishable key for browser/frontend code. Publishable keys may hold only stateless compute scopes such as shipping_quotes:write and tax_calculations:write . Carts, discounts/promotions, orders, payments, subscriptions, shipping/tax settings, invoices, Net-N customer terms, dashboard views, webhooks, API keys, and connector state all carry an environmentId . A UAT cart never becomes a production order, and a production webhook endpoint never receives UAT events. Dashboard environment selector The dashboard topbar selector chooses the active workspace environment for reads and setup writes. Production workspaces can switch between production and any active non-production environment. Workspaces that have not completed go-live can work in non-production environments until production is available. Create and archive custom environments from Workspace Settings → Environments . Manage environments Every workspace starts with one immutable production environment. Create additional environments for UAT, staging, or QA; each custom environment is non-production and routes to sandbox-provider credentials. Production cannot be created, archived, or deleted. Archiving a custom environment removes it from normal selectors and prevents new work in that environment; historical rows keep their environment id for audit and reporting. API keys are environment-scoped GET , POST , and DELETE /api/v1/api-keys act only on the request's selected environment. Create separate keys for production, UAT, staging, or any other non-production environment you run. An API key whose environment contradicts an explicit X-Throttle-Environment-Id header is rejected outright with 403 environment_mismatch . Fetching a specific resource (an order, a subscription, and so on) by id from another environment answers 404 not_found instead — the lookup is already scoped to your environment, so a resource that belongs to a different one simply doesn't match, the same as an id that doesn't exist at all. Either way, use a key for the correct environment, or select the matching X-Throttle-Environment-Id for dashboard-session setup calls. Payment provider environments Throttle uses the workspace environment to choose the provider environment. Production routes to production processors; non-production environments route to sandbox-provider wiring such as the Card Simulator or sandbox payment processor. Production payment paths require production entitlement. Non-production environments are available for integration work before go-live. Webhook environment routing Outbound webhook endpoints are scoped to a workspace environment. Events carry environmentId in the delivered envelope and are delivered only to endpoints in the same environment. Cheat sheet Use non-production environment keys such as sk_uat_* for development and UAT. Use environmentId to choose dashboard-session setup scope; API keys already carry their environment. Going live makes the immutable production environment usable for production payment paths without changing the structure of your integration code."},{"title":"Bootstrapping Extension Credentials","route":"/developers/extensions/bootstrap","headings":["Bootstrapping Extension Credentials","The handshake, end to end","1. Issue client credentials","2. Where the client secret goes","3. Receive the install callback","4. Pull your credentials — read the secret, mint the key","Read the signing secret","Mint a fresh API key","Rotate a leaked signing secret","Error codes to distinguish in your handler","5. Recover: enumerate your own installs","The three credentials, and why they rotate differently","Next steps","secret API key holding extensions:write — an extension can never mint this","for itself (not on the extension_client allow-list).","→ { data: {","\"clientId\": \"extc_...\",","\"clientSecret\": \"...\", ← shown once, never again","\"installCallbackUrl\": \"https://your-worker.example.com/throttle/install-callback\"","} }","Calling this again ROTATES the pair — the previous secret stops verifying immediately.`;","public identifiers, wrong for the client secret.","The client secret goes in the deploy's secret store instead, never in a file","that gets committed:","(Vercel: vercel env add · Fly: fly secrets set · Render: dashboard env group)`;","you need the secret in order to verify a delivery in the first place.","\"installationId\": \"inst_01HZX...\",","\"installSequence\": 2,","\"endpointId\": \"we_01HZY...\",","\"url\": \"https://your-worker.example.com/webhooks/throttle\",","\"enabledEvents\": [\"order.created\"],","\"signingSecret\": \"whsec_...\",","\"previousSecretExpiresAt\": null","} }`;","keeps the outgoing one verifying for graceSeconds (default 86400 = 24h), so","deliveries in flight do not start failing before you have stored the new one.","\"signingSecret\": \"whsec_...\", ← the new one","\"previousSecretExpiresAt\": \"2026-09-15T10:00:00.000Z\"","replaces. Call this only after a delivery's signature has verified, and only","when you actually need to write back to Throttle."],"text":"Extensions Bootstrapping Extension Credentials Every credential Throttle mints by default belongs to one install and is shown to the installing merchant, once. That works for an iframe extension a human eventually opens, but it leaves a webhook-only extension with no machine route to its own secrets at all — every delivery fails signature verification until someone copy-pastes a secret out of the dashboard. Client credentials close that gap: a developer issues one pair for their catalog extension, and every install after that bootstraps itself. The handshake, end to end Four exchanges, only the first of which involves a person. The thing to hold on to is which way each arrow points and what it carries: Throttle pushes the fact of an install, and your extension pulls the secret — the push is deliberately worthless to anyone who intercepts it. 1. Issue client credentials From your extension's catalog settings in the dashboard, generate a clientId / clientSecret pair. This is a one-time, developer-facing action — it is never shown to a merchant. The route accepts a dashboard user holding the application:extensions:write permission, or a secret ( sk_ ) API key holding the extensions:write scope — but never the extension's own client credentials, which are deliberately not on the allow-list that reaches this route. An extension cannot mint its own credentials. installCallbackUrl must be https:// — a plain http:// URL is rejected with 422 invalid_callback_url . Calling this route again rotates the pair immediately — the previous secret stops verifying the moment the new one is issued. That is deliberate: a developer rotating their own deploy s secret picks up the new value from their own secret store in the same step, unlike the per-install rotations below, which do carry a grace window because a merchant is not redeploying anything. 2. Where the client secret goes A typical extension worker already keeps a wrangler.jsonc (or equivalent) with a vars block for public values like THROTTLE_EXTENSION_ID . That block is committed to git and readable by anyone with repo access — correct for an identifier, wrong for a secret. The client id is fine alongside it; the client secret is not. Put the client secret in your deploy's secret store — wrangler secret put for a Cloudflare Worker, or the equivalent for whatever you deploy to. Nothing about the existing vars block tells you where the line is; this is that line. 3. Receive the install callback Once you have client credentials and an installCallbackUrl on file, every future install of your extension POSTs a signed notice to that URL — the only human action anywhere in this flow is the merchant clicking Install. The payload carries identifiers only, never secrets : it tells you an install happened and who it belongs to, not how to authenticate as it. The request carries an X-Throttle-Install-Token header — an RS256 JWT signed with the same extension JWKS keypair your bridge already verifies session tokens against. aud is your extension id, sub is the installation id. The JSON body repeats the token's claims for convenience, but a receiver must verify the JWT against https://api.usethrottle.dev/.well-known/extension-jwks.json and trust its claims — never the body. A body is just JSON anyone could send; the signature is what makes workspaceId , applicationId , and the rest trustworthy. An install can never fail because your endpoint was unreachable. Throttle attempts the callback 3 times total (two retries) with backoff, then gives up silently — the merchant's install always succeeds regardless. Retries are status-driven, not blanket: any response below 500 ends delivery immediately — a 4xx is treated as a permanent rejection and is never retried. Respond 5xx for a transient failure you want Throttle to retry, and reserve 4xx for a failure that retrying would never fix (a bad or missing token). If the callback never arrives (dropped, never configured, all 3 attempts exhausted, or you lost your own datastore), enumerate your installs instead of waiting for it. 4. Pull your credentials — read the secret, mint the key Every route below authenticates with the same header: Authorization: Basic base64(clientId:clientSecret) . Nothing else accepts that scheme, and a client credential can reach only these three routes plus the enumeration route below — anything else is 403 before the handler runs, and so is the wrong verb on a route it can reach. But the read and the mint are not interchangeable , and treating them as one call is the mistake this section exists to prevent. A client id/secret pair base64-encodes to well over 76 characters, so GNU coreutils base64 (the default on Linux and most CI images) inserts a newline into the header value — BSD/macOS base64 does not, which is exactly why this ships unnoticed from a Mac. The wrapped header fails validation and comes back as 401 unauthorized , which reads as a wrong secret rather than a formatting bug. Pipe through tr -d \\n (portable) or pass base64 -w 0 on GNU systems. Fetching the signing secret is a read — idempotent, and safe to call before you have verified a request, which you must, because you need the secret in order to verify it in the first place. Minting the API key is not a read: it issues a brand-new key and expires the old one. Call it only after a delivery's signature has verified, and only when you actually need to write back to Throttle. Collapsing both into one ensureCredentials() call hands anyone who can guess an installation id a way to churn your keys from outside — the read is harmless to run on unauthenticated input, the mint is not. Read the signing secret Genuinely readable: the secret lives in cleartext on the installation's webhook endpoint row, so re-reading it changes nothing. Call this as soon as you learn an installation id — before you have verified anything with it — because it is what lets you verify. Mint a fresh API key API keys are hashe"},{"title":"Build an Extension","route":"/developers/extensions/build","headings":["Build an Extension","Design for each installation","Choose where code runs","Browser iframe","Publisher backend","1. Create the extension","2. Create and publish a version","3. Install into an application","4. Wire the iframe bridge","Option A — hosted script (no build step)","Option B — npm package","React pattern","Deep links","Runtime model","Next steps","→ { data: { id: \"ext_...\", name: \"Inventory Sidebar\", lifecycle: \"draft\", ... } }`;","→ { data: { id: \"ver_...\", version: \"1.0.0\", status: \"draft\", ... } }","3. Publish the version (marks it installable)","→ { data: { id: \"ver_...\", status: \"published\", ... } }`;","Response (store the API key immediately — shown once. The signing secret can be read back later.):","\"data\": {","\"id\": \"inst_...\",","\"extensionId\": \"ext_xxx\",","\"versionId\": \"ver_xxx\",","\"status\": \"active\",","\"environmentId\": \"8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0\",","\"environmentSlug\": \"uat\",","\"environmentKind\": \"non_production\",","\"providerEnvironment\": \"sandbox\",","\"apiKey\": \"sk_uat_...\", ← store this securely; shown once","}","}`;"],"text":"Extensions Build an Extension This guide walks you from creating an extension catalog entry to having a working iframe panel with authenticated API access and live event delivery. Start with the Get Started tutorial . This page explains architecture and API mechanics after your first private install. Design for each installation Treat every installation as an independent tenant connection. Store a server-side record keyed by installation ID with the workspace, application, environment, pinned extension version, provider account reference, encrypted credential reference, status, last successful sync cursor, timestamps, and uninstall state. Throttle is the authority for whether an installation is active and which version and scopes it has. Your backend is the authority for provider credentials, mapping state, cursors, retries, and reconciliation. Never infer tenant identity from a provider account alone; validate the signed installation context. Stop queued and scheduled work when an installation is suspended or uninstalled. Choose where code runs Browser iframe Use for safe display settings and operator actions. Verify bridge identity and assume users can inspect all browser-delivered data. Publisher backend Use for provider secrets, event processing, scheduled synchronization, reconciliation, and privileged provider calls. 1. Create the extension The extension manifest declares what surfaces your extension exposes, which scopes it needs, and which events it wants to receive. You can declare an iframeUrl , a webhookUrl with eventSubscriptions , or both. Every scope listed must be extensionAllowed — see the Scopes reference . Every scope in the manifest is validated against the extensionAllowed flag when the extension is created or updated. Human and admin scopes ( team_members:* , billing:* , admin:* , * ) are never grantable to extensions and return 400 invalid_scope . 2. Create and publish a version Versions are immutable snapshots of the manifest. Create a version when you're ready to make an installable release, then publish it. A version must be published before it can be referenced in an install call. 3. Install into an application Installs are application-scoped and environment-scoped. The install response contains a one-time API key and, if a webhook URL is declared, the signing secret. Copy the API key immediately — it is shown exactly once. The signing secret is stable and readable at any time, so losing it is recoverable. The apiKey and webhookSigningSecret are unique to each install. Store them in your secrets manager. The API key is shown exactly once — losing it means uninstalling and reinstalling. The signing secret is stable and readable at GET /api/v1/installations/:id/webhook-secret . Copying either value into your extension by hand is deprecated and will be removed in a future release; use automatic setup . 4. Wire the iframe bridge For UI (iframe) extensions, call createBridge() once at page load. The bridge sends a ready message to the dashboard, which responds with a signed session token and context. The token is automatically refreshed before it expires (10-minute TTL). Option A — hosted script (no build step) Option B — npm package React pattern Always pass targetOrigin: 'https://app.usethrottle.dev' to createBridge() . Without it the bridge falls back to document.referrer or '*' as the postMessage target, which can leak the session token to other origins if the referrer is spoofed. The bridge does not pass configSchema or installed config values automatically. The dashboard consumes configSchema to collect settings during install and stores the resulting config on the installation record. See the configuration schema guide for the full lifecycle. Deep links A link to the launch page can open your iframe at a specific page. Add a page query parameter, and the dashboard loads your iframeUrl with that value as its URL fragment, so ?page=/products?x=1 opens <iframeUrl>#/products?x=1 . Read location.hash in your iframe to route. The value must be URL-encoded in the query string. The dashboard accepts it only when it is a string that starts with / , does not start with // , contains no \ , no # and no control characters, and is at most 512 characters. Anything else is ignored and the iframe opens at its configured URL. An existing fragment on your iframeUrl is replaced. The page is applied when the iframe first loads; changing it later does not reload an open session. Runtime model A UI extension is loaded only after it is installed into an application. The dashboard opens the installed version's iframeUrl , creates a bridge host for that iframe origin, and mints a 10-minute extension-session JWT for the active dashboard user. Your iframe calls createBridge() once, sends a ready message to the parent, receives a session message, and then uses bridge.api for scoped REST calls. The session token carries the installed version's scopes, application, workspace, environment, role, extension id, installation id, and version. It does not carry catalog authoring data such as configSchema , and it does not carry one-time install secrets. Event extensions use the separately generated webhook signing secret and verify incoming deliveries in their backend route. Next steps Identity JWT — the full claim set, TTL, JWKS verification, and using the token as an API bearer. Events — verify the X-Throttle-Signature header and browse the full event catalog with required scopes. Scopes — which scopes extensions may request and how scope ceilings work. Versioning — immutable published versions, upgrades, and rollback. Security model — RS256 JWKS, iframe sandboxing, origin pinning, scope ceilings."},{"title":"Extension Events","route":"/developers/extensions/events","headings":["Extension Events","Payload envelope","Verify signatures","Rotate the signing secret","Process idempotently","Delivery inspection and replay","You are told when your endpoint stops accepting deliveries","Auto-suspend on repeated failures","Event catalog","→ { \"data\": { \"installationId\": \"…\", \"endpointId\": \"…\",","\"signingSecret\": \"whsec_…\", # shown once","\"previousSecretExpiresAt\": \"2026-09-11T09:15:02.101Z\" } }`;","Replay a dead-lettered delivery","Send a verification ping to the installation's webhook URL.","The ping is signed with the installation's signing secret, like every delivery."],"text":"Extensions Extension Events Extensions can subscribe to Throttle events by declaring a webhookUrl and an eventSubscriptions list in their manifest. Throttle creates a managed webhook endpoint for each install and delivers signed events to your URL with the same retry and dead-letter mechanics as standard webhooks. Use this reference for backend and hybrid applications after your first private install , then validate retries and replays with the testing guide . Payload envelope Every delivery is a JSON POST with a stable top-level envelope. The event-specific payload lives under data . Headers: X-Throttle-Signature , X-Throttle-Event-Id , X-Throttle-Event-Type , Content-Type: application/json . Envelope fields: id (unique event ID), type , version (payload-schema version, currently "1" ), workspaceId , environmentId , environmentKind ( production or non_production ; omitted only when the environment could not be resolved, so treat absent as unknown rather than as production), createdAt , data . Additive fields are not a version bump. Throttle adds envelope fields and data fields without changing version , announced in the changelog but not ahead of the first delivery that carries them. Parse the envelope with a schema that strips unknown keys. A .strict() envelope schema rejects every delivery the moment a field is added, and if your handler reports that with the same 401 as a bad signature, the failure is indistinguishable from a rotated secret. Extension starter versions before PR #11 shipped exactly that schema; update and redeploy. Amounts: all money fields are integer minor units (e.g. cents for USD). Environment isolation: an install only receives events from the same workspace environment. The same event is never delivered across environments. The data shape for every event type is typed in @usethrottle/webhook-types . Import the ThrottleEvent discriminated union or ThrottleEventEnvelope generic and narrow on type to get the exact shape per event. Customer contact fields need customer_pii:read Events that involve a buyer carry a customer object under data : cart.* , order.* , payment.* , fulfillment.* , subscription.* and customer.* , where the customer is the subject of the event. The buyer s contact fields are delivered only to extensions granted customer_pii:read . Without the scope, email , phone , firstName , lastName and name arrive as null . The keys are still present, so the payload shape does not change. What stays: the customer id , externalId , externalCustomerId , company and every address, including the order s shipping address, which a fulfilment extension needs to ship. Which scopes count: the scopes of the version the installation is pinned to. customer_pii:read , or the legacy customers:read , which includes it. To start receiving the fields, publish a version that requests the scope and have merchants upgrade to it. Signatures: the fields are removed before the body is signed, so the signature verifies against exactly the body you receive. Replays follow the same rule. Verify signatures Each delivery includes X-Throttle-Signature in the format t=<unix-seconds>,v1=<hex-hmac-sha256> . The signed payload is <t>.<rawBody> — identical to the standard webhook signature scheme. The signing secret is the per-install webhookSigningSecret returned at install time. Signature verification fails if your framework parses and re-serializes JSON before you verify. Capture the raw request body string first, verify, then parse. verifyWebhook rejects deliveries whose t timestamp is more than 300 seconds (5 minutes) from the current clock. Pass toleranceSeconds to override. For 24 hours after the secret is rotated the header reads t=…,v1=<outgoing>,v1=<new> . Check every v1 against the secret you hold and accept if any matches — @usethrottle/extension-bridge 1.1.1+ and the starter's verifier do. A parser that keeps only the last v1 verifies once it holds the new secret, and not before. Rotate the signing secret Either side can rotate: your extension, authenticated as the installation, or the merchant from the dashboard. The old secret keeps verifying for graceSeconds (default 86400, max 604800) while every delivery is signed with both; 0 revokes it now — the leaked-secret case. When the merchant rotates, you are told: a signed extension.webhook_secret_rotated is delivered to the endpoint — dual-signed, so it verifies with the secret you still hold. It never carries a secret. On receipt, fetch the new one with GET /api/v1/installations/:id/webhook-secret and store it before previousSecretExpiresAt ; that read also reports the deadline while a window is open. Like extension.uninstalled , it is delivered to the installation's own endpoint without a subscription and cannot be listed in eventSubscriptions . previousSecretExpiresAt is null when the old secret was revoked immediately — fetch the new secret at once; deliveries in between will have failed and will be retried. Rotating again inside a window replaces the outgoing secret: only two are ever live. Process idempotently Extension event delivery is at-least-once. Retries use the same backoff schedule as standard webhooks: [5m, 15m, 1h, 6h, 24h] (max five attempts). After the fifth attempt, the delivery is marked dead_letter . Always store each event.id before applying side effects. Delivery inspection and replay Use the installation delivery routes to inspect past attempts and replay dead-lettered events. The delivery list and replay require the extensions:read and extensions:install scopes respectively. The delivery list and replays return payments and saved cards in their public shape , so a stored payload that was projected or cleaned no longer matches its original signature. A replay is signed again over the body it sends. The list shows a payload s customer contact fields only to a caller holding customer_pii:read ; for any other key they read as null . The dashboard pings "},{"title":"Get Started with Extensions","route":"/developers/extensions/get-started","headings":["Get Started with Extensions","Before you begin","Your first private install","You are done when"],"text":"Extensions Get Started with Extensions Build and privately install a third-party app using only Throttle's public dashboard, APIs, packages, and documentation. Before you begin A Throttle workspace and application Developer or Admin access to that application Node.js 20 or newer for the recommended starter A publicly reachable HTTPS URL during iframe or webhook development The list of scopes an extension may request — extensions can only hold scopes marked extensionAllowed , which is a subset of the full API scope catalog. Naming any other scope in your manifest is rejected at publish time. You can create, version, install, and test a private extension in your own workspace. Paid-plan or partner eligibility matters when you submit a public marketplace listing. Your first private install You are done when The iframe displays the verified workspace, application, environment, installation, and user context. The webhook returns 2xx only after verifying the signature against the unmodified raw body. Replaying the same event does not create duplicate provider work. Uninstalling stops scheduled work and removes or disables the installation's provider connection. Next, learn how to design the application , test failure cases , and prepare a marketplace submission ."},{"title":"Extension Identity JWT","route":"/developers/extensions/identity","headings":["Extension Identity JWT","Full claim set","How the dashboard delivers the token","TTL and auto-refresh","Verify tokens in your backend","Using the token as an API bearer","Key rotation","Verifying a shopper's customer token","200, Cache-Control: no-store","{ \"data\": { \"active\": true,","\"customerId\": \"c3d4e5f6-0a1b-4c2d-8e3f-4a5b6c7d8e9f\",","\"emailVerified\": true,","\"expiresAt\": \"2026-10-20T12:15:00.000Z\" } }","# Any token that is not live for this application and environment:","{ \"data\": { \"active\": false, \"customerId\": null, \"emailVerified\": false, \"expiresAt\": null } }`;"],"text":"Extensions Extension Identity JWT When the dashboard opens an extension's iframe, it mints a short-lived RS256-signed JWT that identifies the installation, the acting user, and the current workspace and application context. The iframe receives this token through the bridge handshake and uses it as a bearer on every API call. Use this reference after completing the Get Started tutorial . Identity establishes who launched the iframe; scopes establish what it may do. Full claim set The JWT carries both standard claims ( iss , sub , aud , iat , exp ) and the full ExtensionSessionClaims object as top-level custom claims. The claim set is additive: Throttle adds claims without a version bump and without notice, and never removes or retypes one without a changelog entry. Parse the claims you use and ignore the rest. A strict parser that rejects unknown top-level claims will take every one of your panels dark the moment a claim is added, on our deploy, with no change on your side. iss is always "throttle" . Reject tokens with any other issuer. sub is the installationId . aud is the extensionId . environment reflects the install's workspace environment and matches the API key environment. role is the acting user's application-level role ( admin | developer | finance | viewer ). scopes are the scopes from the pinned version — a ceiling on what the token may do. user.name is currently set to the same value as user.email (workspace members do not have a separate display name column today). How the dashboard delivers the token The dashboard creates a BridgeHost (from @usethrottle/extension-bridge ) that wraps the iframe. When the iframe loads and calls createBridge() , the bridge immediately posts a ready message to the parent. The dashboard calls POST /api/v1/installations/:id/launch-token and replies with a session message carrying token , expiresAt , and a context object. The iframe bridge resolves bridge.ready with that context. POST /api/v1/installations/:id/launch-token is Clerk-JWT only — it requires an authenticated dashboard session. API keys cannot mint launch tokens because tokens embed a human user identity. Attempts with an API key return 400 launch_token_requires_dashboard_session . TTL and auto-refresh Tokens have a 10-minute TTL . The bridge tracks the expiry time and proactively sends a refresh message to the host when the token is within 60 seconds of expiry, or immediately on receiving a 401 from the API. The host mints a fresh token and sends a new session message. All pending and in-flight bridge.api.* calls wait for the refresh before proceeding. Verify tokens in your backend If your extension calls your own backend, pass the bridge token in the Authorization: Bearer header and verify it against the public JWKS. The JWKS endpoint is unauthenticated — no key needed. Always return null / reject on any verification failure — expired tokens, unknown key ID, wrong issuer, bad signature. Never attempt to use a partially-validated payload. The Throttle SDK's verifyExtensionSessionToken follows this contract: it returns null on any error and never throws. Using the token as an API bearer The extension-session JWT is also accepted by the Throttle API as a Bearer token on /api/v1/* routes. The token's scopes claim is used as the caller's granted scope set, exactly as with an API key. This means the iframe can call the API directly without the extension's dedicated API key being present in the browser. The token's scopes are the pinned version's scopes, which are themselves a subset of the extensionAllowed scopes. Calling a route that requires a scope not in the token's list returns 403 insufficient_scopes . Key rotation The JWKS endpoint may publish multiple keys (identified by their kid header). Your verification library (e.g. jose 's createRemoteJWKSet ) automatically selects the right key by kid and caches the JWKS response. Key rotation adds new key(s) to the set before retiring old ones — in-flight tokens remain valid through their TTL, and the JWKS cache handles the transition. Create your createRemoteJWKSet instance once at module initialization and reuse it. The jose library caches the JWKS and re-fetches on unknown kid . Recreating it per-request makes an unnecessary HTTP call on every token verification. See also: Security model for the full threat model around JWT issuance and iframe isolation. Verifying a shopper's customer token The JWT above identifies a dashboard user. A shopper signed in to a storefront holds a different credential: the accessToken from storefront customer accounts . When your extension's backend receives one (for example to show a signed-in shopper their own prices), ask Throttle whether it is live and whose it is. Never trust a customer id the browser sends on its own. Scope. Call it with your installation's secret API key holding customer_tokens:read ; list that scope in your extension's scopes . customers:read does not grant it, and you do not need it: introspection is its own narrow scope so an app that only checks sign-ins never holds customer PII, addresses or stored cards. Publishable pk_ keys cannot hold it ( 403 ). Answer. active: true only for a live access token of the calling key's own workspace, application and environment. Then customerId is the shopper, emailVerified is read from their account now (not from the token), and expiresAt is when the token expires. Inactive. An invalid, expired or revoked token, a signed-out session, or a token of another application or environment all get the same answer, { active: false, customerId: null, emailVerified: false, expiresAt: null } , so the answer tells you nothing more about the token. Treat the shopper as signed out. No caching by Throttle. Responses carry Cache-Control: no-store . Rate limit. The route has its own budget, 600 calls a minute per API key and environment, separate from the rest of yo"},{"title":"Installing Extensions","route":"/developers/extensions/install","headings":["Installing Extensions","Install an extension","Read your signing secret","Scope and event consent","Configuration","Configuration schema","Environment-scoped installs","List and inspect installs","Uninstall","Both the dashboard session token (Clerk JWT) and the Application ID header","are required — installs are application-scoped.","─── Response (apiKey is shown once — store it immediately) ──────────────────","Authenticate with the installation's own API key.","─── Response ─────────────────────────────────────────────────────────","The extension catalog entry and other applications' installs are unaffected.","→ 204 No Content`;","→ { data: { id: \"inst_xxx\", config: { ... }, ... } }`;","Response shape: paginated list","\"data\": [ { \"id\": \"inst_...\", \"extensionId\": \"ext_...\", \"status\": \"active\", ... } ],","\"meta\": { \"requestId\": \"...\", \"pagination\": { \"cursor\": null, \"hasMore\": false } }","One installation by id, scoped the same way. 404 not_found when this","application + environment has no installation with that id."],"text":"Extensions Installing Extensions An install pins a specific published version of an extension to an application, mints a dedicated API key (and optionally a webhook signing secret), and begins event delivery if a webhookUrl was declared. Install an extension Pass the versionId of the published version you want to install, or omit it to install the default version. For another workspace's public extension only the approved version may be named; see Versioning . Optionally pass config — a free-form JSON object that your extension reads at runtime. The install's environment is derived from the caller's API key or dashboard-selected X-Throttle-Environment-Id and cannot be overridden by the request body. apiKey is shown exactly once in the install response. If you lose it, the only recovery is to uninstall and reinstall — a new key is minted. The webhookSigningSecret is different: it is returned once on first install, but it is stable and your extension can read it back at any time (see below), so losing it is recoverable without a reinstall. Having a merchant copy the API key and signing secret out of the install wizard and paste them into your extension is manual setup. It still works, but it is deprecated and will be removed in a future release. Use automatic setup instead: your extension fetches both itself when it is installed. Read your signing secret Your extension can fetch the signing secret for its own installation, so it never has to depend on a human copying the value out of the dashboard at install time: Authenticate with the installation's own API key (or its extension session token, which reads the secret only while the installation is active — once a merchant suspends or uninstalls it, a session minted moments earlier gets 404 for the rest of its ten minutes). A dashboard user holding application:extensions:install can also read it, for a suspended or uninstalled installation too. Any other credential gets a 404 . The route needs no particular scope — the credential itself is the authorisation. Do not attach this response to an error. The natural failure handler — wrap the fetch error with the response body — puts a live signing secret into every error log you keep. Raise an error that carries the HTTP status and nothing else, and read the secret only on the success path. Uninstalling and reinstalling reuses the same installation row, and deliberately keeps the signing secret so verification does not silently start failing. Use installSequence — which increments on every (re)install while id stays put — to tell a reinstall from a restart, and to know when to discard credentials you cached against the install. Scope and event consent When you install an extension, you implicitly consent to its declared scopes and event subscriptions. The installer must hold the extensions:install scope (API key) or the application:extensions:install RBAC permission (dashboard user). The issued API key is scoped exactly to the version's declared scopes. If the extension later publishes a new version with additional scopes , upgrading to that version requires re-consent. The upgrade endpoint returns 409 new_scopes_require_consent listing the new scopes, and the caller must retry with acknowledgedNewScopes: true . See the Versioning guide . Configuration The config field is a free-form JSON object passed at install time and updateable any time thereafter. Use it for extension-specific settings such as API keys for third-party services, feature flags, or integration identifiers. Your extension reads it from the installation record; it is not embedded in the iframe URL or automatically included in the bridge session. Do not place ShipStation keys or other provider secrets in installation config when an iframe can read that config. Collect credentials through an authenticated setup flow, encrypt them in publisher-controlled storage, and retain only a non-secret connection status or provider account label in display configuration. bridge.ready resolves with user, workspace, application, environment, installation id, version, role, and scopes. It intentionally does not include config or configSchema . If a browser iframe needs config, give the extension an explicit read scope and fetch the installation record. Keep secrets that should never reach browser JavaScript on your extension backend instead. Do not store the webhookSigningSecret in config — the config object may be readable by anyone with extensions:read scope. Store the signing secret in your own secrets manager. Configuration schema Extension authors can publish a configSchema to describe the settings an installer should provide. The dashboard authoring flow includes a field builder for simple string, number, and boolean settings plus an advanced JSON editor for custom JSON Schema. During install and on the management page, Throttle uses this schema to render the configuration form. Empty schemas are treated as “no configurable options.” The builder emits a JSON Schema object with type: "object" , properties , and optional required . Use the advanced JSON mode for nested objects, arrays, enums, or provider-specific validation. configSchema lives on the workspace catalog extension while you are editing it. Creating a version snapshots that schema into the immutable version record. Installs use the pinned version's schema to collect per-install config values. Publishing a new schema does not rewrite existing installs until they upgrade to a version that carries it. Environment-scoped installs Installs are per environment. A UAT install receives only UAT events and gets an API key for that environment; a production install receives only production events and gets a production-environment API key. The installs are completely independent — each has its own config, its own API key, and its own event delivery endpoint. List and inspect installs Install status values: active , suspended . Suspended installs sto"},{"title":"Line Resolvers","route":"/developers/extensions/line-resolvers","headings":["Line Resolvers","What a line resolver is","Which installation is called","The request","Verify the signature","Test vector","The answer","Refusal codes and what the buyer gets","Timeouts, retries and fail closed","What Throttle stores","Sign-in merges","Checkout","The completion guard","Shipping on resolved carts","Error reference","Related","Remove the resolver from the draft later (allowed only with PATCH):"],"text":"Extensions Line Resolvers A line resolver lets an installed extension own cart lines. Throttle never takes a price from the browser for a line your extension owns: it asks your resolver for the name, price, stock and limits on every add, quantity change, sign-in merge and checkout. What a line resolver is Your extension declares an HTTPS endpoint, lineResolverUrl . A storefront (or a merchant server) creates a cart with lineResolver: { extensionId } , which makes it a resolved cart . Lines added to it by referenceId are owned by your installation, and Throttle calls your resolver to price them. Opt in, per cart. Only a cart created with lineResolver is resolved. Every other cart behaves exactly as before: same status codes, same error envelopes, same response bodies, same webhooks. The one exception is an environment that turns on resolved lines only : there, unresolved cart-session carts are locked too. One owner per line. Each owned line records the installation that priced it, so a cart can hold lines from two resolvers after a sign-in merge, and each line is re-priced by its own resolver. Fail closed. If your resolver cannot answer, the operation is refused and nothing on the cart changes. Throttle never falls back to a browser price. Shoppers create resolved carts through cart sessions ; servers through the Cart API . This page is the contract for the resolver itself. Declare a resolver: lineResolverUrl lineResolverUrl is a manifest field on the extension, accepted by POST /api/v1/extensions and PATCH /api/v1/extensions/:id . A version snapshots it like any other manifest field, and an installation uses the URL of the version it is pinned to. https: URL on the default port (443). An explicit :443 is fine; any other port is not. , html: 400 validation_error : lineResolverUrl must be an https URL on the default port (443) . ], [ A DNS host name: no IP address (IPv4 or IPv6, in any notation), no localhost or *.localhost, no trailing dot. , html: Same 400 validation_error . ], [ No user name or password in the URL. , html: Same 400 validation_error . ], [ html: Requires webhookUrl and at least one entry in eventSubscriptions . Resolver calls are signed with the managed webhook endpoint's signing secret, which only exists for an extension that receives events. , html: 422 line_resolver_requires_webhook . The check runs on the stored extension merged with the PATCH, so a PATCH cannot drop the webhook side and strand a resolver. ], [ html: PATCH with lineResolverUrl : null removes the resolver. After that, eventSubscriptions: [] is allowed again. , — ], ] /> Domain-verified at review. Marketplace review checks the resolver URL's registrable domain against your workspace's verified domains, exactly as it does for iframeUrl and webhookUrl ( domain_not_verified otherwise). A new domain forces review. A version whose resolver domain differs from the approved one is flagged changed_line_resolver_domain and needs review again. Adding a resolver to an approved extension is a domain change too. See Publishing . Signing secret. The resolver is signed with the same secret your installation receives for webhooks. Rotate it with the webhook secret rotation ; resolver calls follow the same grace window. Throttle reads the signing secret from the managed webhook endpoint linked to the installation. An older installation whose managed endpoint does not carry that link is treated as having no secret: creating a resolved cart answers 422 line_resolver_not_available and an existing resolved cart answers 503 line_resolver_unavailable , both with reason: no_signing_secret . It is never called unsigned. Which installation is called lineResolver.extensionId on cart creation names your extension. Throttle maps it to that extension's installation in the cart's application and environment, and stores the installation on the cart. Creation is refused with 422 line_resolver_not_available and details.reason : A resolved cart's currency must be the environment's currency or one of the merchant's extra cart currencies (case-insensitive), or creation answers 422 currency_not_enabled with details: { currency, enabledCurrencies } , where enabledCurrencies lists the environment's currency and the extra ones. Your resolver is asked for prices in the cart's currency, so it may be called in any of them. On a cart in an extra currency a fixed_amount discount is refused with 422 discount_currency_unsupported , and a non-zero shipping rate from a shipping configuration in another currency with 422 shipping_currency_unsupported , so such a cart checks out with no shipping or a zero-rate method. A cart never changes currency: to sell in another one, start a new resolved cart. If the installation later goes away, is suspended, or loses its URL or secret, calls on its existing carts fail closed with 503 line_resolver_unavailable and the same details.reason . The request Throttle sends one POST per resolver per operation, with a JSON body of type cart.lines.resolve . cart.lines.resolve . ], [ code: id , A UUID for this request. The checkout retry sends the same body, so the same id. ], [ code: installationId , Your installation. Use it to find the merchant catalog to price from. ], [ code: applicationId / environmentId , The cart s application and workspace environment. ], [ code: cartId , The Throttle cart. On a create it is the id the new cart will get. ], [ code: intent , html: Why Throttle is asking: add , update , refresh or checkout (below). ], [ code: currency , The cart currency. Every ok line must answer in it. ], [ code: customerId , The signed-in customer of the cart, or null for a guest cart. ], [ code: customerEmailVerified , True when that customer has verified the email on their storefront account. ], [ code: locale , html: The buyer locale given at cart creation (for example en-GB ), or null. ], [ code: lines , html: 1 to 50 lines, each { lineI"},{"title":"Operating Your Extension","route":"/developers/extensions/operations","headings":["Operating Your Extension","Responsibility boundary","Throttle operates","The publisher operates","Baseline telemetry","Incident and lifecycle procedures"],"text":"Extensions Operating Your Extension Marketplace publication starts an operational commitment: monitor the integration, support installations, and release compatible upgrades. Responsibility boundary Throttle operates Catalog, versions, installations, and review systems Scoped extension credentials and identity tokens Signed event delivery and delivery logs Marketplace availability and platform status The publisher operates Application hosting, queues, and persistence Third-party credentials and provider API use Mapping, reconciliation, monitoring, and alerting Customer support, privacy obligations, and incident response Baseline telemetry Record the Throttle request ID, installation ID, event ID and type, extension version, provider account reference, attempt number, duration, result class, and environment. Use structured logs so one installation can be traced without searching payload contents. Do not log API keys, webhook signing secrets, access tokens, authorization headers, or customer-sensitive request bodies. Redact provider responses before attaching them to support cases. Incident and lifecycle procedures Back off and reconcile after provider outages or rate limiting. Surface expired credentials as an actionable installation state. Quarantine poison events and provide a replay procedure. Pause new installs or move the listing private during a material incident. Keep upgrades backward compatible; document and review new scopes. Publish deprecation notice and data export/deletion instructions before retirement. On uninstall, revoke provider access and stop scheduled or queued work safely. Use the Versioning guide for releases and the marketplace guide for listing changes or re-review."},{"title":"Extensions Overview","route":"/developers/extensions/overview","headings":["Extensions Overview","Choose your extension shape","Backend integration","Embedded application","Hybrid application","What Throttle provides—and what you provide","Throttle","Publisher","Two-level model: catalog vs installs","Catalog (workspace-level)","Installs (application-level)","Surfaces","UI iframe","Event webhooks","Lifecycle: draft → private → public","Versioning and pinned installs","Environment isolation"],"text":"Extensions Extensions Overview Extensions let you embed custom UI panels, receive live event webhooks, or do both — all from inside the Throttle dashboard and API surface, with an authenticated identity token, scoped API access, and signed event delivery. Choose your extension shape Backend integration Subscribe to events and process them on your server. Use this for connectors, synchronization, and work that must continue when nobody has the dashboard open. Embedded application Render an iframe and use the bridge for verified identity and scoped API access. Use this for configuration, reports, and operator workflows. Hybrid application Combine a background event processor with an embedded management UI. Most full-service provider connectors eventually use this model. A ShipStation-style connector should synchronize orders, fulfillment, tracking, cancellations, and returns on its backend. An iframe can configure and monitor the connector, but synchronization cannot depend on the iframe being open. What Throttle provides—and what you provide Throttle Catalog, version, installation, and marketplace workflows Scoped credentials and signed iframe identity Signed event delivery and installation configuration forms Publisher Hosting, persistence, queues, retries, and monitoring Third-party credentials and provider-specific behavior Support, privacy terms, incident response, and data lifecycle External developers use public APIs, packages, the dashboard, and these docs. You do not need access to Throttle's private repository. Build your first private install . Two-level model: catalog vs installs Extensions live at two levels. The catalog is workspace-scoped: one extension definition per workspace, with a name, surfaces (iframe URL and/or webhook URL + events), and a list of requested scopes. An extension can be authored by your workspace and used only internally, or promoted to public for any Throttle workspace to install. Installs are per-application. When a dashboard user installs an extension into an application, a single-use API key and (if a webhook URL is declared) a per-install signing secret are issued. Both are shown exactly once at install time; an extension should fetch its own with automatic setup , because having the merchant paste them in is deprecated. Multiple applications in the same workspace can each have their own independent installation of the same extension, with separate API keys, signing secrets, and configuration values. Catalog browsing supports cursor pagination and search. Use q to search extension name, slug, author, and description, and installable=true to return only private/public extensions with a published version before pagination is applied. Catalog (workspace-level) POST /api/v1/extensions — create GET /api/v1/extensions — list PATCH /api/v1/extensions/:id — update manifest POST /api/v1/extensions/:id/versions — create version POST /api/v1/extensions/:id/versions/:vid/publish — publish POST /api/v1/extensions/:id/lifecycle — promote/demote Installs (application-level) POST /api/v1/extensions/:id/install — install (pinned to a version) GET /api/v1/installations — list for this app POST /api/v1/installations/:id/upgrade — upgrade to new version PATCH /api/v1/installations/:id/config — update config POST /api/v1/extensions/:id/uninstall — uninstall Surfaces An extension manifest declares one or both surfaces. You can mix them freely. UI iframe Declare an iframeUrl in the manifest. The dashboard renders a sandboxed <iframe> at that URL when the extension panel is open. Your page calls createBridge() from @usethrottle/extension-bridge (or the hosted script tag) to handshake with the dashboard, obtain a signed identity token, and make authenticated API calls. See the Build guide and the Identity JWT reference . Event webhooks Declare a webhookUrl plus the list of eventSubscriptions your extension wants to receive. Throttle creates a managed webhook endpoint for the install and delivers signed events to that URL automatically. The per-install webhookSigningSecret is returned in the install response, and — unlike the API key — it is not lost if you miss it: GET /api/v1/installations/:id/webhook-secret reads the current value back at any time. See the Events reference . An extension can declare both iframeUrl and webhookUrl . The iframe bridge and the webhook delivery pipeline are independent — the bridge token gives the iframe REST access, while the webhook pipeline delivers async events to your server. Use both when you need a live UI panel and background event processing. Lifecycle: draft → private → public Every extension starts as draft . You iterate freely — create versions, update the manifest — before promoting it. draft — visible only within your workspace; can only be installed by your own workspace's applications; no published version required. private — still workspace-private but signifies the extension is production-ready. Installs within your workspace are in this state during internal testing. public — listed in the global marketplace; any Throttle workspace can install it. Requires the application:extensions:admin permission. Throttle must enable the public marketplace for your workspace. Versioning and pinned installs Installs are always pinned to a specific published version. Publishing a new version does not automatically upgrade existing installs — each install retains the version it was pinned to until explicitly upgraded via POST /api/v1/installations/:id/upgrade . If the new version declares additional scopes, the installer must acknowledge them; Throttle returns 409 new_scopes_require_consent listing the delta. See the Versioning guide . Environment isolation Installs carry the workspace environmentId selected by the installing caller. A UAT install issues a UAT API key and receives only UAT events; a production install issues a production API key and receives only production events. The catalog itself (the extension definit"},{"title":"Publishing to the Marketplace","route":"/developers/extensions/publishing","headings":["Publishing to the Marketplace","Before you submit","Eligibility","Paid plan or partner status","Publisher Agreement accepted","Listing requirements","Review lifecycle","What “Approved” unlocks","Future versions and re-review","Auto-publish (no re-review)","Requires re-review","Launch and rollback","Webhook events","Related guides","Requires: an accepted Publisher Agreement + a completed Listing.","Response:","\"data\": {","\"id\": \"rev_01HZX...\",","\"extensionId\": \"ext_xxx\",","\"status\": \"pending\",","\"versionId\": \"ver_xxx\",","\"createdAt\": \"2026-06-06T10:00:00.000Z\"","}","}`;","(a dashboard session works too). Field name is \"file\"; the response carries the","public URL to put on the extension / listing.","→ { \"iconUrl\": \"https://.../extensions/icons/01J....png\", \"key\": \"...\" }","→ { \"screenshotUrl\": \"https://.../extensions/screenshots/01J....png\", \"key\": \"...\" }","Then reference them: iconUrl on PATCH /api/v1/extensions/:id, and the","screenshot URLs in the listing's \"screenshots\" array on PUT /api/v1/extensions/:id/listing.`;","→ { data: { id: \"ext_xxx\", lifecycle: \"public\", ... } }`;"],"text":"Extensions Publishing to the Marketplace The Throttle marketplace lets any workspace discover and install your extension. Getting there requires meeting eligibility requirements, completing a listing, and passing a staff review. You control when the extension goes live after approval. Before you submit Confirm workspace eligibility and accept the Publisher Agreement. Publish the exact version you tested and document its functionality and upgrade behavior. Complete the listing, support contact, privacy policy, and terms of service. Demonstrate why every requested scope and event subscription is necessary. Complete the testing preflight and security review . Name an operational owner who will monitor errors and respond to customer and Throttle review messages. Review status and feedback are visible in the dashboard. Throttle does not promise a review turnaround in this guide; plan your launch around approval rather than a speculative date. Eligibility Your workspace must meet both of the following conditions before you can submit an extension for review: Paid plan or partner status Your workspace must be on an active paid Throttle plan, or have partner status granted by Throttle. Sandbox (trial) workspaces cannot submit extensions for public marketplace review. If you are on a trial and want to publish, upgrade your workspace first or apply for the partner program . Publisher Agreement accepted A workspace owner or admin must accept the Throttle Publisher Agreement in the dashboard under Extensions → Catalog → [your extension] → Review → Publisher Agreement . Acceptance is recorded once per workspace and covers all extensions you publish. The Submit button is disabled until acceptance is on record. Approval records that a specific version passed review; it does not list you. A workspace admin still has to set the extension's lifecycle to public , and that is only permitted while the approved version is still the one you are serving. Publishing any change afterwards moves you off the approved version and requires a new review before you can go public again. Listing requirements Before submitting for review you must complete your extension's public listing. The preflight check validates all of the following fields. Missing or invalid fields are returned as an array of specific errors with a 422 response. Public name — a human-readable display name for the marketplace listing, separate from the internal extension slug. Tagline — a single sentence (≤ 80 characters) that describes what the extension does. Category — one of the marketplace taxonomy categories (e.g. analytics , fulfillment , finance ). Displayed as a filter on the browse page. Icon — a square PNG, JPEG, WebP or SVG (≤ 2 MB) uploaded via POST /api/v1/extensions/icon-upload . Stored as a public object and referenced by its public URL. At least one screenshot — a minimum of one screenshot or promotional image (PNG, JPEG or WebP, ≤ 5 MB, 1280 × 800 recommended) uploaded via POST /api/v1/extensions/screenshot-upload . Used in the detail page carousel. Support URL — an HTTPS URL to a support page or ticketing system. Privacy policy URL — an HTTPS URL to your privacy policy. Required by the Publisher Agreement. Terms of service URL — an HTTPS URL to your extension's terms of service. The preflight check rejects any support, privacy, or terms URL that does not begin with https:// . Plain HTTP URLs are not accepted. Both image uploads are multipart/form-data and accept a secret API key with extensions:write as well as a dashboard session, so a release script can complete the whole listing without the dashboard. Review lifecycle Submitting for review kicks off an automated preflight followed by a manual staff review. The full state machine is: Submit — call POST /api/v1/extensions/:id/reviews (or use the dashboard Review tab). Throttle runs automated preflight checks (listing completeness, eligibility, scope diff against the previous approved version) and creates a review with status pending . A confirmation notification and email are sent to workspace members with the extensions:write scope. In review — a Throttle staff member claims the review. The review moves to in_review . Staff can see the automated check results and the scope delta relative to the previously approved version. Decision — staff selects one of three outcomes: Approved — the extension passes review. The developer receives a notification and email and can now publish the extension. Changes requested — staff adds feedback in a threaded conversation. The developer receives a notification with the feedback, addresses the issues, and resubmits. Rejected — the extension will not be published to the public marketplace. Staff includes a reason. The developer may fix the underlying issues and submit a new version for review. The review has a comment thread. Staff can add comments at any point during the review and the developer receives notifications for each message. Use the dashboard Review tab or POST /api/v1/extensions/:id/reviews/:rid/comments to reply. What “Approved” unlocks An approved review does not automatically make your extension public. Approval unlocks the Publish to marketplace button in the dashboard Lifecycle tab and the POST /api/v1/extensions/:id/lifecycle endpoint with lifecycle: "public" . You choose when to go live. Once public , the extension appears in GET /api/v1/extensions?installable=true and is browsable in the marketplace at /w/:ws/a/:app/marketplace . You can revert to private at any time via the lifecycle endpoint. Existing installs are not affected by a lifecycle change. Setting the lifecycle to private automatically publishes a version snapshotting the current configuration when no published version matches it, so a private extension is always immediately installable. Installing an extension that somehow has no published version fails with 409 no_published_version . Throttle retains the ability to revert a pu"},{"title":"Extension Scopes","route":"/developers/extensions/scopes","headings":["Extension Scopes","How scopes flow","Scope implication rules","Management scopes for extensions","Management scope summary","Team RBAC for extension management","Extension-allowed scope catalog","{group}","Name a page of known customers: up to 50 ids, comma-separated","200, Cache-Control: no-store","{ \"data\": [ { \"id\": \"c3d4e5f6-0a1b-4c2d-8e3f-4a5b6c7d8e9f\",","\"name\": \"Jane Doe\",","\"email\": \"jane@example.com\" } ],","\"nextCursor\": null }`;"],"text":"Extensions Extension Scopes Extensions operate within a strict scope ceiling. Only scopes marked extensionAllowed may appear in an extension manifest. Human, admin, and management scopes are never grantable to extensions. Choose the smallest scope set that supports demonstrated behavior. Once selected, continue to installation and configuration . How scopes flow Scopes are declared in the extension manifest, snapshotted into a published version, and issued to an install. At each stage Throttle enforces the extensionAllowed ceiling: Manifest declaration. You list the scopes your extension needs in POST /api/v1/extensions or PATCH /api/v1/extensions/:id . validateScopesForExtension runs immediately — any scope with extensionAllowed: false (or an unknown scope) returns 400 invalid_scope . Version snapshot. When you publish a version, the manifest scopes are frozen into the version record. Subsequent manifest edits do not change the published version. Install. POST /api/v1/extensions/:id/install pins the install to a version. The install's API key is granted exactly the version's scopes — no more, no less. Iframe JWT. The launch-token endpoint mints a JWT whose scopes claim is the pinned version's scope list. Every bridge.api.* call is authorized against those scopes. Scope implication rules The same implication rules that apply to API keys apply to extension scopes: * covers all scopes — but * is never extensionAllowed and cannot appear in an extension manifest. resource:write implies resource:read on the same resource . For example, orders:write covers orders:read . Read never implies write. orders:read does NOT grant orders:write . No cross-resource implication. orders:read does not grant customers:read . The buyer s email, name and phone in event payloads need customer_pii:read . Without it they arrive as null ; addresses are still delivered. See customer contact fields . Finding customers: customer_directory:read An extension that shows or picks customers (for example a customer-group member list) needs their names, not their records. customer_directory:read answers only id , name and email , without customers:read and the PII, addresses and stored cards it carries. Scope. List customer_directory:read in your extension's scopes and call with the installation's secret key. The merchant sees it at install as "Find customers by name or email", among the other requested scopes. It is sensitive , so no OAuth connection can hold it. No other scope grants it except full access ( * ), customers:read included; a key without it gets 403 insufficient_scopes . Parameters. Send exactly one of q or ids . q is 2–100 characters, matched as a case-insensitive prefix of the full name, the last name or the email, and answers up to limit customers (1–20, default 10) ordered by email. ids is up to 50 comma-separated customer ids; every id found is answered, in the order given, and an integer limit is ignored. Unknown ids are skipped. A search never lists an erased (anonymized) customer; ids still names one, with name: null . Anything else is 400 validation_error . Answer. Only customers of the calling key's own workspace, application and environment. name is the first and last name joined by a space, or null when there is neither. nextCursor is always null : the directory answers one page. Responses carry Cache-Control: no-store ; do not store the answer either. Rate limit. Its own budget, 300 calls a minute per API key (per installation for calls from your iframe session) and environment. Over it you get 429 rate_limit_exceeded . Management scopes for extensions The management scopes ( extensions:read , extensions:write , extensions:install ) control who can author and install extensions. These are Taxonomy B (human) scopes — they are not extensionAllowed . Extensions cannot manage other extensions. Management scope summary extensions:read — view the extension catalog and installation list. Required for GET /api/v1/extensions , GET /api/v1/extensions/:id , GET /api/v1/extensions/:id/versions , GET /api/v1/installations , GET /api/v1/installations/:id/deliveries . extensions:write — author, version, and publish extensions. Required for POST /api/v1/extensions , PATCH /api/v1/extensions/:id , POST /api/v1/extensions/:id/versions , POST /api/v1/extensions/:id/versions/:vid/publish , POST /api/v1/extensions/:id/lifecycle (draft/private). extensions:install — install, configure, and manage installs. Required for POST /api/v1/extensions/:id/install , POST /api/v1/extensions/:id/uninstall , PATCH /api/v1/installations/:id/config , POST /api/v1/installations/:id/upgrade , POST /api/v1/installations/:id/launch-token , POST /api/v1/installations/:id/deliveries/:deliveryId/replay , POST /api/v1/installations/:id/test . The extensions:install scope is a custom action — it is independent of extensions:write and extensions:read . Granting only extensions:install to a key lets it manage installations without being able to author or list the catalog. Team RBAC for extension management Dashboard (Clerk) callers are additionally gated by RBAC permissions, not just scopes. The relevant RBAC keys are: application:extensions:read — all app roles ( admin | developer | finance | viewer ). application:extensions:write — app admin and developer . application:extensions:install — app admin and developer . application:extensions:admin — required to promote an extension to public or to delete a catalog entry. Only admin role. Workspace owners and workspace admins have implicit admin role on every application. See the Permissions reference for the full role-capability matrix. Extension-allowed scope catalog The following scopes may appear in an extension manifest. They are all extensionAllowed: true and sourced directly from the live scope registry — they update automatically when new scopes are added. extensionScopeGroups.map(( group, scopes ) => ( group scopes.map((def) => ( def.id — def.label"},{"title":"Scripts an Extension Ships","route":"/developers/extensions/scripts","headings":["Scripts an Extension Ships","The two tiers","Declaring a script","Shipping an update","Writing the script","Settings — one script, different merchants","Buyer data","Loading a vendor library","Cookies and storage","Review","Install lifecycle — when your scripts start and stop","Limits","then attach source to it."],"text":"Extensions Scripts an Extension Ships An extension can ship JavaScript that runs on the merchant's buyer-facing pages — hosted checkout, quote pages, and the merchant's own storefront. This is how a pixel, an analytics relay, a fraud signal or an announcement bar reaches real buyers without the merchant pasting code into a theme. Read this after your first private install . For the merchant's view of the same feature — and for a merchant writing their own script rather than installing an app — see Your Own Scripts . The two tiers Pick the tier when you declare the script. It decides where the code can run and how hard it is to get approved. sandbox — runs inside a null-origin sandboxed iframe with no access to the host page, its cookies or its storage. Allowed on every surface, including checkout. This is the default and what almost every app should use. dom — runs directly on the merchant's storefront page with full access to it. Restricted to the storefront surface, refused on checkout by the API, the manifest schema and a database constraint, and it requires a staff review of the source for every change. DOM-tier code is unsandboxed third-party JavaScript on every installed merchant's storefront. Nothing contains it, so it never auto-approves — a human reads the source. Use sandbox unless you genuinely need the page, and expect longer review turnaround when you do not. Declaring a script A script has two halves: a declaration , one per (extension, key) , carrying what the script is allowed to do; and source , which is per extension version. Source is attached to a draft version and frozen when you publish it. key — a stable lowercase handle. It identifies the script forever; merchants see it in their script inventory. surfaces — any of storefront , checkout , quote . consentCategory — essential , functional , analytics or marketing . The script does not mount at all until the buyer has granted that category. protectedData — none or hashed . See Buyer data . externalDomains — hostnames the script may reach. Enforced as a Content Security Policy, which matches hosts exactly : google-analytics.com does not cover www.google-analytics.com . One leading wildcard label is allowed — *.klaviyo.com — for a vendor SDK whose host set is the vendor's to change; it covers every subdomain and not the bare domain. A request the CSP refuses is reported as a blocked outcome with reason: csp , so it is diagnosable from the merchant's script health view. The list is frozen once a version containing the script is published. Shipping an update Pushing a key that already exists replaces its source on the target draft version — that is how you ship a new build. The response is 201 for a key that was new and 200 when an existing one was replaced. Capabilities freeze once the script has shipped. After any version containing the script is published, a push that changes tier , surfaces , consentCategory , protectedData or externalDomains is refused with 409 script_published_capabilities_frozen . Those fields live on the declaration and take effect immediately on every install already pinned to an approved version, so changing one would bypass review. Ship the new behaviour under a new key. Source-only pushes are always fine while the target version is a draft. Bug fixes do not need a new key. A published version is frozen. Attaching to one returns 409 extension_version_published — a sha256 in a merchant's inventory has to be the sha256 that actually ran. A new version inherits the previous version's scripts. Creating a version copies every non-deleted script forward byte-for-byte, so a version that changes nothing keeps shipping what you already shipped. Push only what changed. Deleting the declaration is how you stop shipping a script. A version that adds a script key the merchant does not already run requires them to re-consent, with acknowledgedNewScripts on the upgrade call. A version that drops one does not re-prompt: the merchant ends up running less, which needs no permission. Writing the script A sandbox-tier script runs inside a null-origin iframe. Four bindings exist at the top level: register , analytics , browser and settings . document , window , parent , top , opener , localStorage and sessionStorage are undefined . api.browser at the top level of your file throws api is not defined , and the merchant sees an error in their health panel with no other clue. Use the bare bindings at the top level, or api.* inside the register callback. Settings — one script, different merchants Every install can carry its own values for your script. A merchant sets them on the installed app's Scripts tab, and they arrive as settings in a sandbox script or as a data- attribute on a dom-tier tag. Missing settings are an empty object, never an error. Handle the empty case by doing nothing rather than throwing. The same tab lets a merchant switch one of your scripts off without uninstalling your app. Treat that as normal. They are handed to code running on a public page, and on the dom tier they are an attribute in the page's own DOM. Never ask a merchant to put a secret in them — use your install-time API key and your own backend for anything privileged. Buyer data What a script receives on checkout.completed depends entirely on the protectedData grade it declared. none — no buyer identifiers at all. The default, and correct for most scripts. hashed — emailSha256 and phoneSha256 , each a SHA-256 of the lowercased, trimmed value. This is the form GA4 and Meta's advanced matching want. A third grade, raw , used to be accepted and stored — and delivered exactly what none delivers. An app could declare it, pass review, and then receive no identifiers at all with nothing explaining why. It is now refused with a message pointing at hashed . If your integration genuinely needs plaintext, it needs a grade that does not exist yet, and that is a consent and data-protecti"},{"title":"Security Model","route":"/developers/extensions/security","headings":["Security Model","RS256 + JWKS","Iframe sandboxing and origin pinning","Short-lived tokens with auto-refresh","Scope ceilings","Webhook signature verification","Summary"],"text":"Extensions Security Model The extension security model is built on four pillars: RS256 asymmetric JWT signing with a public JWKS, iframe sandboxing and origin pinning via the bridge protocol, short-lived tokens with auto-refresh, and strict scope ceilings that block human and admin access. Apply these controls from the first private install. Before public submission, complete the testing preflight and the marketplace checklist . RS256 + JWKS Extension-session JWTs are signed with an RSA private key (RS256) held server-side. Throttle publishes the corresponding public key(s) at GET /.well-known/extension-jwks.json in standard JWK Set format. Any out-of-process verifier — your extension backend, a middleware layer, a Cloudflare Worker — can verify tokens without a Throttle-specific SDK or a symmetric shared secret. The protected header always carries alg: "RS256" and a kid . Reject tokens with any other algorithm. The issuer is always "throttle" . Reject tokens with a different issuer even if the signature verifies. The JWKS endpoint may publish multiple keys (key rotation). Your verification library selects the right key by kid automatically. Old keys remain in the set through their last token's TTL. The endpoint is unauthenticated — no API key required. Cache it at module init via createRemoteJWKSet . If token verification fails for any reason — expired, bad signature, wrong issuer, unknown kid , malformed structure — return null and reject the request. Never attempt to use a partially-validated payload. Iframe sandboxing and origin pinning The dashboard renders extension iframes with a restrictive sandbox attribute. All communication between the iframe and the dashboard host goes through the bridge's typed postMessage protocol — the iframe cannot access the parent's DOM or navigate the parent directly (those requests go through the bridge's navigate helper, which the host controls). Origin pinning works on both sides of the bridge: Iframe side — the targetOrigin option passed to createBridge() restricts outgoing postMessage calls to the dashboard origin. Set this to 'https://app.usethrottle.dev' in production. Host side — the bridge host validates that inbound messages come from iframe.contentWindow at the declared targetOrigin . Messages from any other window or origin are silently ignored. Without targetOrigin , the bridge falls back to document.referrer and ultimately to '*' . A wildcard target sends the session token — including the signed JWT — to all windows in the frame stack. Always set targetOrigin to the exact dashboard origin. Short-lived tokens with auto-refresh Extension-session JWTs have a 10-minute TTL . Short TTLs limit the exposure window if a token is intercepted. The bridge handles renewal automatically: When the current token is within 60 seconds of expiry, the bridge proactively sends a refresh message to the host. When a bridge.api.* call receives a 401 , the bridge requests a refresh and retries the call once. The host calls POST /api/v1/installations/:id/launch-token and sends a fresh session message with the new token. All bridge.api.* calls in flight during a refresh wait for the new token before proceeding. Launch tokens can only be minted for active Clerk (dashboard) sessions — API keys cannot mint them. This ensures the token always embeds a real human identity and can be revoked by ending the Clerk session. Scope ceilings Extensions operate under a two-level scope ceiling. First, only extensionAllowed scopes may appear in a manifest — human scopes (team, billing, email, admin) are blocked at registration time. Second, the issued JWT and API key are scoped exactly to the pinned version's declared scopes — even if the workspace's API key holds broader access, the extension never inherits it. See the Scopes reference for the full extensionAllowed catalog and the management scope details. Webhook signature verification Extension webhook deliveries are signed with HMAC-SHA256 using a per-install signing secret. The signature covers the delivery timestamp and the raw body, preventing replay attacks older than 5 minutes (configurable). The signing secret is unique per install and stable — reinstalling does not rotate it, so verification keeps working across an uninstall/reinstall cycle. Read the current value any time with GET /api/v1/installations/:id/webhook-secret . The verifyWebhook helper uses constant-time comparison to prevent timing attacks. Never verify signatures with string equality ( === ) — use the provided helper. Capture the raw body before any JSON parsing. Parsing and re-serializing changes whitespace and key ordering and will break verification. Each installation has its own signing secret. A compromised secret for one install does not affect other installs or other applications. The secret survives an uninstall and reinstall — the installation row is reused — so a merchant reinstalling your extension will not break signature verification. See the Events reference for the full verification workflow and event catalog. Summary RS256 + public JWKS — no shared secret needed to verify tokens; key rotation is transparent to verifiers. Iframe sandboxing — restricts iframe capabilities; no direct DOM access to the parent. Origin pinning — postMessage is locked to the dashboard origin on both sides of the bridge. 10-minute token TTL — limits exposure window; auto-refresh keeps the session alive without user interaction. extensionAllowed scope ceiling — human, admin, and management scopes are never grantable to extensions. Per-install secrets — API key and webhook signing secret are unique per install; a compromised install is isolated. HMAC-SHA256 with replay protection — deliveries older than 5 minutes are rejected; constant-time comparison prevents timing attacks."},{"title":"Extension Starter Repository","route":"/developers/extensions/starter-repository","headings":["Extension Starter Repository","Clone and run","What the starter includes","Extension surfaces","Safe integration foundations","Repository strategy","Required customization"],"text":"Extensions Extension Starter Repository The public starter will provide a production-shaped baseline for iframe, webhook, and hybrid extensions without depending on Throttle's private monorepo. The starter is published at github.com/Epic-Design-Labs/throttle-extension-starter . It implements the hybrid shape (embedded React iframe UI + a Cloudflare Worker backend) and is a template to copy, not a dependency. No private repository access is required. Clone and run The five-minute quickstart runs against a mocked bridge — no Throttle account or Cloudflare deploy needed. When you are ready to build your own integration, customize the template with pnpm run setup and follow the starter README through Test-mode registration, real webhook delivery, and Cloudflare deployment. What the starter includes Extension surfaces Iframe route and bridge handshake Backend identity-token verification Raw-body webhook signature verification Health endpoint and structured logging Safe integration foundations Install-aware persistence Idempotent event processing Environment-variable template Unit tests and deployment guidance Repository strategy Use one repository per provider integration by default. ShipStation, ERP, tax, analytics, and messaging connectors have independent credentials, data models, release schedules, incidents, and support ownership. A monorepo is reasonable only when one team deliberately shares their runtime and deployment lifecycle. The starter is a template, not a hosted runtime dependency. Your publisher workspace owns the catalog entry; your repository and infrastructure own the application code and provider synchronization. Required customization Declare the minimum scopes and event subscriptions, chosen from the scopes an extension may request . Define installation configuration without exposing provider secrets to browser JavaScript. Replace example handlers with provider-specific mapping and reconciliation. Add the provider's rate limits, retry policy, credential rotation, and support runbook. Validate clean installation and uninstall in Test mode. Pair the starter with the Build guide and use the security requirements as the implementation baseline."},{"title":"Testing Extensions","route":"/developers/extensions/testing","headings":["Testing Extensions","Testing ladder","Required scenarios","Delivery and recovery","Lifecycle and data","Marketplace preflight"],"text":"Extensions Testing Extensions Test mapping, delivery, isolation, recovery, installation, and removal before asking another workspace to trust your app. Testing ladder Unit tests — mapping, signature verification, idempotency, and conflict rules. Contract tests — current public Throttle request, response, and event shapes. Local HTTPS tests — iframe handshake and signed webhook delivery through a tunnel. Private install — full setup in a Test-mode application with realistic fixtures. Failure injection — timeouts, rate limits, expired credentials, retries, and provider outages. Production smoke test — a small, non-destructive live test after launch approval. Required scenarios Delivery and recovery Valid, missing, stale, and invalid signatures Duplicate and out-of-order events Timeout, 429, and 5xx retry behavior Poison event and dead-letter recovery Uninstall while work is queued Lifecycle and data Pagination and missing optional fields Version upgrade and new-scope consent Strict Test/Live separation Provider credential expiration Cancellation and return races for fulfillment apps Marketplace preflight All public endpoints use HTTPS and expose a useful health check. Scopes and event subscriptions are limited to demonstrated functionality. A clean install, configuration update, upgrade, replay, and uninstall all succeed. Logs and screenshots contain no credentials or customer-sensitive data. Support, privacy, and terms URLs are public and accurate. Use Test mode throughout development. Verify production behavior deliberately with live credentials only after the extension passes security and marketplace preflight. Review the Security Model before following the publishing procedure ."},{"title":"Extension Versioning","route":"/developers/extensions/versioning","headings":["Extension Versioning","Version lifecycle","Which versions can be pinned","Installs are pinned","Upgrading","Rollback","Versioning strategy","Response:","\"data\": {","\"id\": \"ver_new...\",","\"version\": \"1.1.0\",","\"status\": \"draft\",","\"scopes\": [\"orders:read\", \"customers:read\"],","\"notes\": \"...\",","\"createdAt\": \"...\"","}","}`;","Response: { data: { versions: [ { id, version, status, scopes, ... }, ... ] } }`;","If the new version requests scopes not in the current install:","HTTP 409","\"error\": {","\"code\": \"new_scopes_require_consent\",","\"newScopes\": [\"customers:read\"]","Retry with explicit consent:","→ { data: { id: \"inst_xxx\", versionId: \"ver_new...\", ... } }`;","\"Rollback\" and \"upgrade\" use the same endpoint — just target an earlier versionId.","No consent required if the older version's scopes are a subset of the current scopes."],"text":"Extensions Extension Versioning Extension versions are immutable snapshots of a manifest. Published versions cannot be changed. Installs are always pinned to a specific version, so rolling out a change requires publishing a new version and upgrading each install explicitly. After publishing an upgrade, follow the operations guide to monitor adoption, compatibility, and incidents. Version lifecycle A version starts as draft and becomes published when you publish it. Once published, the version record is frozen — its scopes , iframeUrl , webhookUrl , and eventSubscriptions never change. Which versions can be pinned An install that names a versionId and an upgrade or rollback that names a targetVersionId follow the same rule: The version must belong to the extension. Any other id, including a version of a different extension, returns 404 not_found . A draft version is never pinned in production, which returns 422 draft_not_installable_in_live . Outside production, only the workspace that owns the extension may pin a draft. The workspace that owns the extension may pin any of its other versions, including published versions still awaiting marketplace review and older versions. Any other workspace may pin only the version a fresh install would get: the approved version of a public extension, or its latest published version if it has never been approved. Anything else returns 422 version_not_approved . An install with no versionId pins that same default version. Installs are pinned When an extension is installed, the install row records the exact versionId . Publishing a new version has no effect on existing installs — they continue running on the pinned version indefinitely. This provides stability: a breaking manifest change cannot affect installs that have not opted in to the upgrade. The dashboard may surface an "upgrade available" indicator when a newer published version exists for an installed extension. The installer decides when and whether to upgrade. Upgrading Use POST /api/v1/installations/:id/upgrade to move an install to a different published version. The install's API key is updated to reflect the new version's scopes; the config is preserved. Only an active installation can be upgraded or rolled back. A suspended or uninstalled installation returns 409 installation_not_active and is left unchanged. If the new version's scopes are a subset of the current version's scopes (scope reduction), no consent is required and the upgrade proceeds immediately. If the new version requests additional scopes, the endpoint returns 409 new_scopes_require_consent with the delta. Retry with acknowledgedNewScopes: true to confirm. The target must be a version this workspace may pin (see Which versions can be pinned ). Installers of another workspace's public extension can move to a new version once it is approved. The consent requirement protects against a malicious extension publisher silently acquiring new scopes. The installer must explicitly acknowledge each new scope — the 409 lists exactly which scopes are being added so the operator can make an informed decision. Rollback Rolling back to an older version uses the same upgrade endpoint — just target an earlier versionId . Because older versions have fewer or equal scopes, rollback typically does not require consent. After rollback, the install runs the older version's manifest (including its iframeUrl and event subscriptions). Rollback follows the same pinning rule as an upgrade: the owning workspace can roll back to any of its versions, while installers of another workspace's public extension can only target its approved version. The installation's config object is not changed by an upgrade or rollback. If the new version requires different config keys, update config separately via PATCH /api/v1/installations/:id/config . Versioning strategy Use semantic versioning (e.g. 1.0.0 ) — it is stored as a string, not parsed. Additive changes (new UI features within the same scopes, bug fixes, updated iframeUrl ) can be shipped as a new version without requiring install consent. New scopes always require explicit upgrader consent. Removing scopes does not require consent — it is a reduction. Keep at least the previous published version available to allow rollback without republishing. See also: Security model for how scope ceilings are enforced at the version level, and Installing extensions for the full install lifecycle."},{"title":"Idempotency-Key","route":"/developers/idempotency","headings":["Idempotency-Key","When to use it","Usage","How replay works","Scope","first response byte-for-byte. The header below tells you it's a replay."],"text":"Reliability Idempotency-Key Make every mutating Throttle request safe to retry by attaching an Idempotency-Key header. Throttle will replay the original response if you retry within 24 hours, even if the network blew up midway through the first attempt. When to use it Always, on every POST , PATCH , and DELETE from a server you can't fully trust the network between (i.e. all of them). It costs nothing when the request succeeds first try, and saves you from double- charging, double-creating, or double-cancelling on retry. Generate a fresh key per logical operation. A UUID v4 is the canonical choice. Reuse the same key when retrying the same operation (timeout, 5xx, connection reset). Do not reuse a key for a different operation. Throttle will reject the second call with 409 idempotency_key_reused . Usage How replay works Throttle hashes the canonicalised request body (SHA-256, key-sorted) alongside the key. On the second arrival within 24 hours: Same key + same body → original response replayed byte-for-byte with an extra Idempotency-Replay: true response header. The handler does NOT run again. Same key + different body → 409 idempotency_key_reused . Surfaces the integrator bug instead of double-charging. Scope Per workspace, per (method, path). The same key can be reused across endpoints without colliding — Throttle scopes the cache by tuple (workspaceId, key, method, path) . The path is the concrete request path with its ids filled in (the query string is ignored), so the same key sent to /subscriptions/A/pause and /subscriptions/B/pause is two separate operations. TTL: 24 hours. After 24h, the same key can be used again without replay. GET / HEAD requests ignore the header. They're already idempotent. 5xx responses are not cached. A transient server error doesn't pin a failure response to your key. The whole point is that the SAME key is reused. Generating a new UUID on every retry defeats the cache and risks double-charging. Persist the key in your job queue / outbox alongside the request payload."},{"title":"Bulk imports","route":"/developers/imports","headings":["Bulk imports","Before you start","1. Create and upload","2. Inspect, then map","3. Validate before you commit","What a committed order import writes","Runs are queued, so poll for the outcome","The error report round trip","Undo: what it removes, and what it does not","Full endpoint reference","201","that header, so a different one fails with SignatureDoesNotMatch.","PRE-run status. The run itself happens on a worker — poll for the outcome.","202 — queued. The outcome lands on the import row as undoResult."],"text":"Data migration Bulk imports Move a merchant's historical customers or orders into Throttle from a file their old platform exported. Four steps: upload, map, dry run , commit. The dry run is not optional — a commit rewrites order and customer history, so Throttle refuses to run one until you have been shown what it would do. Before you start CSV and TSV up to 500 MB and 500,000 rows. The extension on fileName selects the parser, so it has to be accurate — it is chosen before a single byte is uploaded. Excel up to 10 MB and 65,536 rows ( .xlsx , .xlsm ; .xls up to 25 MB). The lower ceiling is not arbitrary: a .xlsx is a ZIP whose directory sits at the END of the file, so a sheet cannot be decoded until the workbook, shared-strings and styles entries have all been read — it is parsed whole rather than streamed a row at a time. 65,536 is the .xls format's own ceiling, reused for both so there is one number rather than two. Past it, a sheet is refused with a message naming the cap; save it as CSV, which handles 500,000 rows. Spreadsheet dates cannot be misread. A date cell is a number plus a format, not text, so it is imported as ISO-8601 and the dateOrder choice cannot move it. That ambiguity is real only for CSV, where 03/04/2024 is two different days. A workbook with several sheets reads the first by default. POST /api/v1/imports/ id /inspect returns every sheet in sheets , and re-POSTing with sheetName : Orders reads that one instead. Changing the sheet re-saves the dialect, which retracts any dry run — the preview described a different grid. Naming a provider pre-fills the mapping. bigcommerce and shopify both ship export templates; omit it and the header-name matcher runs alone. The provider also selects the status vocabulary, which is not cosmetic: BigCommerce exports one status column that implies both position and payment, while Shopify splits it across Fulfillment Status and Financial Status and needs both read together — an order can be fulfilled but unpaid, or paid but unfulfilled. Shopify blanks its order columns on continuation rows. Where BigCommerce repeats the order-level values on every line of a multi-item order, Shopify writes them on the first line only. The Shopify template declares that shape, so the blanks are read as the format rather than as an inconsistent export. Its single Billing Name column is split into first and last on the last space. One entity per file. entityType is customer or order . Scopes. Reads need imports:read ; every verb that creates, maps, runs or undoes an import needs imports:write . Publishable ( pk_ ) keys cannot hold either, and extensions are not granted them. Imports are scoped to one workspace environment. An import belongs to the application and environment its credential resolves to, and reading one from a different environment is 403 environment_mismatch . POST /api/v1/imports hands back a presigned PUT valid for one hour and you upload straight to storage. That is why the size ceiling is checked twice: once against the fileBytes you declare (before the URL is issued) and again against the object's real size at inspect time, because a presigned PUT signs the key and the content type — never the body length. 1. Create and upload A PUT that never happened, or one that stored zero bytes, is caught at the next step as 409 file_not_uploaded rather than becoming an empty mapping screen. 2. Inspect, then map POST /api/v1/imports/ id /inspect reads at most the first 1 MB of the file however large it is, so it answers immediately. It returns the sniffed dialect, the headers, a 20-row sample, the platform template it matched, a suggested target field per header, and detectedDateOrder . Those are suggestions — you confirm them. A column you leave out of columns is ignored. That is how you skip one. Two columns mapped to one field is rejected , with both headers named. The mapper takes the first match and would discard the second silently, which is data loss, not a harmless duplicate. The one exception is customer.metadata — see below. A customer may carry up to five addresses. The first group s targets are customer.address.* ; the rest are customer.address2.* through customer.address5.* , with the same ten suffixes ( firstName , lastName , company , addressLine1 , addressLine2 , city , stateProvince , postalCode , countryCode , phone ). A group whose columns are all blank for a row produces no address for that row. The first address becomes the customer s default only if they had none; a customer who already has a default keeps it. A header s trailing group index is parsed, not matched. Address City - 2 , Address City #2 and Address City (2) all suggest customer.address2.city . An index above 5 is left unmapped rather than folded into an existing group. A digit that is part of the field s own name — Address Line 1 — is untouched. An order s products may arrive in ONE column. BigCommerce s control-panel order export gives an order a single Product Details cell holding every product, pipe-separated between products and Key: value within — so there are no per-line-item columns to map. Point that column at order.lineItems.packed and each product becomes its own line item. The keys read are Product ID , Product Qty , Product SKU , Product Name , Product Variation Details (kept as the line item s description) and Product Unit Price . Commas and colons inside a value are preserved. When this target is mapped it replaces the per-line order.lineItem.* reading for that row rather than adding to it. order.coupons.packed reads a packed coupon cell the same way, summing each Coupon Value into the order s discount. It is read only when no order.discountTotal column is mapped, so an explicit discount column still wins. Pick the tax basis deliberately when a file offers both. Exports that ship (inc tax) and (ex tax) variants of the same figure are not auto-mapped in either direction — mapping both would point two columns at one field. An order s t"},{"title":"Customer Invoices","route":"/developers/invoices","headings":["Customer Invoices","One list, three payment types","Auth and trust model","Pagination","Per-application branding","Refund behavior","Net-N invoice status","Amount due","Deposit receipts","Invoice numbers","When tax is filed on invoice terms","REST routes quick reference","Pages in this section","Merchant API: list invoices for a specific customer","Redirect directly to PDF (default)"],"text":"Finance Customer Invoices Every payment Throttle processes — a one-time card capture, a Net-N issuance, or a recurring subscription charge — lands in one unified, cursor-paginated invoice list. Your buyers see a single consistent view regardless of how they originally paid. Customers never hold an API key. Your backend mounts the @usethrottle/invoices/server proxy which resolves the authenticated buyer's identity and forwards only their own invoices. See React Package for the setup. One list, three payment types Throttle invoices are payment-source agnostic. The same GET /api/v1/invoices endpoint returns all three payment types in a single paginated response, ordered by creation time (newest first by default). Single-time card captures ( sourceType: "order" ) — generated when a buyer completes a one-time checkout session with a card. The invoice is marked paid immediately on capture. Net-N issuances ( sourceType: "net30" ) — issued at checkout time with a future dueAt timestamp (N days out). Status starts as open and transitions to paid once the merchant marks it paid or a payment is received. Subscription charges ( sourceType: "subscription" ) — generated on the initial subscribe and on every renewal. The invoice carries periodStart and periodEnd so buyers can see exactly which billing period each charge covers. A checkout that sells several subscriptions, or subscriptions and goods together, produces one order invoice ( sourceType: "order" ) listing every line, each subscription line with its period label (for example "Oct 1 – Nov 1, 2026", or "Oct 1 – Nov 1, 2026 · Payment 1 of 3" on an installment plan), plus shipping and tax; its total equals the capture. No separate first-period subscription invoice is issued for it. Renewals invoice as subscription charges. A single-plan checkout keeps its subscription invoice. Use the type query parameter to filter by source type if your UI needs to show a subset. Auth and trust model The Throttle invoice API is a merchant-facing API: every call requires an sk_* secret key. Buyers cannot authenticate directly. The recommended pattern is a thin backend proxy: Your backend authenticates the buyer (Clerk, NextAuth, session cookie, etc.) and resolves their internal user id. The proxy calls GET /api/v1/customers/by-external/:externalId with your secret key to resolve the Throttle customerId . All reads are forwarded as GET /api/v1/customers/:customerId/invoices so the buyer only ever sees their own invoices. The @usethrottle/invoices/server package ships this proxy handler ready to mount in any framework that accepts a standard Request / Response interface (Next.js App Router, Hono, Remix, etc.). Pagination Invoice list endpoints return a cursor-paginated envelope. Pass the returned meta.pagination.cursor as the cursor query parameter to fetch the next page. When meta.pagination.hasMore is false there are no further pages. Per-application branding Every invoice PDF is rendered with the application's configured logo and brand color. Set these on your application via the dashboard Settings → Branding panel or via PUT /api/v1/embed-config . The PDF renderer reads logoUrl and brandColor from application_env_settings at render time, so changes apply to invoices downloaded after the update (not retroactively). Refund behavior When a payment is refunded via the dashboard or API, the corresponding invoice status transitions automatically: Full refund — status becomes refunded ; the amountRefunded field reflects the full invoice total. Partial refund — status becomes partially_refunded ; the amountRefunded field shows the refunded portion. The re-rendered PDF shows the refunded amount and updated status. Buyers who download after a refund automatically see the current state. Net-N invoice status An invoice raised on Net-N terms ( sourceType: net30 ) starts open and follows the payment behind it: Paid — when the payment is captured (the merchant marks the invoice paid), status becomes paid . Cancelled — when the payment is voided (the invoice is cancelled, the order is cancelled or comped, or an edit leaves it owing nothing), status becomes void . The PDF re-renders on the next download, so it says Paid or Void instead of Due. Amount due A Net-N invoice's total is what the buyer owes on that invoice — the amount of the Net-N payment behind it — not the order total. They differ when part of the order was settled another way, such as a deposit taken by card at checkout with the balance on Net-N terms. The invoice still lists every line, and subtotal , taxTotal , shippingTotal and discountTotal are the order's. When the invoice bills less than the order, billingSnapshot.totals also carries orderTotal and the deductions that close the gap — amountPaid (already paid, e.g. the deposit) and compWaived (waived by a comp) — and the PDF prints them under the order total, with the band reading Amount due . Deposit receipts The card receipt for a deposit ( sourceType: order ) on a deposit + Net-N balance order has total equal to the deposit that was paid, not the order total. The lines and the order's money rows stay itemised; billingSnapshot.totals carries orderTotal and balanceDue (the balance billed on the Net-N invoice), and the PDF prints them under the order total with the band reading Amount paid . Every other receipt is unchanged. Invoice numbers A Net-N invoice has one number: the invoice's number , printed on its PDF and used as the PDF's filename. The Net-N payment carries the same value in metadata.invoiceNumber , so the invoice emails, the dashboard, receivables and the invoice.past_due webhook all show it. It is formed from your Net-N invoice prefix (default INV ), the application slug and the application's invoice sequence, e.g. INV-acme-00042 . Invoices issued before 2026-10-04 were emailed under a separate payment num"},{"title":"React Package · @usethrottle/invoices","route":"/developers/invoices/react-package","headings":["{invoice.number}","React Package · @usethrottle/invoices","Install","Provider","Hooks","useInvoices(params?, options?)","useInvoice(id, options?)","useDownloadInvoice()","Cursor pagination","Cache invalidation","REST routes","Invoice object fields","Boundaries"],"text":"Customer Invoices React Package · @usethrottle/invoices React hooks and a server proxy helper for surfacing a buyer's unified invoice list — card captures, Net-N issuances, and subscription charges — inside your storefront portal. Browser hooks never call Throttle directly. They call your backend proxy, which holds the sk_* key and resolves the authenticated buyer's identity before forwarding the read. Install Backend proxy · /server entrypoint Mount createInvoiceProxyHandler from @usethrottle/invoices/server on a catch-all route. The handler accepts any framework that exposes a standard Request object (Next.js App Router, Hono, Remix, etc.). createInvoiceProxyHandler(options) apiKey: string — your sk_* secret key. getCustomerId(request): string | null | Promise<string | null> — called on every request. Return the buyer's external id (e.g. your auth framework's user id). Returning null causes the proxy to reply 401 unauthorized . baseUrl?: string — defaults to https://api.usethrottle.dev . The proxy allows three read-only paths: /api/v1/invoices (list), /api/v1/invoices/:id (get), and /api/v1/invoices/:id/download (PDF URL). All other paths and non-GET methods return an error. List requests are transparently rewritten to /api/v1/customers/:customerId/invoices so the buyer can only read their own invoices. Provider Mount InvoiceProvider above any component tree that uses invoice hooks. The fetcher prop is the bridge to your backend proxy — every hook routes through it. Hooks useInvoices(params?, options?) Lists invoices for the authenticated buyer. Returns a QueryState<PaginatedInvoices> with data, isLoading, isFetching, error, refetch . params : status? ( paid | open | refunded | partially_refunded | void ), type? ( order | net30 | subscription ), cursor? , limit? , sort? ( asc | desc ). Note: The SDK normalizes the REST meta.pagination envelope into a top-level pagination on the hook result — so with the hooks you read result.data.pagination.cursor / .hasMore , whereas raw REST responses carry it under meta.pagination . useInvoice(id, options?) Fetches a single invoice by id. Skips the fetch when id is null or undefined . useDownloadInvoice() Returns an async function (id: string) => Promise<string> that fetches the signed PDF URL and opens it in a new tab. Returns the raw URL so your UI can also use it as an anchor href . To capture the returned URL for custom usage (e.g., render as an anchor href): Cursor pagination Responses carry a pagination object inside data.pagination . When hasMore is true, pass the opaque cursor string in the next useInvoices call. Cache invalidation All hooks share a cache version. Call useInvoiceInvalidation() to bump the version and cause every mounted hook to re-fetch. Use this after a refund or a payment action that changes invoice state. REST routes The following merchant-facing routes require an invoices:read API key scope. GET /api/v1/invoices — list invoices for the application (all customers, all source types). GET /api/v1/invoices/:id — fetch a single invoice. GET /api/v1/invoices/:id/download — 302 redirect to a 10-minute signed PDF URL; append ?format=json to get data : url : ... instead. GET /api/v1/customers/:customerId/invoices — list invoices scoped to a specific customer (used internally by the proxy). All list endpoints accept cursor , limit , sort (asc | desc), status , and type query parameters. Invoice object fields id — Throttle invoice id. number — human-readable invoice number (e.g. INV-sprinter-001 ). status — paid | open | refunded | partially_refunded | void . sourceType — order | net30 | subscription . currency — ISO-4217 currency code. subtotal, taxTotal, shippingTotal, discountTotal, total — integer minor units. amountRefunded — integer minor units refunded (0 when not refunded). issuedAt — ISO-8601 invoice date. dueAt — ISO-8601 due date for Net-N invoices; null for card captures and subscriptions. periodStart, periodEnd — billing period for subscription invoices; null otherwise. createdAt — ISO-8601 record creation timestamp. Boundaries The package is read-only — it surfaces invoices and PDFs but does not create or mutate payment records. Payment collection stays in @usethrottle/checkout-react . This package renders the resulting invoices. Merchant-wide views (all customers) are available via the REST routes with a secret key; the React hooks are scoped to the authenticated buyer's invoices through the proxy."},{"title":"MCP server","route":"/developers/mcp","headings":["MCP server","Install","Hosted / remote server","What it can do","Catching field-name mistakes before they 400","Writes","Options"],"text":"Tools MCP server @usethrottle/mcp connects Throttle to AI assistants that speak the Model Context Protocol — Claude Code, Claude Desktop, Cursor, and anything else with an MCP client. It reads your commerce data, diagnoses failing checkouts, searches these docs, and checks request bodies against the published contract before you write code against it. Install The server runs locally over stdio and authenticates with a secret sk_ API key, exactly like the CLI. For Claude Desktop or any client that reads an mcpServers config: The key pins the workspace, application and environment — there is no way to switch from inside a tool call. Create a key that holds only the scopes the assistant needs, and start with a non-production environment. Publishable ( pk_ ) keys are rejected: they carry only browser-safe compute scopes and cannot read data. Hosted / remote server For clients that speak MCP over HTTP instead of stdio — n8n's MCP Client Tool node, for example — connect directly to https://mcp.usethrottle.dev/mcp (Streamable HTTP) instead of running @usethrottle/mcp locally. Send the same secret key as an Authorization: Bearer sk_... header — no separate provisioning, no OAuth. The tool surface and scopes are the same as the local server's read-only toolset, with two differences besides transport: the hosted server is strictly read-only, so seed_test_data is not registered there even on a non-production credential (seed from the local CLI instead), and api_get is not registered there either, because a tool that can read any path cannot declare in advance which scopes it uses. A session is created on your first call and reused for the rest of the connection; it is torn down after 30 minutes of inactivity or an explicit client disconnect. Claude's custom-connector static_headers auth type (Bearer token, no OAuth) works with this endpoint but is currently rolled out selectively by Anthropic. ChatGPT's Developer Mode does not support API-key auth for custom connectors at all — only OAuth or no auth — so it cannot use this endpoint yet. As of this change, the server also supports real OAuth 2.1 ( authorization_code + PKCE + refresh) for both platforms' native connector/directory flows, once each platform's OAuth client is registered. This is in addition to, not instead of — the static_headers path above still works too. customers:read and payments:read are OAuth-grantable, so search_customers , get_customer , search_payments and get_payment register on hosted/OAuth sessions too, not only on sk_ API keys. An OAuth connection never sees a shopper's personal data, because an OAuth grant cannot hold customer_pii:read . On every response to an OAuth token, email, phone and first/last name are withheld wherever they appear (orders, subscriptions, carts, invoices, quotes, events, webhook deliveries, the audit log): null in REST responses, and absent from MCP tool results, which drop null fields. Company and the merchant's external ids ( externalId , externalCustomerId ) are withheld too, since a merchant may key customers by email or name. REST addresses keep city, state, postal code and country and lose the street lines. Free-form fields are withheld whole, because a name or email can sit anywhere inside them: metadata , notes, hold and comp reasons, quote comment bodies, acceptance records, client context (IP address, user agent), tax ids and a payment's raw processorResponse . File downloads and exports (orders CSV, invoice PDFs, exemption certificates) return 403 oauth_file_download_forbidden . Nothing can be looked up by a withheld value either: GET /customers/by-external/:externalId and the externalCustomerId filter on subscriptions return 403 oauth_external_id_forbidden , a customer text search answers with no matches ( search_customers refuses q and lists by date instead), and search_orders matches only order ids, numbers and statuses. On top of that, each tool on an OAuth session returns a fixed list of fields chosen for its purpose, so a field the API adds later does not reach the assistant until it is added to that list. A field a buyer can type is not on any list: line-item and quote-item names, SKUs and reference ids (a quote request form takes them from the buyer), company, and every address line but the country. For the same reason get_top_products , which ranks by item name or reference id, is not offered over OAuth. API keys and the dashboard are unaffected. On every credential, API key or OAuth, a tool result never includes the payment processor's raw record: a payment's or transaction's processorResponse , or a saved card's metadata.gr4vy_payment_method . This covers get_payment , search_payments , get_order and api_get . The REST API still returns processorResponse on payments and payment transactions, but the field is deprecated: read the payment's own fields ( status , amount , capturedAmount , cardBrand , cardLastFour , errorCode ) instead. Those lists only hold if the token cannot go around them. An OAuth access token is issued for the MCP server (its aud is https://mcp.usethrottle.dev/mcp ) and works only there: sent straight to the REST API, it is refused with 401 oauth_token_wrong_audience . To call REST yourself, use an API key. (Automation connectors such as Zapier are registered for the REST API instead; see scopes .) get_integration_status reports each webhook endpoint's id, events and health but not its URL, since a destination URL often carries a secret in its path. What it can do Tools are registered per session based on what your key is actually allowed to do, so a read-only key produces a read-only toolset rather than tools that fail when called. whoami — which workspace, application and environment this key is pinned to, and whether it is production. search_docs — full-text search over these docs, with canonical links. describe_endpoint and validate_payload — the request shape of any endpoint, a"},{"title":"Metadata","route":"/developers/metadata","headings":["Metadata","Resources that accept metadata","Customers","Subscriptions","Carts and line items","Fulfillment hints on line items","Tax keys","How the keys travel from a quote to an order","Shipping keys","Orders and payments","Checkout sessions","On checkout sessions","On webhooks","Conventions","Dashboard exposure","Next","Matches the line link (lineItems[].subscriptionId), the order's subscriptionId,","or the legacy metadata.subscription_id pointer. Prefer it over q=$SUB_ID, which","substring-matches the whole metadata blob and can return unrelated orders.`;"],"text":"Cross-cutting Metadata Every major resource accepts a free-form metadata field — a JSON object you attach to capture your own business state. Throttle stores it as jsonb , returns it on every read, and emits it in webhook payloads. Outside documented automation keys, Throttle treats it as opaque storage. Metadata comes back out unchanged. A small set of documented line-item keys also drives fulfillment automation, such as fulfillmentType for digital delivery or access grants. Use the rest to bridge Throttle's data model with your own — sales rep IDs, campaign codes, contract numbers, feature flags, merchant notes. Resources that accept metadata Update support is missing on payments and checkout sessions because those are short-lived single-use records — payments capture once, sessions expire in 30 minutes. If you need to mutate metadata on them, do it at create time. Customers Subscriptions Carts and line items Fulfillment hints on line items When an order is paid and in processing , Throttle reads line-item fulfillment hints. Digital and access-grant items are fulfilled automatically; shipment and service items stay manual in the dashboard. Net30 invoices wait for capture before delivery. Cart items and order line items accept an explicit deliveryMethod field: shipment , digital , access_grant , service , in_person , custom , or none . This is the preferred way to say what an item needs — set it directly and skip the metadata hints below entirely. Omit deliveryMethod and Throttle resolves it once, at insert time, from type and the metadata hints below — then freezes the result on the row. It is not re-resolved on later updates, so changing an item s metadata after creation does not change its delivery method. Every order read returns the resolved value on each line item, so you never have to re-derive it: an order whose items are all digital , access_grant or none has nothing to ship and reaches fulfilled without a fulfillment of your making. Defaults are conservative: unmarked products are shipments, subscriptions and tickets are access grants, services stay manual, and donations are not applicable. Metadata never makes a subscription line ship: only an explicit deliveryMethod: shipment does, which makes a physical subscription. For older integrations, requiresShipping: false is treated as digital unless requiresFulfillment: false is set. Tax keys A handful of metadata keys feed tax calculation and, with Avalara connected, the document Throttle files. They have no typed field of their own: the SDKs type metadata as Record<string, unknown> and the API does not validate them. A misspelled key or a value of the wrong type is silently ignored, and the line is taxed as if you had not sent it. Copy the names exactly. taxCategory means two different things. With the native engine it is matched against the categories on your tax rules: an exempt line is never taxed, and a value no rule lists — including a different case, such as Digital — is taxed only by a rule covering all categories, otherwise not at all. With Avalara it is sent as the line's AvaTax tax code. Native category names are never sent to Avalara, regardless of case: a line carrying standard or exempt falls back to the application's default item tax code. So exempt does not exempt a line under Avalara — send the AvaTax code for the treatment you mean. How codes resolve, and why an item in your AvaTax catalogue beats them, is in the Avalara AvaTax setup guide . taxIncluded must be a JSON boolean. The string "true" is ignored, and the line follows the application setting. transport is case-sensitive. A value outside the five above is never sent to AvaTax, because AvaTax rejects the whole transaction on one. Both the estimate and the filed document use the application default in its place, so a misspelling silently becomes the default. Set it on the cart or the checkout session, not only on the order: the estimate is calculated before the order exists and reads the cart with the session's metadata over it, and the order copies that same metadata for the filed document. The native engine ignores it. Use the camelCase key. Tax calculation and the filed document also accept tax_category and tax_included , but these are kept for older integrations; new code should send taxCategory and taxIncluded . The item identifier is referenceId , not sku AvaTax files each line under an item code, and Throttle sends the line's referenceId as that code. Carts and orders have no sku field: their line schemas are strict, so a top-level sku is rejected with a validation error. When a line has no referenceId , the estimate and the filed document both fall back to metadata.sku , but send referenceId : it is the field the order page shows and the one every integration reads. Quote lines are the exception: they take both sku and referenceId . How the keys travel from a quote to an order Quote → cart, on acceptance. Each line's metadata is copied whole, so taxCategory and taxIncluded come with it. The cart line's referenceId is the quote line's referenceId , or its sku when it has none. The quote's buyerReference lands on cart.metadata.buyerReference — a value given at acceptance wins. Of the quote's own metadata, only transport is carried; any other key, including purchaseOrderNo , stays on the quote. Cart → order. The order's metadata starts from the cart's (the merge order is under On checkout sessions, below), and each line keeps its metadata and referenceId . Estimate and filing. Tax is estimated from the cart and filed from the order. Because the order inherits the cart's keys, the buyer is charged on the same answers the document records. Avalara reads more per-order keys than these — shippingTaxCode and taxTrigger among them. They are covered in Overriding an application default on one order . Shipping keys Line-item metadata keys that size a shipment for a connected carr"},{"title":"Multi-currency","route":"/developers/multi-currency","headings":["Multi-currency","Enabled currencies","One rule for every create path","Amounts are per currency","Shipping","Net 30 and payment methods","Routing","Orders, payments and saved cards","Quotes","Minor units","The money package","Showing money","What stays in USD","A currency the store has not enabled.","A carrier markup with one amount per currency.","above 100.00 USD or 90.00 EUR."],"text":"Money Multi-currency A store has one main currency and may enable more. Every record is created in one currency, and every amount that depends on a currency (a discount, a shipping rate, a Net 30 cap, a routing threshold) is stored per currency. Throttle never converts an amount for you. Enabled currencies The main currency belongs to the application and is shared by its sandbox and production environments. The extra currencies are cartCurrencies , set per environment in the dashboard or with PUT /api/v1/embed-config . An application reports both as enabledCurrencies , with the main currency first. PATCH an application or store currency to change the main currency. After the store's first production sale it answers 409 currency_locked and the response shows currencyLocked: true . A code that cannot be a main currency is 422 currency_not_supported . Only currencies shown and charged with two decimal places can be enabled for now. Zero-, three- and four-decimal currencies are refused, and so are HUF, TWD, ISK, UGX, COP and IDR, which payment processors handle differently. Disabling a currency stops new carts, quotes and subscriptions in it. Existing records keep working in their own currency: sent quotes, renewals, invoices and refunds. Until multi-currency opens in production, a production environment accepts USD only. Adding a code to a production cartCurrencies , or creating a record in a non-USD currency there, answers 422 currency_not_available_in_production . An unchanged or shorter cartCurrencies list is still accepted, and sandbox is not restricted. One rule for every create path Carts, cart sessions, quotes, orders, subscriptions, the checkout embed token, the add-card embed token and imports all follow the same rule: A missing currency means the store's main currency. A given currency is matched case-insensitively and stored upper case ( eur becomes EUR ). It must be enabled, else 422 currency_not_enabled with details.enabledCurrencies . An unknown code ( XXX , ABC ) is 422 currency_not_supported . An integration that omitted currency used to get USD on orders, payments, subscriptions and quotes. It now gets the store's main currency. A USD-main store sees no difference. The old 422 currency_mismatch on an unresolved cart is replaced by the codes above. Amounts are per currency Throttle does not convert prices, so a currency with no entry is not offered . The legacy single-value field ( value , a method's rate, a flat markup's amount , maxOrderTotal ) mirrors the main-currency entry. 422 discount_currency_unsupported . See Discounts . , ], [ Shipping methods , code: currencyAmounts , html: The method is not offered. Selecting it is 422 shipping_method_unavailable . , ], [ Carrier markup , code: markup.amounts , A flat markup with no entry for the cart currency drops that carrier rate, the main currency included. , ], [ Net 30 caps , code: maxOrderTotals , Once any cap is set, a currency with no entry is not offered for Net 30. With no caps at all, Net 30 is uncapped in every currency. , ], [ Routing amount conditions , code: amounts , The rule does not match an order in that currency. , ], ] /> Shipping Each currencyAmounts entry takes baseRate (required), handlingFee (default 0), and the optional subtotalMin and subtotalMax thresholds, all in that currency's minor units. The main-currency entry is required; a bare number is refused with 400 . The shipping configuration's currency must equal the main currency; anything else is 422 currency_mismatch . A calculation with a cartId must send currency , else 400 calculation_currency_required . Selecting a shipping method, or saving an external snapshot, in a currency other than the cart's is 422 shipping_currency_unsupported on every cart. A legacy write of a single rate sets the main-currency entry and keeps the other entries; when a write sends both the single value and the map, they must agree on the main currency. Net 30 and payment methods GET /api/v1/payment-methods takes an optional currency query: Net 30 is offered or hidden using the cap for that currency, or the main currency when absent. Sending both maxOrderTotal and maxOrderTotals that disagree is 400 . Routing Routing thresholds are in major units ( 100 is 100.00), unlike every other amount in the API. op is gt (uses min ), lt (uses max ) or between (both). An amount condition needs a main-currency entry when the rule's currency condition admits the main currency ( 422 routing_amount_main_currency_required ). When amounts and the mirrored currency / min / max are both sent, amounts wins. The other 422 codes are routing_too_many_rules and routing_amount_currency_excluded . Rule messages start with "Rule N:" ("Decline rule N:" in the decline flows). Orders, payments and saved cards POST /api/v1/orders follows the create rule above. A payment ( POST /orders/{id}/payments and /payments/record ) is always in the order's currency: omit currency , or send the same one, or get 422 currency_mismatch . So is a subscription created with orderId ( POST /api/v1/subscriptions ). POST /api/v1/me/payment-methods/embed-token takes an optional currency for the verification hold. Omitted, the hold is in the main currency, or the first enabled currency a connector can take. A currency refusal comes before 402 connect_processor . POST /api/v1/checkout-sessions/embed-token no longer requires currency ; amount is still required. Quotes Changing a draft quote's currency flags every typed amount for review, and POST /api/v1/quotes/{id}/issue answers 422 quote_amounts_need_review until each is confirmed. See Quotes . Minor units Every amount in the API is an integer in the currency's minor unit. The number of decimals comes from ISO 4217, not from an assumption of 100. Routing thresholds are the one exception: their min / max are in major units (see Routing ). Only two-decimal currencies can be ena"},{"title":"Authorize and Capture","route":"/developers/orders/capture","headings":["Authorize and Capture","The routes","Partial capture","amount vs capturedAmount","Full capture","Authorizing","Closing an order","Webhooks","Error codes"],"text":"Orders Authorize and Capture Most Throttle checkouts authorize and capture in one step. Splitting them lets you hold funds at order time and take the money when you actually ship — and take less than you held if the final total came in lower. The routes The order-scoped and payment-scoped routes do the same underlying work. Use the order-scoped pair when you are holding an order id and want Throttle to keep the order status in step; use the payment-scoped pair when you are working from a payment. Partial capture Only POST /api/v1/payments/{id}/capture accepts an amount . Omit it and Throttle captures the full authorized amount; pass one and it captures exactly that, in minor units. Capturing less than you authorized finalizes the payment at the captured amount — the uncaptured remainder is released back to the buyer by the processor, and there is no second capture against that authorization. Decide the final figure before you call this. If you need to bill more later, create a new payment on the order. Over-capturing is rejected with 400 capture_amount_exceeds_authorized . Its details are the authorized amount as a money value, amount, currency, display (minor units), plus requested , the amount you asked for in the same shape. A zero or fractional amount is refused with 400 invalid_capture_amount . Capturing a payment that is already captured is 409 payment_already_captured — a second capture moves no money, so a success there would be a lie. For network retries, send the original call again with an Idempotency-Key , which replays the first response. amount vs capturedAmount After a partial capture the payment keeps two figures, and they mean different things: amount — what was authorized . It does not shrink to match the capture. capturedAmount — what was actually taken, and what the buyer is charged. Anything that sums money — payouts, revenue reports, refund ceilings — must read capturedAmount . Reading amount overstates revenue on every partially captured order. Full capture The order-scoped route captures the whole authorization and keeps the order status aligned. The order must be in pending , processing , partially_fulfilled , or completed . POST /orders/{id}/capture and /authorize check the payment first and treat an already-captured or already-authorized payment as a no-op, so a duplicated webhook or a retried job is harmless. The payment-scoped routes do not. Calling POST /payments/{id}/capture on a payment that is already captured , or /authorize on one that is no longer pending , fails. If you are driving these from a queue or a webhook handler, prefer the order-scoped pair or send an Idempotency-Key . Authorizing POST /api/v1/orders/{id}/authorize requires the order to be pending — authorization is the first money step, so anything further along is a 400 invalid_order_state . On this route, re-authorizing an already-authorized payment is a no-op; on the payment-scoped route it is not (see above). Cancelling an order voids any open authorization, and any unpaid Net-N invoice ( authorized or pending ), automatically, so you do not need to release a hold or cancel the invoice by hand. See Order states . Closing an order POST /api/v1/orders/{id}/close moves a completed order to closed , the terminal archive state. It is the only transition out of completed , and it moves no money. Webhooks A successful capture emits payment.captured carrying both figures, so a consumer can tell a partial capture from a full one without re-fetching: A processor decline emits both payment.capture_failed and payment.failed . Handle whichever suits you, but expect both to arrive for a single failure. Error codes Full list and envelope shape on Error codes , and payment status meanings on Payment states ."},{"title":"Disputes and Chargebacks","route":"/developers/orders/disputes","headings":["Disputes and Chargebacks","Two ways a payment gets flagged","Flagging a payment","Clearing a dispute","How processor events are classified","Webhooks","Error codes"],"text":"Orders Disputes and Chargebacks A disputed payment is money you booked that is now contested — a card chargeback, or a B2B buyer refusing a Net-N invoice. Throttle surfaces both through the same three fields on the payment, written either by your own call or by the processor. If you are coming from a processor with a first-class Dispute resource, calibrate expectations: Throttle stores disputed , disputeReason , and disputeOpenedAt on the payment, plus disputeOutcome and disputeClosedAt once a card chargeback is decided. There is no evidence submission, no due date, and no dispute id on the payment. The fields exist so a contested payment stops looking like clean revenue in your reporting and your AR views — contest the chargeback in your processor s dashboard. Two ways a payment gets flagged You flag it. POST /api/v1/payments/{id}/dispute — mainly for Net-N receivables, where the dispute arrives as an email from a buyer rather than a processor event. The processor flags it. Inbound dispute.* and chargeback.* webhooks are matched to the payment by processor transaction id and write the same three fields. No configuration needed. Because both paths converge on the same columns, a consumer of payment.disputed does not need to care which one fired. Flagging a payment Scope payment_disputes:write . reason is required, 1–500 characters. disputeOpenedAt is stamped server-side. Flagging an already-flagged payment is a 409 already_disputed rather than a silent overwrite, so you cannot lose the original reason by retrying. Clearing a dispute Clearing sets disputed: false but deliberately leaves disputeReason and disputeOpenedAt in place, so the history of the dispute survives on the payment. It takes no body, and clearing a payment that was never disputed succeeds without emitting an event. How processor events are classified Inbound dispute events are classified from the event suffix and the target s status: disputed: true means a dispute is open right now — build your worklist from it. Once the processor decides a card chargeback, disputed goes back to false and disputeOutcome records won or lost , with disputeClosedAt . A lost chargeback rolls the order s paymentStatus up to charged_back . Net-N invoice disputes you clear by hand don t set an outcome. A resolution event for a payment that is not currently flagged is a no-op, and redeliveries of an open event will not re-fire payment.disputed — it is emitted only on the first transition into the disputed state. Webhooks payment.dispute_cleared carries only paymentId . payment.dispute_lost carries paymentId , reason , amount (minor units), currency , and closedAt , and fires once per loss. Subscribing to any of the three requires payment_disputes:read . Both are worth routing to a human — a chargeback is one of the few events where nobody finds out until a payout is short. See Webhooks . Error codes"},{"title":"Build an order from a cart","route":"/developers/orders/from-cart","headings":["Build an order from a cart","Create the draft order","Send a payment link","Invoice on terms","Cancelling a builder order"],"text":"Orders Build an order from a cart The dashboard s order builder makes an order without a checkout: it fills a cart, turns it into a draft order, and then collects payment from the order. The same three routes are available to a secret key. Currency: an order without currency takes the store's main currency, and its payments follow the order's currency. See Multi-currency . Create the draft order POST /api/v1/orders/from-cart takes the id of an open cart in the credential s application and environment. Build the cart first with the cart API (line items, customer, addresses, shipping). Scope orders:write . The order is always created in draft and stamped metadata.createdVia: dashboard . Nothing is charged: a draft is an order waiting for a way to be paid. Its line items are locked to the cart s total, so PATCH /orders/{id}/line-items answers 409 order_edit_in_builder . Cancel the order and build a new one instead. See Order line-item edit codes . Converting a cart twice does not create a second order. The 409 names the first one: Send a payment link POST /api/v1/orders/{id}/payment-link opens a hosted checkout session on the order s cart that stays payable for seven days, and emails it to the order s customer. Send send : false to create the link without emailing it; emailedTo is then null , as it is when the order has no customer email. The body is optional. The route both changes the order and creates a checkout session, so an API key needs orders:write and checkout_sessions:write . Calling it again replaces the link: the previous session is cancelled first, so only the newest link can be paid. When the customer pays, the order leaves draft as any checkout does. The link pays the order exactly as built. Its page re-quotes tax only, on the cart s lines and shipping, and the buyer cannot change the email, addresses or VAT number: a /calculate that sends values other than the cart s answers 409 payment_link_details_locked . To change them, cancel the draft and build a new order. DELETE /api/v1/orders/{id}/payment-link cancels the link and answers 204 . It answers 204 when the order has no link, too. DELETE returns the same 403, 404, payment_in_progress , payment_link_conflict and payment_link_cancel_failed codes. Invoice on terms POST /api/v1/orders/{id}/invoice-on-terms issues a Net-N invoice for the draft and moves the order to processing , the way a buyer choosing Invoice Terms at checkout does. The merchant accepts the terms on the buyer s behalf: companyName and billingEmail are recorded on the acceptance, and the payment s metadata records termsAcceptedBy: merchant . It requires an active Invoice Terms connector. An API key needs payments:write and orders:write . netDays (0 to 365, 0 is due on issue) sets the terms for this invoice. Omit it and the usual order applies: the customer s terms, then the application default. The 201 response is the net30 payment, at authorized , carrying its netN , netNSource and invoice number. A payment link on the order is cancelled first, so the buyer cannot also pay by card. Two requests for the same order do not issue two invoices: the second answers 409 invoice_in_progress . Cancelling a builder order Cancelling a dashboard-built order with POST /orders/{id}/cancel , or by setting its status to cancelled , cancels its payment link first. If the link cannot be cancelled the order is not cancelled either, and the request answers 409 payment_in_progress (the customer is paying it right now), 409 payment_link_conflict , or 502 payment_link_cancel_failed . Orders without a payment link cancel as before. See Order states ."},{"title":"Returns and Exchanges","route":"/developers/orders/returns","headings":["Returns and Exchanges","Lifecycle","Open a return","How refundAmount is computed","Transitions","The refund on completion","Exchanges","Webhooks","Error codes"],"text":"Orders Returns and Exchanges A return is an RMA against a subset of an order s line items. It moves through an explicit state machine, and the money only moves at the end — either as a refund on complete , or as the price difference on an exchange. Lifecycle Only requested , approved , received , and completed hold a returned quantity against the order. Rejecting or cancelling releases it, so the buyer can open a fresh return for the same line later. Open a return POST /api/v1/orders/{id}/returns — scope order_returns:write . Pass the line items and quantities coming back. reason (max 1000 chars) and restock are optional. Throttle computes refundAmount at creation time and returns it on the object, so you can show the buyer the exact figure before anyone approves anything. List an order s returns with GET /api/v1/orders/{id}/returns and fetch one with GET /api/v1/returns/{id} — both scope order_returns:read . How refundAmount is computed Throttle refunds what the buyer actually paid for those units, not the list price. Per line that is: the line subtotal, plus the tax charged on it, minus any discount applied directly to that line, minus that line s proportional share of any order-level discount. The result is prorated across the returned quantity and telescopes exactly across repeated partial returns — returning 1 unit three times refunds the same total as returning 3 units at once, with no rounding drift. Transitions POST /api/v1/returns/{id}/transition with an action . Anything not listed here is a 409 invalid_return_state . The refund on completion Completing a return resolves the order s payment in captured or partially_refunded status and refunds refundAmount against it, stamping refundPaymentId on the return. The refund carries the reason return:{returnId} . If the order has no captured payment — an unpaid Net-N invoice, say, or an order captured outside Throttle — the return still moves to completed and no refund is issued. The server logs a warning, but the API returns 200. If you rely on a return implying money moved, assert on refundPaymentId being non-null rather than on the status. If the processor declines the refund, the transition fails with 402 refund_failed and the return stays in received , so you can retry. Exchanges POST /api/v1/returns/{id}/exchange swaps the returned goods for replacements instead of refunding. The return must be in approved or received ; it is an alternative route to completed that settles a difference rather than issuing the standard refund. Throttle then: creates a replacement order carrying metadata.exchange_for_return pointing back at the return, computes delta = replacementTotal − refundAmount , charges the customer s stored card when delta > 0 , or refunds the difference when it is negative, marks the return completed and stamps the replacement order id. Replacements are charged at list price — this path has no address-aware tax quote yet. Because delta compares against the return s paid value (which does include tax), an even-swap exchange produces a small refund roughly equal to the returned line s tax. The difference favours the buyer, never the merchant, but budget for it if you run high exchange volume. A delta > 0 needs someone to bill: if the original order has no customer attached you get 409 no_customer , and a declined card is 402 payment_failed . In both cases no replacement order is left half-settled. Webhooks Opening a return emits return.created . Every transition and every exchange emits return.updated , carrying status and previousStatus so you can react to a specific edge rather than polling. Subscribing requires order_returns:read . See Webhooks . Error codes Full list and envelope shape on Error codes ."},{"title":"Pick the Smallest Package That Does the Job","route":"/developers/packages","headings":["Pick the Smallest Package That Does the Job","Package Boundary"],"text":"SDKs and CLI Pick the Smallest Package That Does the Job Throttle publishes focused packages for checkout embeds, native cart orchestration, discounts, generated REST calls, and command-line setup. Each package card calls out where it is safe to run and whether it is production-ready today. Server SDKs do not take a separate environment option for normal API calls. Generate separate keys for Production, Test, UAT, or any custom non-production environment; the raw key is pinned to exactly one workspace environment. Every non-production environment routes to sandbox provider credentials. Clerk-authenticated dashboard calls that are not using an API key send X-Throttle-Environment-Id instead. packages.map((item) => ( )) Package Boundary For public integrations, use the @usethrottle/* packages above. If a package is not listed here, it is not public API."},{"title":"Upgrading api-client from 2.x to 4.x","route":"/developers/packages/api-client-2-to-4","headings":["Upgrading api-client from 2.x to 4.x","Breaking changes that stop compiling","Breaking changes that still compile","order.completed became order.fulfilled","What you gain","Upgrade checklist"],"text":"Packages Upgrading api-client from 2.x to 4.x Two major versions shipped a day apart in August 2026: 3.0.0 (one order status instead of two) and 4.0.0 (payments that are still in flight). This page lists every breaking change between 2.18.0 and 4.3.0 , taken from a diff of the two published packages. Throttle serves one API version. Staying on 2.x does not keep the old behaviour — it keeps types that describe it. Three things are already true for a 2.x client today: postApiV1OrdersComplete returns 404, an order is never completed (it is fulfilled ), and order responses carry no fulfillmentStatus . Everything else in 2.x keeps working. Breaking changes that stop compiling OrdersService.postApiV1OrdersComplete is gone. The endpoint ( POST /orders/ id /complete ) was removed with the status it set. Use postApiV1OrdersStatus , which moves an order to any lifecycle status. Order status : completed became fulfilled . The full set is now draft , pending , processing , partially_fulfilled , fulfilled , cancelled , closed . Stored orders were migrated; you will not receive completed from any client version. fulfillmentStatus was removed from order responses and filters. Delivery progress is in status itself, with per-item counts in fulfillmentSummary . See Order states . Status unions grew. Order paymentStatus gained processing , disputed and expired ; payment status gained processing and expired . An exhaustive switch over either stops type-checking until it handles them. The delivery-log eventType filter no longer accepts order.completed . See the event rename below. Before 4.0.0 a payment the processor had not yet decided — a 3DS challenge in progress, a redirect the buyer had not returned from, a bank debit still settling — was reported as failed . If your code retries or re-charges on failed , make sure it does nothing on processing . Breaking changes that still compile The generated client takes positional arguments. Two list methods gained filters in the middle of their argument lists, so an existing positional call shifts by one or two places. TypeScript catches the shift where the types differ and misses it where both sides are string . OrdersService.getApiV1Orders — delivery was inserted before source , and subscriptionId before createdAfter . A call that filtered by customerId now passes that id as the free-text search q , and its createdAfter as customerId . Neither is a type error. WebhooksOutboundService.getApiV1WebhookDeliveries — status and eventType were inserted before limit and offset . TypeScript rejects a number where status is expected; plain JavaScript does not. Every other method kept its argument order. Re-check these two by hand; a green build does not prove them. order.completed became order.fulfilled order.completed was renamed to order.fulfilled on 2026-08-22 and is no longer delivered. Stored webhook and extension subscriptions were rewritten to the new name. order.completed is still accepted in enabledEvents when you create or update an endpoint, in 4.x as in 2.x, and is dropped from the saved list. That is deliberate: it keeps a read-modify-write of an old subscription list from failing. It is not a sign the event still fires. order.fulfilled is not in the 2.x enabledEvents type, but the API accepts it from any client. On 2.x, cast the value; on 4.x it is in the union. order.fulfilled and fulfillment.completed are different events — the first is about the order, the second about one fulfillment. Subscribe to whichever your handler is really about. What you gain Orders: postApiV1OrdersStatus , postApiV1OrdersHold / postApiV1OrdersHoldRelease , postApiV1OrdersComp / postApiV1OrdersCompReverse , getApiV1OrdersTimeline , getApiV1OrdersStatusHistory ; a delivery filter ( in_transit , delivered , returned ) and a subscriptionId filter; the same filters on the CSV export. Payments: postApiV1OrdersPaymentsRecord (record a payment taken outside Throttle, including cheque ) and postApiV1PaymentsMarkFailed ; payment responses declare errorCode and errorMessage . Fulfillments: postApiV1FulfillmentsDelivered . Subscriptions: postApiV1SubscriptionsRetryCharge and postApiV1SubscriptionsWaivePeriod . Webhooks: order.fulfilled , order.held , order.hold_released , order.comped , order.comp_reversed , payment.processing , payment.expired , payment.recorded ; the delivery log filters by status and eventType . Customers carry company ; embed config carries checkoutLinkBaseUrl . Upgrade checklist Search for postApiV1OrdersComplete , 'completed' and fulfillmentStatus . These are already wrong on 2.x, so fix them first — before or without the upgrade. Install 4.x and fix what the compiler reports: the removed method, the removed field, and any exhaustive switch over a payment status. Find every call to getApiV1Orders and getApiV1WebhookDeliveries and re-count the arguments against the lists above. The compiler will not find all of these for you. Replace order.completed with order.fulfilled in the event list you send, and in the handler that switches on the delivered type . Make sure nothing treats a processing payment as a failure."},{"title":"Payment Methods","route":"/developers/payment-methods","headings":["Payment Methods","Two-step auth flow","Endpoint reference","Subscription self-service","The SavedCard shape","Error reference","Security notes","Cards must be attached through the embed","React package","Step 1 — backend: mint a client token","Step 2 — frontend: render the component"],"text":"Buyer wallet Payment Methods Let buyers view, manage, and add payment methods in your storefront without exposing your secret key to the browser. A short-lived customer-scoped token gates all buyer-facing card operations; your backend mints it, the browser uses it. The add-card token takes an optional currency for the verification hold; see Multi-currency . @usethrottle/payment-methods wraps the flow below as a headless core, React hooks, and a pre-built component. See the React package section at the bottom of this page, or use the HTTP routes directly if you prefer. Only cards vaulted through the add-card embed — the flow on this page, which finishes at POST /api/v1/me/payment-methods/confirm — can be charged later for subscription renewals, plan changes, or any other merchant-initiated payment. That step is what records the processor buyer reference those charges require. Attaching a card directly with POST /api/v1/customers/ id /payment-methods stores it and lists it correctly, but it cannot establish that reference, so every later charge fails with 402 payment_failed: payment_method_not_chargeable . Use that endpoint only for record-keeping, never as a card wallet. Two-step auth flow The payment-methods API uses a client token pattern that mirrors how Throttle's embedded checkout protects your secret key. Your backend mints a short-lived token scoped to one customer; the browser uses that token for all card management calls. The secret key never leaves your server. Backend mints a client token. Call POST /api/v1/payment-methods/client-token with your sk_* secret key and the customer's Throttle customerId . Throttle returns a short-lived JWT. Browser calls the /me routes. All /api/v1/me/payment-methods* routes accept the token in the X-Throttle-PM-Token header. The token authorizes ONLY that one customer's payment methods — no cross-customer reads are possible. Endpoint reference POST /api/v1/payment-methods/client-token Mints a customer-scoped payment-method client token. Call this from your backend; never expose your secret key to the browser. Field Type Required Description customerId string Yes The Throttle customer ID ( cus_* ) for the authenticated buyer. subscriptionId string No Mint an update-card link for this subscription instead of a portal token. The token carries the subscription, lives 14 days instead of 30 minutes, and the response adds updateCardUrl — the hosted /billing/{token} page. Send it to the buyer yourself, or use it when the renewal-failed email is not enough. The subscription must belong to customerId ; otherwise 404 . Auth: X-API-Key (secret key, sk_* ). The key must have the customer_payment_methods:write scope. The returned token is a signed JWT. Pass it to the browser as a session value or a short-lived cookie; do not persist it long-term. The expiresAt timestamp (ISO 8601) tells the browser when to request a fresh token. GET /api/v1/me/payment-methods Returns the list of saved cards for the customer identified by the PM token. Auth: X-Throttle-PM-Token header (browser-safe) PATCH /api/v1/me/payment-methods/:id Sets a saved card as the default. Currently the only writable field is isDefault: true — you cannot unset a default directly; set another card as default instead. Auth: X-Throttle-PM-Token header Field Type Required Description isDefault boolean Yes Must be true . Promotes this card to the default; the previously-default card is demoted automatically. The customer's subscriptions that renewed on the previous default move to the new default, and each one emits subscription.updated . Subscriptions deliberately on another card stay where they are. The merchant set-default route behaves the same way. To move a single subscription, use PATCH /api/v1/me/subscriptions/:id/payment-method instead. DELETE /api/v1/me/payment-methods/:id Removes a saved card. Returns 200 with data: id , meta on success. Auth: X-Throttle-PM-Token header If the card being deleted is the default payment method and the customer has at least one active subscription, Throttle returns 409 cannot_remove_default . Prompt the buyer to set a different card as the default first, then retry the delete. Deleting a card that a subscription renews on, but that is not the default, is allowed: that subscription falls back to the customer's default card at its next charge. No subscription.updated is emitted for the change. POST /api/v1/me/payment-methods/embed-token Returns an add-only vault embed payload so the buyer can add a new card without going through a full checkout flow. The embed is configured to vault-only (zero-amount); it will not capture a charge. Auth: X-Throttle-PM-Token header The React component (see React package below) calls this endpoint and mounts the embed for you — you never handle the raw payload. Reach for the endpoint below only if you are building a fully custom embed; the provider-specific fields it returns are consumed by the widget, not by your code. Subscription self-service The same X-Throttle-PM-Token also reaches the customer's own subscriptions, so a buyer who has just saved a card can settle a past-due renewal without leaving the page. This is what Throttle's hosted “update your card” link uses; a portal you build can call it the same way. A token minted for a dunning link carries a subscriptionId claim naming the subscription it is about; a token minted through the client-token route does not, and can read any subscription the customer owns. Throttle's own hosted page for this flow is https://checkout.usethrottle.dev/billing/{token} : it shows the plan and the amount due, saves the card through the add-only embed, makes it the card for that subscription only (the customer's default card and their other subscriptions are left alone), and charges a past-due subscription while the buyer is still on the page. GET /api/v1/me/subscriptions/:id One of the authenticated customer&apos"},{"title":"Permissions Reference","route":"/developers/permissions","headings":["Permissions Reference","How permission checks work","Permission catalog","Permission scope notes","Workspace-only permissions","Read vs write split","Check permissions at runtime"],"text":"Workspace Permissions Reference Every dashboard action maps to a named permission. A permission is granted when the caller's workspace role or their application role on the target application satisfies the rule — whichever axis applies first wins. See Team management for how roles are assigned. This page describes role-based permissions (RBAC) — the rules that gate human users authenticating with a Clerk session in the dashboard. They are a separate system from API key scopes , which gate machine-to-machine calls made with an sk_ / pk_ key. A Clerk caller carries the wildcard scope and is gated purely by these permissions; an API-key caller is gated purely by its scopes and never by RBAC. The two systems compose; neither replaces the other. How permission checks work For each request Throttle resolves two values from the auth context: the caller's workspace-level role ( owner | workspace_admin | none) and their application-level role on the specific application being acted on ( admin | developer | finance | viewer | none). A permission is granted if either role set contains the caller. owner and workspace_admin satisfy most application-level permissions without needing an explicit per-application role. They act as supersets of any application role on any application in the workspace. Roles answer “can this person perform the action?” Environment grants answer “can this person use the selected workspace environment?” A caller must pass both checks. Requests outside the member's environment grant return 403 member_env_forbidden . The permission check is exposed at GET /api/v1/auth/permissions . The response lists every permission key and whether it resolves to true for the authenticated caller, optionally scoped to a specific application via the x-application-id header. Permission catalog The table below lists every permission string, which workspace roles satisfy it, and which application roles satisfy it. A dash (—) means that role axis is not checked for that permission. Permission Workspace roles Application roles workspace:delete owner — workspace:transfer owner — workspace:billing owner — workspace:invite-admin owner — workspace:settings owner, workspace_admin — workspace:invite owner, workspace_admin admin workspace:edit-member owner, workspace_admin — workspace:remove-member owner, workspace_admin — workspace:read-team owner, workspace_admin, member — workspace:audit-log owner, workspace_admin — application:settings owner, workspace_admin admin application:api-keys owner, workspace_admin admin, developer application:webhooks owner, workspace_admin admin, developer application:customers:write owner, workspace_admin admin, developer application:customers:read owner, workspace_admin admin, developer, finance, viewer application:orders:write owner, workspace_admin admin, developer application:orders:read owner, workspace_admin admin, developer, finance, viewer application:imports:write owner, workspace_admin admin, developer application:imports:read owner, workspace_admin admin, developer, finance, viewer application:quotes:write owner, workspace_admin admin, developer application:quotes:read owner, workspace_admin admin, developer, finance, viewer application:refunds:issue owner, workspace_admin admin, developer, finance application:payments:read owner, workspace_admin admin, developer, finance, viewer application:edit-app-member owner, workspace_admin admin application:extensions:read owner, workspace_admin admin, developer, finance, viewer application:extensions:write owner, workspace_admin admin, developer application:extensions:install owner, workspace_admin admin, developer application:extensions:admin owner, workspace_admin admin Permission scope notes Workspace-only permissions workspace:delete , workspace:transfer , workspace:billing , and workspace:invite-admin are exclusively for the owner. No application role satisfies these. workspace:settings covers workspace branding, sender domains, and workspace-scoped email settings. workspace:edit-member and workspace:remove-member require workspace_admin or above; no application role grants them. Read vs write split The finance role sees customers, orders, and payments but cannot write to customers or orders. It can issue refunds. The viewer role is fully read-only. It cannot issue refunds or perform any write action. The developer role is the same as finance plus write access to customers and orders, and access to API keys and webhooks. Check permissions at runtime Call GET /api/v1/auth/permissions with a Clerk JWT. Pass x-application-id to resolve application-level role flags for a specific application. The response shape is: workspaceRole : workspace_admin , appRole : developer , permissions : workspace:delete : false, workspace:invite : true, application:api-keys : true, application:customers:write : true, ... The dashboard uses this response to show or hide actions in the UI. Call it once on page load and cache client-side for the session duration."},{"title":"Accept Your First Payment","route":"/developers/quickstart","headings":["Accept Your First Payment","1. Configure your application","2. Create an API key","3. Create a checkout session","4. Render the React embed","5. Subscribe to webhooks"],"text":"Quickstart Accept Your First Payment This flow uses a server-created checkout session and the React payment embed. It is the fastest native Throttle integration path. 1. Configure your application Before any SDK call works, you need two values from the dashboard: the Application ID and at least one allowed origin . Both ship empty on a fresh signup, which is why following the snippets below verbatim returns applicationId is required or renders Embed not authorized . Sign in to the dashboard . A default application is created for your workspace on first login. If you have more than one, switch between them with the app switcher in the top bar. Open App Settings → General and copy the Application ID (the field has a Copy button; it also appears on the API keys page). Stash it as THROTTLE_APPLICATION_ID . Still in App Settings , add your storefront origin to Allowed origins (e.g. http://localhost:3000 in dev, https://shop.example.com in prod). Origins are scheme + host + optional port — no paths, no trailing slash. http://localhost and http://127.0.0.1 are accepted in every environment for local development. In a non-production environment you can also list one wildcard per entry for preview deployments, such as https://*.preview.example.com — see preview origins . Production takes exact origins only. Install the CLI with npm install -g @usethrottle/cli and set your allowlist without opening the dashboard: throttle embed-config set --origins http://localhost:3000 . It reads your secret key from $THROTTLE_API_KEY (or prompts for it); throttle embed-config show prints the current config. See the packages & SDKs page for everything the CLI and libraries expose. The embed enforces an origin allowlist server-side, and the cart/checkout endpoints reject requests without a valid applicationId . Both checks happen before any SDK error path — you'll see the failure as a 400 or an unauthorized embed instead of a hint that you need to set these. Every new application is automatically wired to a dedicated sandbox sub-account during signup, so PaymentEmbed renders (with the Card Simulator method) the first time you mint a checkout session in a non-production environment — no provider setup required. To accept real cards in production you still have to subscribe and add Throttle's production payment connection from Connectors ; details on Workspace environments . 2. Create an API key In the dashboard, open API keys (under Developers in the left nav), click Create key , and choose a secret ( sk_ ) key with the scopes you need. The raw key is shown once — copy it immediately; only the prefix is stored afterward. Keys are minted to one workspace environment, so the prefix carries that environment s segment — sk_live_* for production, or the environment s own slug otherwise, e.g. sk_test_* or sk_uat_* . Switch environments with the selector in the top bar before creating a key. Use a pk_ publishable key for browser-side shipping/tax quotes only; everything else uses the secret key from your backend. Never put an sk_ key in browser code, mobile apps, or public repositories. The browser should only receive a checkout session id or hosted URL from your backend. For everything beyond checkout — orders, customers, subscriptions, discounts, and webhooks — use @usethrottle/api-client instead of raw fetch . It's generated from the OpenAPI spec, so endpoints, event names, and request/response shapes are all typed (no guessing /webhooks vs /webhook-endpoints , or checkout.* vs cart.* event names). 3. Create a checkout session From your backend, create a native Throttle cart, add your line items, then mint a session for that cartId . This two-step native flow is the default — reach for externalCartId only when you’ve connected an external cart provider. The checkout SDK keeps your secret key server-side and returns sessionId , hostedUrl , and embedUrl . 4. Render the React embed Install the checkout SDK for your backend route and the React embed for your checkout page, then mount PaymentEmbed . 5. Subscribe to webhooks Browser callbacks are immediate UI signals. Webhooks are your durable source of truth for fulfillment, receipts, and order state. Read the webhooks guide to verify signatures and handle retries idempotently."},{"title":"Quotes","route":"/developers/quotes","headings":["Quotes","Status","Core concepts","Create and issue a quote","Request a quote from a cart","Add to Quote on your storefront","Hosted request forms","The buyer link","Subscription lines","Catalog lines","SKU-only lines","Generated plan IDs","Missing prices","Changing the currency","Shipping address","Buyer view","Catalog line errors","Quote catalog routes","Accept a quote","Shipping at checkout","Which addresses the checkout asks for","Payment terms are enforced, not advisory","Errors","Emails, expiry, and PDF","Webhooks","The response includes quote.shareToken — the buyer link is","https://checkout.usethrottle.dev/q/{shareToken} (hosted page ships with the","public-link release).`;","The cart itself is unchanged.","No API key: the qrf_ form token is the authority, so this is safe from a browser.","Every line lands on the quote's working revision UNPRICED. targetUnitPrice is","recorded as the buyer's ask and shown to your rep; it never sets what you quote.`;","items[0] to items[2] have no name, so the Product Catalog fills them, priced","in USD for this customer (here a customer in no price group). The unitPrice","on items[1] overrides the catalog price. items[3] is an ordinary line.`;","Without an installed, active Product Catalog: { \"data\": { \"rows\": [], \"available\": false } }`;","Response: { \"data\": { \"quote\": {...}, \"checkoutUrl\": \"https://checkout.usethrottle.dev/s/...\" } }","Redirect the buyer to checkoutUrl to complete payment (card or Net-N,","depending on the quote's payment terms).`;","Route quote email to a shared alias instead of the whole team"],"text":"Guides Quotes B2B sales quotes: a buyer requests pricing, a rep builds and issues a revisioned proposal, and acceptance converts directly into a Throttle order with card, Net-N, or deposit + balance payment terms. Status Build and issue quotes from the dashboard (Quotes in the app sidebar) or the API, and buyers view their quote at https://checkout.usethrottle.dev/q/{shareToken} — toggle optional lines, comment, request changes, decline, or accept. Accepting materializes a checkout session and converting it into a Throttle order happens automatically once payment succeeds. Lifecycle emails, expiry reminders + auto-expiration, a downloadable PDF, deposit-and-balance terms, and hosted RFQ intake forms are all live. Sales quotes are unrelated to shipping/tax quote tokens. Core concepts Quote — the negotiation record. Carries a Q-{seq} number, customer linkage, an optional buyer-supplied reference number ( buyerReference ), and one share token that will be the buyer's permanent link. Revision — an immutable commercial snapshot. Exactly one working revision is editable at a time; issuing freezes it ( issued ) and supersedes the previous offer. Line items support optional upsells and per-line quantity locks ( qtyEditable ). Payment terms — pay_in_full , net_terms (Net-1..365), or deposit_balance (deposit due at acceptance, the balance becomes a receivable). Deposit amounts are computed and frozen at issue time. Lifecycle — draft → requested → under_review → proposed → accepted → converted , with revision_requested , declined , expired (re-issuable), and archived branches. Transitions are service-enforced. A buyer declines an issued quote from the link; a merchant declines a request it will not price with PATCH /api/v1/quotes/ id status: declined , declinedReason , which emails the buyer the reason. Create and issue a quote Requires an API key with the quotes:write scope (dashboard roles map to application:quotes:* permissions). Prices are integers in minor units. Editing after issue is explicit: POST /api/v1/quotes/{id}/revisions clones the issued revision into a new working revision; PATCH /api/v1/quotes/{id}/revisions/{revId} edits it; issuing again supersedes the prior offer at the same buyer link. Request a quote from a cart Add to Quote on your storefront For buyers who want to browse and collect several products before asking, @usethrottle/quotes ships a quote cart: a persisted list the buyer builds while moving around your site, submitted as one request. It needs no API key — the qrf_ form token is the authority, so it is safe in the browser. Throttle stores no product catalog, so lines come from your own product data, the same way the shopping cart works. Quantities merge when the same SKU is added twice, and the cart survives page navigation. A buyer who already filled a normal cart can ask for a quote on it instead: pass cartId to POST /api/v1/quote-requests and its lines join the request. The cart is resolved inside the form's own application and environment, so an id from anywhere else is simply not found. Cart prices are recorded as the buyer's target, not as the quoted price — a cart is assembled in the browser, and this endpoint authenticates no one but the form. Hosted request forms For inbound RFQs without any integration, create a hosted intake form from the dashboard (Quotes → Request forms) or via POST /api/v1/quote-request-forms . The response reveals a qrf_ form token exactly once (hash-only stored — rotate to mint a new one); the buyer-facing form lives at https://checkout.usethrottle.dev/q/request/{formToken} . Submissions create-or-find the customer by email, open a requested quote (source request_form ) with the buyer's free-text need as the first shared comment, and fire the same quote.requested webhook + rep notifications as every other intake path. A request can carry line items, a written need, or both — at least one of the two. Items land on the quote's working revision unpriced , carrying the name, SKU, quantity, and per-line note the buyer supplied, so your rep prices an itemised sheet instead of transcribing prose. The hosted page renders item rows by default; set fields.lineItems: false for a prose-only form, or fields.targetPrice: true to let buyers name the price they are after. The public form read ( GET /api/v1/quote-request-forms/{token}/public ) carries currency : the currency the quote will be created in, from the form or request, else the store's main currency. The hosted page shows the target-price input with that currency's symbol and decimals, and hides it when the response has no currency . targetUnitPrice is recorded against the line and shown to your rep while they price it, but it never becomes the quoted price. Everything on this endpoint arrives from a browser holding only a public form token, so treating any of it as pricing would let a buyer set what you charge. Shipping address (optional). Set fields.shippingAddress: true to show an optional address section on the hosted form. The submission takes shippingAddress with addressLine1 , addressLine2 , city , stateProvince , postalCode and countryCode (ISO 3166-1 alpha-2). An all-blank address is ignored; a partly filled one without addressLine1 , city and countryCode is 400 invalid_request , and retired names such as line1 are rejected with the canonical name. The address is stored on the quote as shippingAddress (your team can correct it, see “Shipping address” below) and prefilled into the checkout shipping step when the buyer accepts (they can still change it). It is added to the customer's address book only when the request created that customer — an existing customer's addresses are never changed by a public form. Bot protection is always on : a honeypot field (trips return a success-shaped response without creating anything), a minimum-fill-time check, and "},{"title":"React Package · @usethrottle/quotes","route":"/developers/quotes/react-package","headings":["React Package · @usethrottle/quotes","Install","The buyer quote page","Errors","RFQ request form","Payment terms"],"text":"Quotes React Package · @usethrottle/quotes Buyer-facing components, hooks, and a same-origin proxy for the hosted quote link and the RFQ request form — so you can serve the buyer experience from your own storefront instead of sending people to Throttle's hosted page. Everything here is authorized by the qtl_ capability token in the buyer's URL — there is no API key, because this runs in the browser. Merchant operations (create, price, issue, revise, record acceptance) need an sk_* key and live in @usethrottle/api-client 's QuotesService , which must stay server-side. Install React 18+ is a peer dependency. The package ships ESM and CJS builds with type declarations, and exposes two entrypoints: the root (browser) and /server (the optional proxy). The buyer quote page Mount QuoteView on a route that receives the token from the buyer's emailed link. It renders line items with optional toggles and editable quantities, live totals, revision history, the comment thread, a PDF link, and the accept / request-changes / decline actions. On acceptance it sends the buyer to Throttle hosted checkout. QuoteItem.recurring carries a line's subscription terms. lineDueAtAcceptance(item, quantity) and computeSelectionTotal count a free-trial line as 0, the rule the server charges by, and QuoteView prints each subscription line's terms and the split totals (Due at acceptance, Recurring after). The default markup is deliberately plain — it is meant to be restyled or replaced. For your own presentation, use the hook directly. useQuote(token, options?) The hook owns the behaviour that is easy to get wrong: Fires the view beacon once per mount, so the merchant sees first viewed telemetry. The API throttles the resulting quote.viewed webhook to one per revision per 6 hours. Seeds selections from the revision, so a re-issue that adds or locks lines resets the buyer to the new offer. Refetches automatically on revision_superseded — the rep re-issued while the buyer was reading. Sends only the selections the rep actually allows: locked lines and non-optional lines are never included in the acceptance payload. total is computed with the same rule the server applies at acceptance , including scaling a line's tax and discount pro-rata when the buyer changes its quantity. Rolling your own sum risks showing the buyer one number and charging another. Errors Everything throws ThrottleQuotesError , which extends the shared ThrottleError and carries the API's own code . code meaning revision_superseded A newer revision was issued. currentRevisionId names it; useQuote auto-refreshes. quote_expired The offer lapsed (410). Prompt the buyer to request updated pricing. invalid_item_selection A locked line's quantity was changed, a required line deselected, an unknown item sent, or (for deposit terms) the selection fell to or below the deposit. accept_in_progress A concurrent acceptance won the race. Retry shortly. not_found Unknown or rotated token, or a draft/archived quote. RFQ request form Bot protection is wired in: the honeypot field and the mount-time anchor that feeds the API's minimum-fill-time check. A honeypot hit returns a success-shaped response and creates nothing — by design, so a bot cannot learn it was caught. Treat submission success as accepted , not created . If the form requires Cloudflare Turnstile, render the widget yourself and pass its token as turnstileToken — this package pulls in no third-party script. A form that requires Turnstile on a store where it is not configured is refused with turnstile_not_configured rather than silently accepted. Same-origin proxy · /server entrypoint Quote links work fine calling Throttle directly — the token is the credential. Proxy through your own origin if you would rather not have a third-party host in the address bar or CSP. The proxy forwards no API key . It allow-lists the public quote paths, so it cannot be pointed at a merchant endpoint; rejects traversal and any method other than GET/POST; passes the PDF 302 straight through instead of buffering the file; and forwards x-forwarded-for so acceptance evidence and per-IP limits attribute to the real buyer. Payment terms A quote carries one of three modes on currentRevision.paymentTerms : pay_in_full — card at checkout. net_terms — invoice due in netN days. deposit_balance — depositAmount charged today by card, the remainder invoiced balanceNetN days out. depositAmount is frozen when the rep issues the quote, so it does not move when the buyer edits quantities. Selections that would drop the total to or below the deposit are rejected with invalid_item_selection ."},{"title":"Cart states","route":"/developers/reference/cart-states","headings":["Cart states","State machine","States","Mutation rules","Abandoned-cart recovery","Where the recovery link points"],"text":"State reference Cart states A cart s status field is the single source of truth for whether it can still accept mutations. Only open carts are writable. State machine States open — initial state. Accepts items, discount apply/remove, shipping select, tax recompute, and checkout conversion. checkout — cart has been converted to a draft order (POST /carts/ id /checkout ). The cart is frozen; further mutations return 409 cart_already_checked_out . converted — the linked draft order was paid and an order row was finalised ( payment.captured fired). Read- only; archived. abandoned — no activity for 24 hours (or past the cart s expiresAt ), OR the linked draft order was cancelled before payment. An hourly background sweep performs the idle transition and emits cart.abandoned . Read-only. See Abandoned-cart recovery below. Mutation rules Every mutation increments sequence on the cart. Webhook payloads include sequence so subscribers can detect out-of-order delivery. POST /carts/ id /checkout transitions open → checkout . Idempotent within the 24h Idempotency-Key window — same key replays the same draft order id. Direct transitions out of checkout / converted / abandoned via the cart API are not allowed. Cancel the linked order to roll the cart back to abandoned . Mutating a non-open cart returns one of: cart_already_checked_out (409), cart_already_converted (409), or cart_abandoned (410). Abandoned-cart recovery An hourly background sweep marks idle carts as abandoned and emits cart.abandoned . A cart is idle when it has had no mutation for 24 hours — every item, shipping, discount, or tax change resets the clock — or when its expiresAt has passed. Empty carts and externally-managed carts are never swept. Webhook (always): every swept cart emits cart.abandoned to your subscribed webhook endpoints, so you can drive recovery from your own systems — including for anonymous carts you track by your own session. The payload carries the line items, totals, customer (a linked customer, or a guest with id: null when only the cart’s customerEmail was captured; null if neither), shippingAddress / billingAddress (if set), and the recoveryUrl . Recovery email: Throttle sends the customer.cart_abandoned email automatically whenever the cart has a known customer email. The email respects customer opt-out / unsubscribe; in non-production environments it carries a [TEST] subject banner. Cart sessions: a backend-less storefront using cart sessions has no server-side customer record to attach an email to. Set customerEmail with PATCH /api/v1/cart-sessions/ id as soon as the buyer gives it — that alone is what makes the guest cart eligible for the sweep above. Where the recovery link points The link in the recovery email resolves through three tiers, first match wins: cartRecoveryUrlTemplate — a URL on your own site containing a cartId placeholder, e.g. https://shop.example.com/cart?c= cartId . Use this when you want the buyer to land on a specific page you control. checkoutLinkBaseUrl — your own host, e.g. https://shop.example.com . Throttle appends /r/<cartSessionToken> , so serve that route yourself with the cart SDK. Throttle-hosted — when neither is set, the link points at Throttle’s own checkout. Nothing to configure. Both settings live on the application, per environment, and are editable from the dashboard’s Abandoned carts page. checkoutLinkBaseUrl also governs where quote links point, so a merchant hosting their own buyer-facing pages sets it once. A recovery link is single-use . Opening it converts the underlying cart session into a checkout session; a second click lands on the expired page rather than reopening a checkout the buyer may already have paid. Throttle can only recover carts it knows about. To get abandoned-cart recovery, create the Throttle cart (POST /carts ) when the buyer first adds an item and mutate it on every change — the mirror pattern . If you only create the cart at checkout, abandonment happens before Throttle ever sees it and there is nothing to recover."},{"title":"Error codes","route":"/developers/reference/errors","headings":["Error codes","Envelope","Common codes","Subscription and recurring-checkout codes","Currency codes","Order builder collect codes","Resolved cart codes","Order line-item edit codes","Validation errors","SDK errors","HTTP code conventions"],"text":"Reference Error codes Every 4xx / 5xx response carries a stable error.code . Reach for it (not the prose message ) when branching in your handler — codes are versioned with the wire contract; messages are not. Envelope Common codes The list below covers the ~20 codes you'll see most often when integrating cart, checkout, payments, and embed flows. The full set is generated from the OpenAPI spec at api.usethrottle.dev/openapi.json . [ code: code , http, cause, ]) /> Subscription and recurring-checkout codes Codes returned by the subscription routes and by checkout sessions that carry a plan. See Managing Subscriptions and Starting checkout from an existing cart for the behavior behind them. [ code: code , http, cause]) /> Currency codes Codes returned by the one currency rule on every create path, by per-currency amounts (discounts, shipping, routing thresholds) and by the main-currency lock. See Multi-currency for the rules behind them. [ code: code , http, cause]) /> Order builder collect codes Codes returned when collecting payment for a draft order built in the dashboard: the payment-link and invoice-on-terms routes, and the payment-link checkout page. See Build an order from a cart . [ code: code , http, cause]) /> Resolved cart codes Codes returned for carts whose lines an installed app prices, and for cart sessions of an environment where browser carts may only hold resolved lines. Each code, its details shape and the SDK error class are on Line Resolvers . [ code: code , http, cause]) /> The two cart pricing settings on PUT /api/v1/embed-config , resolvedLinesOnly and cartCurrencies , answer 403 permission_denied to any caller other than the merchant (an installed app's key, an extension session, an OAuth token). An invalid cartCurrencies answers 400 validation_error with details[].path naming the field or the entry, for example cartCurrencies.1 for a duplicate, the environment's own currency, or a code that is not a currency shown and charged with two decimal places (JPY, KRW, KWD, BHD, HUF, XTS). Extra cart currencies Order line-item edit codes Codes returned by PATCH /api/v1/orders/:id/line-items , which adds, updates or removes line items and settles the change on an order with a captured payment: an increase is charged to the stored card, a decrease refunded. On an order whose balance is on an outstanding Net-N invoice the invoice takes the change instead — its amount rises or falls, no card is charged — and the response reports it in data.adjustment.invoice . Every code below refuses the edit before anything is written, charged or refunded. [ code: code , http, cause]) /> When the application calculates tax with a provider, the edit re-quotes tax with it. Under the lenient fallback policy a failure the strict policy refuses goes through instead, and the 200 response carries data.warnings: [ code, message ] : provider_fallback (the provider could not answer, so your native tax rules priced the edit), tax_address_missing (no address to tax from, so no tax was charged), or tax_zero_from_provider (the provider returned no tax for the address). The field is absent when there is nothing to report. A refund that fails after the items changed does not error either: it comes back in data.adjustment.refundError , and the refund can be retried from the order. Likewise, a Net-N invoice the edit leaves owing nothing is voided, and a void that fails comes back in data.adjustment.invoice.voidError . Validation errors When a zod body validator fails, the envelope carries code: "validation_error" and a per-field details[] array. Strict-input endpoints return only the canonical camelCase contract. Field-level details identify unrecognized keys and may include a suggestion string when the server can identify the intended field: When in doubt about a field name, send the request, then read error.details[].path . The validator names the canonical key it expected. SDK errors Every Throttle SDK throws the same error class — ThrottleError from @usethrottle/errors , re-exported from each SDK. It carries code , statusCode , message , and optional details . The historical per-SDK names still work and are subclasses, so instanceof ThrottleApiError (cart) and instanceof ThrottleCheckoutError (checkout) keep working — and a single instanceof ThrottleError catches errors from all of them. The cart SDK additionally throws typed subclasses for the two lifecycle failures worth branching on, so you don't have to sniff error.code strings. Both extend ThrottleApiError (and therefore ThrottleError ): CartNotOpenError (409 cart_not_open ) and CartNotFoundError (404). HTTP code conventions 400 — request was malformed (validation, missing required field, invalid value). 401 — authentication missing or invalid (key, signature, JWT). 402 — request requires a paid subscription that isn't active (production entitlement). 403 — authenticated but not authorized for this workspace / application / role. 404 — resource doesn't exist (or exists in a different workspace). 409 — request conflicts with current state (cart already checked out, Idempotency-Key reused, slug taken). 410 — resource was permanently removed (revoked invitation, abandoned cart). 422 — semantic validation failed (e.g. payment amount exceeds order total). 429 — rate limit exceeded. 5xx — Throttle problem. Idempotency-Key replays do NOT cache 5xx, so safe to retry with the same key."},{"title":"Fulfillment states","route":"/developers/reference/fulfillment-states","headings":["Fulfillment states","State machine","States","Type-specific details","When the order is considered fulfilled"],"text":"State reference Fulfillment states Fulfillment lifecycles are shared across all 5 fulfillment type s (shipment, digital, access, service, in_person). The terminal definition varies by type. State machine States pending — fulfillment row created, no provider activity yet. in_progress — provider has acknowledged the work (label printed, digital file prepared, access pending). Fires fulfillment.shipment.shipped for shipment type — on the first tracking number, whether it arrives with POST /orders/ orderId /fulfillments ( shipment.trackingNumber ) or a later PATCH .../fulfillments/ id /shipment . fulfilled — terminal. Per type: shipment — carrier marks delivered ( fulfillment.delivered ). digital — file generated and a presigned URL was returned at least once. access — access grant row inserted and active. service / in_person — merchant marks complete via API. cancelled — terminal. Reachable from pending or in_progress . For shipment, cancelling after label creation requires merchant void via the carrier. Type-specific details Each fulfillment carries exactly one detail record, decided by its type , and each detail endpoint only accepts a fulfillment of its own type: shipment → PATCH /api/v1/orders/ orderId /fulfillments/ id /shipment digital → …/digital access_grant → …/access service → …/service Pointing one at a fulfillment of another type returns 409 fulfillment_type_mismatch and writes nothing. digital and access_grant line items are fulfilled automatically when the payment captures, so their fulfillment already exists — creating a second one returns 400 over_fulfillment . Patch the existing one instead. When the order is considered fulfilled The parent order reaches fulfilled when every deliverable line item s quantity is covered by completed fulfillments — not when every fulfillment row happens to be terminal. Line items with nothing to deliver (a donation, a fee) are excluded from the count, so an order made entirely of them is fulfilled as soon as it is paid. Progress is readable on the order as fulfillmentSummary ."},{"title":"Order states","route":"/developers/reference/order-states","headings":["Order states","Why two fields and not one","What each field owns","Buyer and source fields","State machine","The seven statuses","The twelve payment statuses","Setting a status by hand","The whole story, not just the status changes","Internal notes","Money moved by hand","How much, not just whether","Where the goods are","Holds","What a hold blocks","What a hold does not block","Placing and releasing","Endpoint per transition","What you cannot do","Outbound events"],"text":"State reference Order states An order has one lifecycle status and one payment status. The lifecycle status answers how far along is this order? ; the payment status answers how much of the money has arrived? . Nothing else. Why two fields and not one Delivery only moves forwards: once an item ships, it has shipped. Money moves both ways — a captured payment can be refunded, and a refund is not a step backwards through the lifecycle, it is a separate fact about the same order. Folding the two into a single field would mean either losing the refund or corrupting the sequence. So status is a single ordered sequence, and paymentStatus sits alongside it. Read them together and you have the whole picture; that combination is exactly what displayStatus gives you. An order used to carry a third field, fulfillmentStatus , alongside status . It was removed: the two encoded one fact, had to be kept in sync by hand, and drifted. Delivery progress now lives in status itself, with per-item counts in fulfillmentSummary . What each field owns status — the lifecycle. Moved automatically by payments and fulfillments, and settable by hand via POST /orders/ id /status . paymentStatus — how much of the money has arrived. Rolled up across every payment on the order. Never set directly. displayStatus — a human-readable label combining the two, e.g. Processing — Awaiting payment . Derived on every read, never stored, not filterable. Display it; branch on status . fulfillmentSummary — itemsTotal, itemsFulfilled, itemsRemaining . Items with nothing to deliver (a donation, a fee) are excluded from the total. payments[].status — what happened on one transaction. Never a summary of the order. Buyer and source fields Every order read ( GET /orders/ id , the list, and the order every order mutation returns) also carries the stored order row's identity and provenance columns: ipAddress and userAgent — the buyer's IP address and browser user agent recorded at checkout. Null on orders that did not come through a checkout (created by API, imported, or renewals). This is personal data about your buyer: treat it as you would their email address. clientContext — the attribution snapshot copied from the checkout session when the order was created (for example UTM parameters and the referring page). Free-form; null when the checkout recorded none. applicationId , environmentId and cartId — the application and environment the order belongs to, and the cart it was converted from (null when it was not). externalId , externalCustomerId and importId — for an order that arrived by import, the source platform's order and customer ids and the import that created it; null otherwise. Line items on an order read carry their row columns too: orderId , position , cartId , workspaceId , applicationId , createdAt and updatedAt . State machine A failed payment does not move the order. paymentStatus becomes failed ; status stays where it was. The goods side of the order has not changed, and the merchant may retry on another card. PAYMENT_FAILED therefore appears in no row of the automatic-transition table. The seven statuses draft — created, checkout not started. No payment expected. pending — placed, awaiting payment. processing — payment authorized or captured, nothing delivered yet. Fulfillments can now be created. partially_fulfilled — some deliverable items handed over, some outstanding. fulfilled — everything the buyer bought has been handed to the carrier or given to the buyer. This is the happy-path end state. It is handover, not arrival: arrival is a separate, later fact and reads as the Delivered qualifier. An order with nothing to deliver at all reaches it as soon as payment lands. cancelled — called off. Terminal. Authorized payments are voided; captured money is refunded only on explicit opt-in. closed — archived out of active views. Terminal. Reachable from fulfilled or cancelled : closing is bookkeeping, not completion. completed meant delivered and paid — two facts in one word. It is gone; read status === fulfilled together with paymentStatus instead. Orders that held it were migrated to fulfilled . The twelve payment statuses paymentStatus is rolled up across every payment on the order and recalculated on every money movement. You never set it. It is not the same vocabulary as one payments[].status : an order can be partially_paid , which no single payment can express, and disputed and charged_back are derived from a payment s dispute fields rather than from its own status. pending — nothing has arrived and nothing is in flight. authorized — a hold is in place, no money taken. Capture it or it expires. processing — the payment is in flight at the provider: a 3DS challenge, an ACH or iDEAL debit, a bank redirect. It is not a decline. Wait for the provider s webhook; do not retry the charge. partially_paid — some money has arrived, less than the total. A deposit taken with the balance still outstanding. captured — everything owed has arrived. A fully comped order reads this too, with nothing charged. partially_refunded — some of the captured money has been returned. refunded — everything captured has been returned, across every payment on the order. Fully refunding one payment of several — a deposit with its balance paid, or the extra charge from an edit — reads partially_refunded and leaves the order where it is. Only an order that reads refunded can be cancelled by the refund itself (one that has not shipped, has no subscription and no comp). failed — every attempt was declined, and none is still in flight. voided — the authorization was released without ever being captured. disputed — the buyer has charged back. This outranks captured : the money is captured AND at risk, and only the half with a deadline is worth a label. Defend it with your provider. charged_back — a chargeback was decided against you and the money was taken back. The dispute is closed, so this ranks below an open disputed on another payment."},{"title":"Payment states","route":"/developers/reference/payment-states","headings":["Payment states","State machine","States","Refunds","Net30 special cases","Disputes & chargebacks"],"text":"State reference Payment states Payment states mirror the underlying provider (card processor, Net30 for invoice). Throttle normalises them so your handler is provider- agnostic. State machine States pending — payment row created, no provider response yet. UI shows a spinner; back-end has the row id but no auth/capture proof. processing — the provider has neither approved nor declined yet: the buyer is mid-3DS challenge, away on a redirect method, or the debit is still settling. Nothing has failed, so there is no error.code — treat this as keep waiting , not as a decline. Fires payment.processing , and a resolution event ( payment.authorized , payment.captured or payment.failed ) always follows. authorized — provider authorised the amount (funds reserved). Most card flows auto-capture so this is brief; manual-capture flows stay here until the merchant explicitly captures. captured — funds moved. Triggers payment.captured outbound event. Order can now transition to paid . Capture may be partial : pass an amount below the authorized total to POST /v1/payments/ id /capture . The captured value is tracked on capturedAmount (the event carries both capturedAmount and authorizedAmount ); the uncaptured remainder is released by the processor, and refunds are capped at the captured amount. failed — provider declined OR a network/timeout error during processing. Carries error.code + error.message . Terminal. A merchant can also declare it by hand with POST /payments/ id /mark-failed — the promised bank transfer that never arrived, the cheque that bounced — which writes error.code: manually_marked_failed and the supplied reason. Only a pending , processing or already- failed payment is failable by hand; anything resolved returns 422 payment_not_failable . voided — authorisation released before capture. Funds never moved. Terminal. expired — the authorization outlived its usable life and was never captured, so the hold has lapsed at the provider. Throttle sweeps these itself because providers do not reliably notify on expiry, and fires payment.expired when it does. To collect this money you must charge again; capture will not work. Terminal. partially_refunded — at least one refund processed but total refunded is less than captured amount. Still accepts further refunds. refunded — total refunded equals captured amount. Terminal. Refunds POST /v1/payments/ id /refund returns money from one payment; omit amount to return everything still refundable on it, including the rest of a payment that was already partly refunded. POST /v1/orders/ id /refund works on the order: when an order was paid more than once — a deposit and a balance, an instalment plan, a retried charge that settled on a second row — the refund is spread across its captured payments, newest capture first, and amount may exceed any single one of them. Omit amount to return everything still refundable on the order. Asking for more than remains returns 400 refund_amount_exceeds_balance , whose message names the amount that is still refundable (formatted in the payment's currency) and whose details carry it as a money value, amount, currency, display , with amount in minor units. The check runs before any money moves, so a refund that cannot be completed in full sends none of it. A refund claims its amount before the provider is called, so a second refund of the same payment arriving at the same moment gets 409 refund_already_in_progress rather than passing a stale balance check and returning the money twice. Wait for the first to resolve, then re-read what is refundable. Net30 special cases Net30 payments go through pending → authorized on invoice issue, then → captured when the buyer pays the invoice (manual mark-paid OR ACH webhook). invoice.overdue fires when an authorized Net30 payment passes its dueDate ; the payment row stays authorized until paid or voided. Disputes & chargebacks Disputes are tracked out-of-band from the status machine, on the disputed boolean plus disputeReason / disputeOpenedAt , and — once a card chargeback is decided — disputeOutcome ( won / lost ) and disputeClosedAt , all exposed on the payment object. A payment keeps its underlying status (usually captured ) throughout. Card chargebacks are ingested automatically from the payment provider: an inbound dispute.* / chargeback.* webhook sets disputed = true and fires payment.disputed . A merchant-favourable resolution (won / reversed) clears the flag, records disputeOutcome: won , and fires payment.dispute_cleared . A loss clears the flag, records disputeOutcome: lost , rolls the order to charged_back , and fires payment.dispute_lost . Net30 disputes are opened/cleared manually from the dashboard and fire the same two events."},{"title":"API Key Scopes Reference","route":"/developers/scopes","headings":["API Key Scopes Reference","Secret vs publishable keys","How scope checks work","Implication","The wildcard","Key types & grantable scopes","The buyer plane: a publishable key is not a permission","Customer PII: redacted, not rejected","Customer directory: names without the record","Scope catalog","{group}","Migration from coarse scopes","Machine-readable catalog"],"text":"Workspace API Key Scopes Reference Every Throttle API route declares a single required scope — a resource:action string such as orders:read or payment_refunds:write . API keys are gated purely by the scopes they hold; dashboard (Clerk) users are gated by role-based permissions instead. The two systems compose — they never replace one another. Secret vs publishable keys API keys come in two prefixes. Secret keys ( sk_ ) are full-power, server-side credentials — keep them on your backend and never expose them. Publishable keys ( pk_ ) are safe to embed in browser or frontend code. Because a publishable key is application-scoped and exposed in the browser, it is restricted to a small, curated set of publishable-allowed scopes — five today: shipping_quotes:write , tax_calculations:write , storefront_scripts:read , storefront_scripts:write , and customer_accounts:write — and can never read carts, orders, customers, payment methods, or PII directly. That last scope is a deliberate exception to "publishable means stateless compute"; see the buyer plane below. (Listing available payment methods needs no key: GET /v1/payment-methods is public.) Create either type from Developers → API keys in the dashboard. How scope checks work On every API-key request Throttle reads the matched route's required scope and checks it against the key's granted scopes using these rules: Implication Exact match grants the scope. resource:write implies resource:read on the same resource — holding a write scope lets you read it too. There is no cross-resource implication : orders:write does not grant order_returns:write or payment_refunds:write . Each financial or destructive action is its own scope. read never implies write . The wildcard * grants full access to every scope. It is only grantable to secret ( sk_ ) keys. Publishable ( pk_ ) keys can never hold * . Prefer the smallest set of explicit scopes; reserve * for trusted server-side keys. A request whose key lacks the route's scope returns 403 with error.code = "insufficient_scopes" and a details.required field naming the missing scope. Key types & grantable scopes Secret keys ( sk_ ) May hold any non–staff-only scope, or the wildcard. Staff-only scopes (the admin:* family) are not grantable to workspace-minted keys — those routes are gated by Throttle Staff org membership in Clerk, never by an API key. Requesting one returns 400 invalid_scope . Publishable keys ( pk_ ) Browser-safe. May hold only the publishable-allowed subset (marked pk_ ok below) — five scopes today: the two stateless compute scopes shipping_quotes:write and tax_calculations:write ; the storefront-script loader s storefront_scripts:read and storefront_scripts:write ; and customer_accounts:write , which gates the buyer-auth plane rather than granting a data-reading permission — see the buyer plane below. A publishable key can never read carts, orders, customers, payment methods, or PII — those scopes are not publishable. (Listing the eligible payment methods for a session needs no key at all: GET /v1/payment-methods is public.) Any non-publishable scope — or the wildcard — returns 400 invalid_scope . The buyer plane: a publishable key is not a permission A publishable key identifies an application. On the buyer plane it is not a permission. Storefront customer-account endpoints are authorised by the shopper's session, not by the key's scopes — the key only selects which application the shopper is signing in to. The one scope that gates that plane is customer_accounts:write : without it, a publishable key cannot reach customer accounts at all. Every other publishable scope ( shipping_quotes:write , tax_calculations:write , storefront_scripts:read , storefront_scripts:write ) authorises a stateless compute or a public read directly off the key itself. customer_accounts:write works differently: holding it only unlocks the /v1/storefront/* route family (see Storefront Customer Auth ). Every action a buyer actually takes there — reading their orders, changing an address, removing a saved card — is authorised by their own Authorization: Bearer session token, never by the key. Two different applications' publishable keys can both hold customer_accounts:write and still never see each other's buyers; the key only decides which application a login or register request lands on. Customer PII: redacted, not rejected customer_pii:read gates personal fields on a customer record — email , phone , firstName , lastName , and the combined name some endpoints return. A credential that can read (or write) customers but lacks this scope does not get a 403 : the request succeeds and those fields come back null , with every other field (id, externalId, company, addresses, order/subscription linkage) intact — the same "redact, not reject" pattern the picker warning on ⚠ sensitive exists to flag. This applies whether the customer came back from a read, or is the record echoed back by a create/update call. Because customer_pii:read is sensitive, an OAuth connection to an AI assistant (the MCP integration flow) can never hold it — see the MCP docs for which tools therefore always return redacted customer data. It is the one sensitive scope an automation connector (Zapier, Make — an OAuth client registered for the REST API) may be granted, and the consent screen names it. A connector without it gets the same redaction, and creating, changing or replaying a webhook endpoint returns 403 oauth_customer_pii_required , because deliveries carry the full record. Customer directory: names without the record customer_directory:read gates GET /api/v1/customer-directory , which finds customers by a name or email prefix, or names up to 50 customer ids, and answers only their id , name and email . It exists so an extension can show customers in a picker without holding customers:read . It is ⚠ sensitive , so no OAuth connection can hold it, and no other scope grants "},{"title":"Your Own Scripts on Hosted Checkout","route":"/developers/scripts","headings":["Your Own Scripts on Hosted Checkout","What this is","What a script can and cannot do","Loading a vendor tag","Event catalog","Consent","External domains","Scripts shipped by an installed app","Declaring them","The two tiers","Automated review gates","What a script declares is frozen once it ships","Consent on upgrade","Your controls over an app's scripts","Authoring from the CLI","Your own storefront","One script tag","Or from npm","React","Events you publish","The manifest, directly","Managing scripts","API","Create a script","List scripts","Update a script","Delete a script","Kill switch","Health","Limits","What is not in this release"],"text":"Analytics Your Own Scripts on Hosted Checkout Add your own JavaScript to Throttle s hosted checkout page — a pixel, a cookie-based attribution stitcher, a custom analytics relay. It runs in an isolated sandbox with no access to the page, the payment form, or anything Throttle does not explicitly hand it. This page documents exactly what ships today: your own scripts, sandbox-tier only, on the hosted checkout page ( /c and /s ), and never in embed mode. See What is not in this release before you build against this. What this is Throttle mounts every enabled script for a surface inside its own <iframe sandbox= allow-scripts > — no allow-same-origin , so the frame s origin is opaque and it cannot reach the parent document under any circumstance. Your script talks to the host only through postMessage , wrapped by the small API surface described below. checkout , html: Hosted checkout — /c/<sessionId> and /s/<sessionId> , Nothing. Mounted for you. , ], [ html: quote , html: The hosted quote page, /q/<token> , html: Nothing. Consent fails closed here — a quote link carries no consent decision, so an environment that requires consent runs essential scripts only. , ], [ html: storefront , Your own pages, on your own domain , html: Install @usethrottle/scripts and allow-list your origin — see Your own storefront . , ], [ html: Embedded checkout ( ?embed=1 ) , html: Runs no scripts of its own. It forwards its events to your page instead. , html: Install the loader on the page doing the embedding. Without it, checkout-surface scripts never run — the dashboard warns when it has never seen your loader call in. , ], ] /> Embedded checkout runs nothing itself on purpose: a sandbox inside that iframe would write cookies like _ga to Throttle s checkout domain, splitting them from the storefront half of the same shop. Forwarding the events to your own loader keeps every cookie first-party on your domain. What a script can and cannot do checkout.started , .step_changed , .payment_submitted , .completed , .failed ) and publish its own events over analytics.subscribe / analytics.publish — see the event catalog below. , html: Touch the checkout page — no document , window , or parent . Reachable identifiers are shadowed to undefined ; the real containment is the iframe s opaque origin. , ], [ html: Read/write cookies (real names — _ga , _fbp , etc. — with a small reserved list blocked) and namespaced local/session storage, all proxied async through the host. A read settles as null when refused; a write rejects , so a refused write cannot read as a successful one. , Read the payment form, card fields, or anything else rendered on the page. , ], [ html: Call fetch / sendBeacon to hostnames it declared in externalDomains . , html: Make a network request ( fetch , sendBeacon , XHR, images) to any other domain — blocked by the sandbox document s CSP connect-src , with no error the script itself can see; Throttle reports it as a blocked outcome with reason: csp . A script can still navigate its own frame (e.g. location.href = ... ) to any URL — that is not a network request CSP governs. Throttle detects this: a navigated frame is torn down and reported as an error outcome, so it is observable in the script health view even though it is not prevented. , ], [ Read the page context it booted with (URL, referrer, title, user agent) once, at load. , See anything about the buyer or the order that was not explicitly included in an event payload — no PII grading is wired yet, so no event carries customer data in this release regardless. , ], ] /> The register() contract Your source is compiled as a function body and run, so plain JavaScript works — top-level statements execute, and a script that never calls register() is perfectly valid. Use it when you want to subscribe to events; skip it when you just want code to run. At the top level you are handed four bindings: register , analytics , browser and settings . There is no api at the top level — api is the single object passed into the register callback, so api.browser.cookie.set(…) outside register() is a ReferenceError . Write browser.cookie.set(…) instead. What actually constrains you is not the convention. document , window , parent , top , opener , localStorage and sessionStorage are undefined inside a sandbox-tier script, and the network is governed by the CSP built from your externalDomains . Use browser.* for storage and browser.loadScript() for vendor tags. Your script s whole body is one call to the global register() function, which Throttle invokes once per load with analytics, browser, init, settings . The shape below — subscribing to a named event and publishing a derived one — is copied from a real, currently-passing test in packages/scripts-runtime/src/sandbox/bootstrap.test.ts , not invented for this page: This actually fires: the hosted checkout page bridges its own checkout lifecycle onto this bus (a script never sees the underlying postMessage events — only the projected checkout.* names below). Loading a vendor tag browser.loadScript(url) pulls a remote script into the sandbox and resolves when it has run. The host must be in your externalDomains , exactly — declaring example.com does not cover cdn.example.com , because the CSP matches hosts rather than parent domains. It rejects with a real error you can catch, rather than leaving you with a CSP refusal the page cannot see. A vendor tag mutates whenever the vendor redeploys — that is the point of one — so subresource integrity cannot pin it and no review can vouch for what runs tomorrow. What contains it is the sandbox: the code lands in an opaque origin with no cookies, no access to the merchant s DOM, no parent, and no network beyond the same declared list. Judge a vendor on whether you are willing to run their code under those limits, not on having read it once. Event catalog step — one of cart | address | shipping | billing | payment . , ], [ code: checkout.payment_submitte"},{"title":"Mount Card Fields On Your Own Page","route":"/developers/secure-fields","headings":["Mount Card Fields On Your Own Page","How it works","Mint the session","Content Security Policy","React example","Status","3-D Secure","Saved cards","Testing failures","Declines","The rest","Error reference","Field mounting and vaulting (the hook, in the browser)","3-D Secure (the hook)","Fallback codes","Routing -> Decline rules in the dashboard does the same thing.","Remove it again when you are done."],"text":"Checkout surfaces Mount Card Fields On Your Own Page Secure Fields gives you individually mountable, provider-hosted inputs — card number, expiry, security code, postal code — that you place in your own form and style yourself. Raw card data never touches your page or ours; your server never sees it either. Secure Fields has no PayPal, Apple Pay, or Google Pay. It is a card-capture surface, full stop — that is a property of the product, not a v1 gap. A merchant who needs those payment methods keeps using Embedded Checkout or hosted checkout alongside Secure Fields for the card-only case. How it works Four steps, and the only server-to-server call is the mint. Everything after that is either a mount inside the buyer's browser or the same public completion route the Embed and hosted checkout already use. Mint the session Call this from your backend, with your sk_* secret key. Never call it from the browser — there is no publishable-key variant. Field Notes POST /api/v1/checkout-sessions/:id/card-session :id is an existing Throttle checkout session (create one first with the cart/checkout APIs, exactly as you would for the Embed). Auth X-API-Key secret key with the checkout_sessions:write scope. Response sessionId, providerId, environment — pass this object straight into unchanged. Throttle records every card session it mints on the checkout session it was minted for (the newest 20 are kept). /complete authorises only against one of those; a card session minted for another checkout session, or one Throttle did not mint for it, answers 409 transaction_not_for_session before any order or authorisation, with details.retryable: false . Mint a new card session for this checkout session and mount the fields again. A card session minted before 2026-10-05 is refused once in the same way. Content Security Policy The card inputs are iframes served from Throttle's payment provider, not from your own domain. If your checkout sends a Content-Security-Policy header, the fields render as blank boxes until you allow that origin — usually with no error in the page, only a CSP violation in the browser console. frame-src lets the iframes mount; connect-src covers the calls they make. If you also send frame-ancestors elsewhere, it does not apply here — that directive governs who may frame you , not whom you may frame. React example @usethrottle/checkout-react ships a headless provider, a hook, and four unstyled field primitives. Style everything yourself — the whole point of choosing Secure Fields is that you own the pixels around the fields. session.sessionId (from the mint response) is the payment provider's checkout session id. It is what constructs the SecureFields instance and mounts the field iframes. checkoutSessionId (the prop you pass to separately) is Throttle's checkout session id — the same :id you minted against in the step above. It is what pay() uses to call POST /checkout-sessions/:id/complete . Swap them and every payment breaks: the fields will mount and take input fine (they only need the payment provider's id), but complete() will call /checkout-sessions/ provider id /complete , which does not exist on Throttle's side, and every payment 404s at the very last step. Status useSecureCard() exposes an explicit state machine. Render any of it — there is no need to infer state from side effects. Status What it means What to render idle Provider mounted; fields not yet registered. A loading state over the field area. mounting The Secure Fields instance exists; individual fields are registering. Same loading state — fields are not yet interactive. ready All required fields (number, expiry, CVV) are registered. Enable the Pay button. ready is also exposed as its own boolean. vaulting pay() / vault() called; waiting on the payment provider to accept or reject the card. Disable the button: “Saving card…” authorizing The card vaulted; Throttle's /complete call is in flight. Disable the button: “Processing payment…” challenging /complete returned requires_action ; the payment provider's own 3-D Secure iframe is mounted at your registered element and running. Make the registered mount element visible to the buyer: “Confirming with your bank…” complete The order was created. onSuccess already fired. Navigate away, or show a confirmation. error Vaulting, the challenge, or completion failed. Read error.code / error.message (see below) and let the buyer retry. 3-D Secure A payment that needs step-up authentication does not fail — complete() resolves with status: 'requires_action' instead of an order. pay() handles this for you. When that happens, the hook moves to challenging and drives the payment provider's own 3DS iframe (mounted at the element you passed to registerThreeDSecure ) to a result, then re-calls /complete for the authoritative outcome. Register the mount element alongside your card fields — it only actually mounts a challenge when one is needed, so it costs nothing to always include it. If the buyer never resolves the challenge, challengeTimeoutMs (5 minutes by default, overridable via the same-named prop) settles complete() to error.code: 'three_ds_timeout' on its own. Calling cancelChallenge() (also from useSecureCard() ) abandons an in-progress challenge with error.code: 'three_ds_abandoned' . If the challenge resolves but the payment still needs authentication, or if the buyer keeps retrying without ever clearing it, Throttle caps re-entry at 5 attempts and returns 409 three_ds_unresolved . Treat this as terminal: tell the buyer to start a new checkout rather than retrying the same session again. Saved cards Whether a card gets saved is decided entirely by the checkout session you minted the card session against — nothing the browser sends can change it. If that Throttle checkout session carries"},{"title":"Failures with no error","route":"/developers/silent-failures","headings":["Failures with no error","At a glance","Your tenant id is not where you expect","Cancellation fires two different events","A handler for an event you never subscribed to","The gate that never runs","Creation paths without a subscription","Changing a plan through checkout","Cards that save but cannot be charged","Test doubles kinder than production","Start with the delivery log"],"text":"Integration Failures with no error Every trap on this page shares one property: it produces no error. No exception, no 4xx, nothing in your logs. Several of them look actively healthy — a delivery log full of 200 s while nothing at all is happening. This is the single most useful habit for a billing integration. After every step, assert against your own database — not the API response, and not the absence of an exception. Most of what follows was found in production by someone who had green tests. At a glance Your tenant id is not where you expect Subscription and payment payloads carry Throttle s internal customerId — a UUID that means nothing in your system. Your identifier is on the sibling customer object, as externalId . Both appear on the same customer object. externalId is the id you set when you created the customer. externalCustomerId is a per-connection mapping and is null for direct API integrations — so reading it returns nothing, forever, without erroring. The failure mode is total and invisible: every subscription webhook is received, resolves to nothing, and returns 200. Throttle s delivery log shows delivered / 200 for all of them. If your checkout return path also syncs state, the user-present flow keeps working and hides it — until the first renewal, a month later, with no user present. Cancellation fires two different events Handle only subscription.cancelled and a scheduled cancellation is invisible to you: no confirmation email, and your Cancel button stays live because the subscription is still active . The buyer cancels, sees nothing change, and cancels again. A handler for an event you never subscribed to You can write a flawless handler for an event your endpoint does not receive, and nothing will ever tell you. There is an endpoint for exactly this: it reports event types your application emitted that no active endpoint subscribes to. Run it after every integration change — it is cheap enough to put in CI and fail the build on a non-empty unsubscribed array. An endpoint subscribed to * always reports clean. The gate that never runs An entitlement check registered in the wrong place — before tenant context exists, on a router that does not cover your API, behind a condition that is never true — returns early on every request. Both the expiry lockout and all plan gating become dead code, and nothing distinguishes that from no one is over their limit . Reading the code is not evidence. Put a tenant into a blocked state and confirm you get a 403. This is a two-minute test that no amount of code review substitutes for. Creation paths without a subscription Count the ways a tenant can come into existence: signup, invite, admin creation, seed script, import, test fixture. Each one that does not create a subscription row produces an account with permanent free access that never expires and is never asked to pay. Nothing surfaces this. The accounts work perfectly. Enumerate the paths by grepping for your create call and check each one individually. Changing a plan through checkout Checkout always creates a new recurring subscription. Run an existing subscriber through it and they now have two, and are billed for both. Use POST /subscriptions/{id}/change-plan instead, and fall back to checkout only when there is no live subscription to change. Cards that save but cannot be charged Card entry must go through the hosted embed. A card attached by any other route saves successfully, lists successfully, and then fails at charge time with payment_method_not_chargeable — which you discover at the first renewal, not at integration time. Use the embed via @usethrottle/payment-methods or hosted checkout. It is also what keeps card data out of your DOM and your PCI scope. Test doubles kinder than production A mock that returns a friendlier shape than the real API produces passing tests and a broken integration. This is the failure behind more than one bug on this page, on both sides of the boundary. For anything crossing into Throttle, assert against a real captured payload . The delivery log gives you exactly what was sent; replay one as a fixture rather than writing what you think the shape is. Start with the delivery log GET /api/v1/webhook-deliveries?endpointId= shows the exact payload sent and the status your endpoint returned. When something is not happening and nothing is erroring, this is the first place to look — it distinguishes never sent from sent and your handler swallowed it , which are completely different bugs. Next: the build and test checklist turns this page into something you can tick through."},{"title":"Storefront Customer Auth","route":"/developers/storefront-auth","headings":["Storefront Customer Auth","Two planes, not two auth options on one plane","Turning it on","Tokens and sessions","Session lifecycle","Step-up: proving the password again for destructive actions","Email verification and the claim cutoff","Endpoint reference","No session required","Session required","Cart claim and the retirement of X-Customer-Id","Deprecation timeline","Finding the buyer's cart on another device","Error reference","Webhook events","Carts, claims and reorder","Theming","Checkout interruption","React and react-dom are peer dependencies of this entry point`;"],"text":"Buyer identity Storefront Customer Auth Throttle can authenticate a merchant's end buyers — the shoppers on their storefront — as a plane entirely separate from the merchant API you already use for orders, payments, and webhooks. Buyers register, log in, verify their email, reset passwords, manage addresses and saved cards, view their own orders, invoices and subscriptions, and bind an anonymous cart to their account at checkout. Every application has auth.enabled: false until a merchant turns it on. Guest checkout is completely unaffected either way — a customer with no credentials is a guest by definition, and nothing about existing carts, orders, or subscriptions changes when this feature is off. Two planes, not two auth options on one plane A buyer session is not a weaker version of a merchant API key, and it can never be used as one. The two are isolated structurally, not by convention: , so a page that imports only the provider and guards no longer ships account or sign-in code (the CJS build is one bundle per entry and inlines the modals). Measured on the demo store's /shop : 195,876 B to 184,633 B gzip, 11,243 B lighter. Import paths are unchanged. Drop-in components: @usethrottle/auth/components The hooks above are headless — you build the UI. The ./components entry point is the other option: prebuilt, restylable React components that render inside your own storefront, so a buyer never leaves your site to sign in or read their order history. Same package, same session, same routes underneath. as const, Sign-in card. Also SignUp, ForgotPassword, ResetPassword, VerifyEmail. ], [ code: as const, The five cards with navigation between them — one component for a whole auth surface. ], [ code: as const, A button that opens the auth flow in a modal. Also SignUpButton, SignOutButton. ], [ code: as const, Full-viewport centred shell for a dedicated /sign-in route. Also SignUpPage. ], [ code: as const, Navbar avatar with a menu: identity, account, sign out. ], [ code: as const, Profile, addresses, saved cards, orders, invoices, subscriptions and security, in a sidebar or tab layout. ], [ code: / as const, Render children only in that state. additionally gates on email verification. ], ] /> Every panel is also exported individually ( OrdersPanel , AddressesPanel , …) if you want your own shell around them. Theming Three levels, from least to most invasive: CSS custom properties via appearance.variables , your own classNames on any part via appearance.elements , and whole-component replacement via appearance.components . Every shipped rule is a single-class selector, so a className you pass wins on source order rather than losing to specificity. colorPrimaryForeground is the text drawn on top of colorPrimary . Overriding only the first leaves the theme s own foreground in place, and the pair is not always readable — a terracotta primary on its own measures 3.65:1 against the dark theme s foreground, below the 4.5:1 WCAG asks for button text. Development builds measure the pair and warn once in the console when it falls short. They also warn when your brand colour is too close to the destructive colour to tell apart: Delete and Edit sit side by side in the account panels and a buyer distinguishes them by colour alone, so a warm brand landing on the danger red removes the only signal that one of them is irreversible. Nothing is logged in production builds. appearance.theme accepts light , dark or auto , and defaults to light. It is deliberately not auto : that resolves against prefers-color-scheme , which describes the shopper s machine and has no relationship to your storefront — so a light site would render a dark widget for every shopper with dark mode enabled. Opt into auto explicitly if your own site follows the OS too. Checkout interruption The case these components exist for is a buyer stopped mid-purchase. Render the auth flow inline where they already are, rather than sending them to a separate page — and pair it with cart claim so the cart they built anonymously follows them into the session. An unverified buyer s orders, invoices, subscriptions and saved cards are withheld by the server (see the claim cutoff above). The panels render that as an actionable gate — Verify your email to see your order history , with a resend action — never as a failure. You do not need to special-case it."},{"title":"Subscriptions","route":"/developers/subscriptions","headings":["Subscriptions","What ships in v1","What is intentionally out of v1","How the pieces fit","Tax on renewals","Physical subscriptions","Sequence: auto-subscribe on card capture","Pages in this section"],"text":"Recurring billing Subscriptions Throttle subscriptions turn a one-time embed payment into a recurring billing relationship. The card vaulted during checkout is reused on a 5-minute renewal cron, with built-in dunning and a webhook stream that lets your backend stay in sync. What ships in v1 Recurring on the embed. Pass a recurring block on a checkout session, the embed vaults the card, and (when create: auto is set) Throttle creates the subscription on your behalf. Lifecycle API. Cancel, pause, resume, change plan, list, and fetch subscriptions through @usethrottle/subscriptions/server from your backend, or through the React hooks routed via your backend proxy. Mid-period proration. A plan or seat change sent with effective: now credits the unused portion of the period the buyer already paid for, then charges the net — and skips the charge entirely when the credit covers it. The response carries a proration breakdown ( creditCents , chargedCents , fullAmount ). Send effective: period_end to schedule the change for the next renewal instead, with no charge now. React management package. @usethrottle/subscriptions exposes hooks and primitive components so your buyer-facing portal does not have to re-derive the API plumbing. Any cadence, and installment plans. Send interval for the five named intervals, or intervalUnit + intervalCount for any cadence from 1 hour to 1 year. Add totalPayments (2–60) to make it an installment plan that completes itself after N payments and emits subscription.completed . See Lifecycle and States . Built-in dunning. Retry n waits min(n × cadence, [1, 3, 7][n − 1]) days — weekly and longer cadences always land on the original [1, 3, 7] days; shorter custom cadences retry proportionally faster. The first failed attempt moves the subscription to past_due ; if all three retries also fail it auto-cancels with a dunning_exhausted reason. Trial-fraud protection. Auto-create checks the card fingerprint against prior subscriptions on this merchant; same card already used for a trial → new subscription is created without a trial and a subscription.trial_blocked webhook fires. Pre-flight via POST /api/v1/subscriptions/eligibility-check . See Trial Fraud Protection . Webhook stream. subscription.created , .updated , .activated , .renewed , .past_due , .payment_failed , .paused , .resumed , .cancelled , .completed . What is intentionally out of v1 No-card free trials. Trials always require a vaulted card up front; the embed does not yet support a subscribe now, add card later flow. Trials with a card behave the way you expect — Throttle delays the first charge until trialEnd . Net30 recurring. Renewals charge a stored card via the payment provider. Net30 is supported for one-time orders today; Net30 recurring (invoice every period) is not part of the current subscription package surface. Customer-scoped JWTs. The React package authenticates by proxying through your backend. Direct browser → Throttle calls require a customer token system that is not yet built. See Buyer Portal for the proxy pattern documented for v1. Throttle-sent emails. All notifications flow through your webhook endpoint; you decide which emails to send. Subscribing to subscription.payment_failed is enough to wire the standard your card was declined copy. Recurring discounts. Promotion codes apply to cart-backed checkout totals today. Ongoing subscription-cycle discounts are deferred. Configurable dunning curve. The retry schedule scales down automatically for cadences shorter than a week, but merchants cannot configure their own multipliers or caps. Per-merchant or per-subscription overrides are deferred. Subscriptions touch checkout, customer identity, the renewal cron, and a buyer-facing surface. Start with the Quickstart for the end-to-end flow, then read Customer Identity before you wire your buyer portal. How the pieces fit Subscribe. Your backend creates a checkout session with a recurring block, or puts recurring on cart lines, one subscription per line, up to ten. Buyer pays through <PaymentEmbed /> . Card is vaulted. Throttle auto-creates the subscriptions. onSucceeded returns subscriptionIds . Renew. A cron runs every 5 minutes. For any subscription whose currentPeriodEnd has passed, it charges the stored card with an idempotent chargeStored call and advances the period. On failure it follows the cadence-scaled [1, 3, 7] -day retry curve. Manage. Your buyer portal calls your backend, which proxies to the Throttle API. The React package gives you hooks for the common flows. React. Your webhook handler stays in sync — record renewals, send dunning emails, gate access on past_due . Tax on renewals A renewal has no cart and no shipping address, so there is nothing for a tax provider to quote against. Throttle solves that by freezing an address on the subscription at signup and reusing it on every renewal. taxAddressSnapshot — the address renewals are quoted against. A subscription created through checkout inherits the address the buyer was taxed on: the billing address when the checkout had no shipping step. Each renewal is quoted with it as both the shipping and the billing address, whichever one your tax trigger reads. The renewal order carries it as its shipping address, and also as its billing address when your trigger is billing_address , so the filed document uses it too. taxAddressSource — checkout when it came from the buyer's session, manual once a merchant has edited it. A later checkout never overwrites a manual correction. To correct it, PATCH /api/v1/subscriptions/:id with a taxAddress object ( countryCode is required; the rest are optional). Merchants can do the same from the Tax card on the subscription detail page in the dashboard. Sending null clears the snapshot, which leaves renewals untaxed. The current period was already quoted and charged, and any tax document for it has been filed. Changing the address does not re-rate or re-file that period — it takes effect the nex"},{"title":"Buyer Portal","route":"/developers/subscriptions/buyer-portal","headings":["Buyer Portal","Architecture","Next.js App Router template","Express template","Authorization","Wiring the React provider","What buyers can do","Next"],"text":"Subscriptions Buyer Portal Let buyers manage their own subscriptions. v1 uses a proxy pattern: your backend forwards requests from the browser to Throttle, holding the secret key on your side and enforcing ownership. A proxy keeps your sk_* key off the wire and lets you reuse your existing user authentication. The hook API in @usethrottle/subscriptions is designed around this production path. Architecture Buyer s browser calls /api/throttle/api/v1/subscriptions on your domain. Your backend authenticates the user, verifies they own the resource being mutated, and forwards the call to Throttle with the secret key. @usethrottle/subscriptions s SubscriptionProvider uses a custom fetcher that targets the proxy. Next.js App Router template A single catch-all route handler proxies the relevant subscription endpoints. The helper pins externalCustomerId server-side on every read, rewrites checkout-session creates to the authenticated buyer, resolves payment-method reads from the buyer's customer row, and verifies ownership before reading one subscription or changing it. Since @usethrottle/subscriptions 4.0.0 it also limits what a buyer may send, because it forwards on your secret key: Cancel takes only atPeriodEnd . A refund is refused: a buyer cannot refund themselves. PATCH on a subscription takes only shippingAddress . Plan, cadence and amount need your authorizeUpdate({ request, subscription, body, externalCustomerId }) to return true after checking them against your price list. A direct POST /api/v1/subscriptions is refused unless your authorizeCreate({ request, body, externalCustomerId }) returns true. It names its own price and charges nothing, so prefer a checkout session. Anything else a buyer sends answers 403 field_not_allowed naming the fields; it is never silently dropped. Reading another buyer's subscription answers 404 . Express template Per-route variant. Slightly more code, but easier to reason about route-by-route authorization. Authorization Throttle s API key authorizes your account to act on any subscription you own. It does not know which buyer owns which subscription. That mapping lives in your DB (or you derive it via customer.externalId === user.id ). Without ownership checks on mutations, any logged-in user could cancel any subscription by guessing IDs. Always verify ownership server-side before forwarding a mutating request. Wiring the React provider Every hook in @usethrottle/subscriptions calls through this fetcher. Your proxy is the only thing that talks to Throttle. What buyers can do List their subscriptions and see status, plan, next billing date. Pause an active subscription and resume later. Pause is not offered on a trialing subscription (the API refuses it with 400 invalid_subscription_state ); @usethrottle/auth 0.6.1 and later hide the button there. Cancel at period end (or immediately, if you allow it). Change plan, when your proxy passes authorizeUpdate and it checks the new plan and amount against your own price list (your UI decides which plans are offered, but the browser is not trusted to name the price). Move one subscription to another of the buyer's saved cards: the proxy forwards PATCH /api/v1/subscriptions/:id/payment-method for the buyer's own subscriptions ( setPaymentMethod() on the server client). See Change the card . Change where a physical subscription ships. On the storefront plane that is PATCH /v1/storefront/me/subscriptions/:id/shipping-address (step-up gated), updateSubscriptionShippingAddress() in @usethrottle/auth , and a Change address button in its <SubscriptionsPanel /> . Surface a past_due subscription with an update-card action. The <DunningBanner /> primitive gives you the UI hook; your backend owns the actual card-refresh flow. An installment plan ( totalPayments set) shows its cadence and payment progress — installmentProgress() from @usethrottle/subscriptions gives you paymentsMade , totalPayments , and the remaining amount — but your proxy must hide Cancel, Pause, and Resume for it and refuse to forward those calls. Throttle's subscription API does not itself refuse a pause/resume/cancel called with your merchant secret key. The @usethrottle/subscriptions proxy now refuses every buyer mutation of an installment plan by default ( 403 installment_plan_merchant_only ), except changing its card ( PATCH …/payment-method ), which is not one of the plan's locked terms; if you pass your own authorizeMutation , the default is replaced and your function must check totalPayments != null itself. (Throttle's separate customer-session storefront endpoints, documented under Storefront Auth , enforce this server-side and return 403 installment_plan_merchant_only for a buyer session.) Next React Package — full hook reference."},{"title":"Creating Subscriptions","route":"/developers/subscriptions/creating-subscriptions","headings":["Creating Subscriptions","Pattern A — Server auto-creates (default)","Pattern B — Merchant backend creates explicitly","Pattern C — Webhook-driven creation","Choosing","Direct creation (no embed)","Free and usage-only plans (amount 0)","Linking the order that paid for the first period","Custom cadence and installment plans","Next"],"text":"Subscriptions Creating Subscriptions Three patterns map to three integration trade-offs. Pick the one that fits how much control you want over the create call. currency is optional and defaults to the store's main currency; see Multi-currency for the rule and its error codes. The plan , planName , amount , and interval values you pass on the recurring block come from your application — not from Throttle. We treat planReference as opaque text scoped to your merchant and never validate it against any list. Two workspaces can both have a pro_monthly reference without collision. Most teams keep plans in their own database (a plans table or a static config in the repo). That source-of-truth is what your storefront pulls from to render the plan picker, and what your backend forwards into the recurring block. A managed plan catalog inside Throttle is not part of v1. Pattern A — Server auto-creates (default) Set recurring.create: auto (the default) and Throttle creates the subscription atomically when the card is vaulted. Your onSucceeded callback fires with subscriptionIds (and subscriptionId when exactly one was created). Use this when you want the path of least resistance. The merchant writes zero code on the success path — vault, sub create, and notification all happen on Throttle s side. Throttle treats sub-create-after-vault as a single transactional unit. If the sub create fails, the vault row is still written (the buyer s card is on file), but no subscription exists. Throttle emits subscription.create_failed with a reason code. Listen for it and decide your fallback: retry from your own backend, contact support, or surface the error to the buyer. A checkout creates all of its subscriptions together or none of them. If any create fails, none exist, the order still stands (the money was captured), and one subscription.create_failed fires per subscription line, carrying its lineItemId . Pattern B — Merchant backend creates explicitly Set recurring.create: manual when you want full control. The embed only vaults; you call subscriptions.create yourself. Use this when you need to look up plan details from your own DB, run business logic before subscribing, or attach merchant-specific metadata that the embed cannot know about. Pattern C — Webhook-driven creation Use the payment.vaulted webhook. Survives a buyer closing their tab between vault and create. Recommended when reliability matters more than latency. Combine with create: manual on the embed: auto-mode and webhook-driven creation both racing to call create would double-bill (Throttle de-dupes, but it s still cleaner to pick one). Choosing subscriptions.create with a pre-vaulted payment method ID , ] ] /> Direct creation (no embed) For B2B flows where you ve already collected a card via another mechanism (a sales rep enters it through your admin tool), call subscriptions.create directly. A subscription created this way has no card of its own ( paymentMethodId: null ), so it renews on the customer's default card. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the renewal cron has nothing to charge. Subscriptions Throttle creates from a checkout store the card the buyer paid with instead; see Which card a renewal charges . Free and usage-only plans (amount 0) A subscription at amount: 0 renews every period for nothing, so Throttle treats 0 as a mistake unless you say otherwise. POST /api/v1/subscriptions , PATCH /api/v1/subscriptions/:id and POST /api/v1/subscriptions/:id/change-plan refuse amount: 0 with 400 invalid_amount unless the same request sends allowZeroAmount: true . Send it for a free tier or a plan billed only through usage. The flag applies to that one request and is not stored; subscriptions already at 0 keep renewing. A $0 subscription created without an orderId gets no invoice for its first period, the same as a $0 renewal : GET /api/v1/subscriptions/:id/invoices stays empty until a period costs money. With an orderId the first period keeps its $0 invoice, linked to that order. subscription.created fires either way. Checkout cannot start a $0 subscription. A session recurring.amount is the renewal price and must be above 0 when sent ( 400 invalid_amount ), and so must a cart subscription line's unitPrice ( 400 invalid_line_item ). For a free trial, send the price the plan renews at and set trialDays . Linking the order that paid for the first period When Throttle creates the subscription (Pattern A) it links the signup order itself. When you charge the first period and then create the subscription (Pattern B, Pattern C, or a proxy checkout you completed yourself), nothing connects the two — so that order shows no subscription, and the subscription's Orders tab is missing its own signup charge. Pass orderId to fix it: Throttle writes the link both ways: the order gets a subscriptionId of its own (and keeps carrying the legacy metadata.subscription_id pointer), and the subscription's first invoice records the orderId that collected it. So the order links to the subscription and GET /v1/orders?subscriptionId=... returns it. The order must exist in the same application and environment; if it does not, the call returns 404 and no subscription is created. Renewals need no orderId : the renewal cron creates those orders and stamps them itself. An order Throttle created with several subscriptions lists them in subscriptionIds ; subscriptionId is set only when there is exactly one. Passing such an order as orderId — one whose lines already link other subscriptions — creates the subscription but leaves the order's links unchanged, so the order never appears to have only the new one. Custom cadence and installment plans Every create path above accepts a cadence beyond the five named intervals, and an optional fixed number of payments: interval (named), or intervalUnit + intervalCount for any cadence from 1 hour to 1 year, or both together (they"},{"title":"Custom Cadence","route":"/developers/subscriptions/custom-cadence","headings":["Custom Cadence","Named and custom cadences","Validation","Labels","How renewal dates are computed","Month-end clamping","Hour and day cadences","When the renewal job falls behind","Pause and resume","Trials","Changing the cadence","End to end","Checkout: every 10 days","Direct: every 6 hours","Reading custom cadences","TypeScript SDK","React","Errors","Edge cases","Is "every 4 weeks" the same as monthly?","How is a custom cadence valued for MRR?","What time of day does a plan renew?","My Jan 31 subscription bills on the 3rd. Why?","Does a slow renewal-job run shift an hourly plan?","Can a buyer choose the cadence at checkout?","Next","→ { \"data\": { \"id\": \"c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21\", ... }, \"meta\": { ... } }","2. A checkout session that bills 49.00 USD every 10 days.","Then create the session with no \"recurring\" block: the line carries it.`;","it records the period you pass as already paid, and the renewal job bills","every period after it. The customer needs a default vaulted card.","→ data.pendingInterval \"biweekly\", pendingIntervalUnit \"week\", pendingIntervalCount 2,","pendingAmount 9900. subscription.plan_change_scheduled fires.`;"],"text":"Subscriptions Custom Cadence A subscription can bill every N hours, days, weeks, or months — every 6 hours, every 10 days, every 3 weeks, every 2 months — not only on the five named intervals. This page covers what you can send, what Throttle stores, how it labels a cadence, and how it computes each renewal date. Named and custom cadences Every subscription stores its cadence as a unit and a count: intervalUnit ( hour , day , week , or month ) and intervalCount (how many). The interval field names the cadence when it is one of the five named intervals, and is "custom" for everything else. The named intervals are shorthand: You send one of three shapes, and every response carries all three fields: The server reduces what you send to its largest exact unit, then names it when a named interval matches. Hours become days when the count is a multiple of 24, and days become weeks when it is a multiple of 7. Weeks never become months and months never become anything else, because a month is not a fixed length. So every 24 hours is stored as custom / day / 1 , every 14 days as biweekly , and every 4 weeks as custom / week / 4 (28 days — never monthly ). Each cadence has exactly one stored form, so filtering by interval=biweekly also finds a subscription you created as "every 14 days". A cadence is accepted in five places, with the same rules in each: POST /api/v1/subscriptions — required: send a named interval or a unit and count. recurring on POST /api/v1/checkout/sessions — checked when the session is created, so a bad cadence fails for you, not for a buyer at the end of checkout. recurring on a cart line ( POST /api/v1/carts/{id}/items ) or a quote line — checked when the line is added. Each such line becomes its own subscription; see Mixed Carts . POST /api/v1/subscriptions/{id}/change-plan — required, as on create. PATCH /api/v1/subscriptions/{id} — optional; a term-only edit (see Changing the cadence ). Validation The shortest cadence is 1 hour and the longest is one year, measured in the unit you sent: at most 8,760 hours, 365 days, 52 weeks, or 12 months. The bound is checked before the reduction, so 8,784 hours (366 days) is refused even though it would reduce to days. Every message below is the exact error.message . invalid_interval — A cadence can be at most one year: 8760 hours ], [ code: intervalUnit: day , intervalCount: 366 , html: 400 invalid_interval — A cadence can be at most one year: 365 days ], [ code: intervalUnit: week , intervalCount: 53 , html: 400 invalid_interval — A cadence can be at most one year: 52 weeks ], [ code: intervalUnit: month , intervalCount: 13 , html: 400 invalid_interval — A cadence can be at most one year: 12 months ], [ code: intervalCount: 0, -1, or 1.5 , html: 400 invalid_interval — intervalCount must be a whole number of at least 1 ], [ code: intervalUnit: minute or year , html: 400 invalid_interval — intervalUnit must be one of hour, day, week, month ], [ code: interval: daily or fortnightly , html: 400 invalid_interval — Unknown interval "daily" . There is no named daily interval; send day × 1 . ], [ code: intervalUnit without intervalCount, or the reverse , html: 400 invalid_interval — intervalUnit and intervalCount must be sent together ], [ code: interval: custom alone , html: 400 invalid_interval — A custom interval needs intervalUnit and intervalCount ], [ No cadence field at all (create, change-plan) , html: 400 invalid_interval — Send an interval, or intervalUnit and intervalCount ], [ code: interval: monthly , intervalUnit: week , intervalCount: 4 , html: 400 interval_conflict — interval "monthly" does not match every 4 weeks ], [ code: intervalCount: 3 (a string) , html: 400 validation_error — the body schema requires a JSON number ], ] /> Errors use the standard envelope: Labels @usethrottle/subscriptions exports two helpers so every surface words a cadence the same way. cadenceLabel is a standalone label for an Interval column; cadenceNoun is the period that reads correctly after a slash, as in $49.00 / 10 days . The table shows what the server stores for each input and what the helpers return for that stored row. The helpers read the fields as given; they do not reduce them. Called on a Throttle response they always match the table above. Called on raw input, cadenceLabel({ interval: custom , intervalUnit: day , intervalCount: 14 }) returns "Every 14 days", while the server stores and returns that cadence as biweekly . A value the helpers do not recognise is returned unchanged rather than dropped. How renewal dates are computed Renewal dates sit on a grid measured from billingAnchorAt , which every subscription response carries. Each period end is computed from the anchor, never chained from the previous period, so a rounding or clamping step can never compound. Hour, day, and week cadences advance by exact UTC durations: 3,600 s, 86,400 s, or 604,800 s times the count. Every 10 days is always exactly 240 hours. Month cadences land on the anchor's day of month and UTC time of day, in the month anchor month + k × count , with the day clamped to that month's last day. See Month-end clamping . The first period. A subscription created by checkout starts when the buyer pays and its first period is exactly one cadence long. On POST /api/v1/subscriptions you pass currentPeriodStart and currentPeriodEnd ; billingAnchorAt is set to currentPeriodStart (or to trialEnd when there is a trial). currentPeriodEnd must be after currentPeriodStart and in the future, or the create is 400 validation_error . The renewal job picks up a subscription once its currentPeriodEnd has passed and decides which period the charge opens: Billing restarts when a trial converts, when a failed payment is recovered, and when a scheduled change of cadence applies. Everything else is an ordinary renewal. The anchor stays where it is except in these cases: effective: now ) , "},{"title":"Customer Identity","route":"/developers/subscriptions/customer-identity","headings":["Customer Identity","Pattern A — externalCustomerId (recommended)","Pattern B — store Throttle's customerId","Pattern C — email-only","Keeping your DB in sync via webhooks","Looking up a customer by your own ID","What changes in v1","Next"],"text":"Subscriptions Customer Identity Subscriptions are scoped to customers. Throttle gives you three patterns for identifying a customer; the recommended one means you never store Throttle s IDs. Use Pattern A — externalCustomerId . Pass your own user ID everywhere. Throttle resolves it on every call. No new column on your users table. Pattern A — externalCustomerId (recommended) You give Throttle your own user ID at checkout time. Throttle upserts a customer record keyed on (workspaceId, externalId) . Subsequent checkouts and API calls reuse the same record automatically. No schema change in your DB. Idempotent — calling create twice for the same external ID does not duplicate. Your code reads naturally; user.id is the identifier everywhere. Pattern B — store Throttle s customerId Create the Throttle customer explicitly and persist the returned cus_xyz ID in your user table. Slightly faster on hot paths because Throttle skips the upsert lookup, but you own a new column. Best when you call subscription endpoints often and want to skip the resolve step. Adds one column to your DB ( users.throttleCustomerId ). Requires you to handle the user exists in your DB but not yet in Throttle case at signup time. Pattern C — email-only If your storefront accepts guest subscriptions (no merchant-side user account yet), you can pass customer: email alone. Throttle upserts on (workspaceId, email) . Works for the create flow, but management is awkward — you need to look up by email to act on the subscription later, and the buyer cannot easily prove their identity from a self-service portal. Migrate guests to Pattern A as soon as they create an account. Any checkout with a subscription line needs a customer: customer.email , customer.externalCustomerId , customer.customerId or customerEmail on the session, or session creation answers 422 recurring_customer_required . An accepted quote uses the email of the buyer who accepted it. Keeping your DB in sync via webhooks The customer.created event fires whenever a new customer record is created — directly via API, indirectly via checkout resolution, or anywhere else. If you want to mirror Throttle s customer IDs into your DB without explicit creation calls (Pattern A → Pattern B without extra round trips), subscribe to the event. Looking up a customer by your own ID New endpoint: GET /api/v1/customers/by-external/:externalId returns the canonical customer record (or 404). Use this when you need to fetch the Throttle row but only have your own user ID. What changes in v1 GET /api/v1/subscriptions accepts an externalCustomerId query parameter (mutually exclusive with customerId ). GET /api/v1/customers/by-external/:externalId is new. Pass your own ID, get the Throttle record back. The @usethrottle/subscriptions hooks accept either customerId or externalCustomerId as a scope. Next Buyer Portal — how the proxy pattern uses externalCustomerId end-to-end. React Package — hooks accept both ID forms."},{"title":"Installment Plans","route":"/developers/subscriptions/installment-plans","headings":["Installment Plans","How it works","Lifecycle","A 4-payment plan, start to finish","Counting payments","Creating one","Hosted or embedded checkout","A cart with goods and an installment plan","Directly, with the API","Quotes","Custom schedules","Gap units","When each payment is due","Tax, shipping, orders","Discount codes","What is locked","Reading the schedule","Schedule errors","Reading them","What you can change","Merchant API (secret key)","Creation rules","Buyer surfaces","After completion","Failed installments","Cancelling and refunding","Webhooks and emails","SDK and React","Errors","FAQ","Can a buyer pay the rest early?","Can I turn an ongoing subscription into an installment plan, or the reverse?","What if the renewal job was down for several periods?","Is each installment taxed?","Does pausing extend the plan?","Can a custom schedule have a free trial or an equal-payment count?","Can I show "Payment 2 of 4" myself?","What does an older SDK do with a completed plan?","Next","→ { \"data\": { \"id\": \"c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21\", ... }, \"meta\": { ... } }"],"text":"Subscriptions Installment Plans An installment plan is a subscription with a fixed number of payments — "4 payments of $100 every 15 days". It bills like any subscription, then stops by itself: the moment the last payment succeeds it becomes completed and is never charged again. How it works Set totalPayments (a whole number from 2 to 60) when you create the subscription. It cannot be added, changed, or removed later. The plan uses any cadence, named or custom — see Custom Cadence . The signup charge is payment 1 of N. paymentsMade counts the payments collected so far. When payment N succeeds the status becomes completed , completedAt is set, no further renewal is scheduled, subscription.completed fires, and the buyer gets a paid-in-full email. completed is terminal. The amount, cadence, quantity, and plan are fixed. There is no trial and no metered usage. Only you can pause, resume, or cancel it. The buyer cannot, from Throttle's buyer portal or its storefront API. If your app grants access while status === active , add completed . A completed plan's currentPeriodEnd is the end of the period the final payment covered; whether access continues after it is your decision. Lifecycle A merchant can cancel from active , paused , or past_due . A past-due installment is recovered by a retry, a buyer card update, or a waive; the fourth failed attempt cancels the plan. A plan created by a session intent on a quote or deposit-split cart starts without payment 1: see Counting payments . Everything else about active , past_due , paused , and cancelled is the same as for an ongoing subscription; see Lifecycle and States . A 4-payment plan, start to finish Four payments of 100.00 USD every 15 days, bought on October 1 at 09:00 UTC: The plan completes on November 15, when payment 4 succeeds — not on November 30, when the period it pays for ends. Nothing is charged on November 30. Counting payments POST /api/v1/subscriptions , html: Starts at 1 — the route books period 1 as paid ], [ Checkout with a session recurring intent on a quote or deposit-split cart, whose charge does not include the plan , Starts at 0; the first renewal is payment 1, so all N payments come from renewals ], [ A renewal charge succeeds (the renewal job, a merchant retry, or the buyer paying a past-due installment) , +1 ], [ A past-due period is waived , +1 — a waived installment counts, and a waived final installment completes the plan ], [ A charge is declined , Unchanged ], [ An installment is refunded , Unchanged ], [ Periods skipped because the renewal job fell behind , Never counted ], [ The plan is paused , Waits — nothing is charged or counted while paused ], ] /> installmentProgress in @usethrottle/subscriptions turns those fields into progress. remainingCents is remainingPayments × amount × quantity on an equal plan. Pass the subscription itself (it carries paymentSchedule ) and on a custom schedule it is the sum of the unpaid payments' net amounts instead. Both are before tax. The buyer sees the schedule before paying. The hosted checkout page reads "Pay 100.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 400.00 USD." — with "plus applicable tax" added when the application taxes renewals, and without "Cancel anytime". With a discount code, only the first charge is discounted: "Pay 80.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 380.00 USD." When the charge today does not include the plan it reads "Then 4 payments of 100.00 USD every 15 days, starting at the next billing date. Total: 400.00 USD." Creating one Hosted or embedded checkout Put totalPayments on the session's recurring block. With the default create: auto , Throttle charges payment 1 and creates the plan when the buyer pays; the embed's onSucceeded receives subscriptionIds (and subscriptionId , set when exactly one subscription was created). The session is validated when you create it: a bad totalPayments or a trial fails then, not at the end of checkout. With create: manual , checkout takes payment 1 and vaults the card, and your backend creates the plan. Pass the checkout's orderId so the order and the plan point at each other: A cart with goods and an installment plan Put the plan on a cart line: recurring with totalPayments on POST /api/v1/carts/{id}/items , with the installment amount as the line's unitPrice . Leave recurring off the session. The first charge covers the goods plus installment 1 ( paymentsMade: 1 ); renewals charge the installment only. Each such line becomes its own subscription, so one checkout can sell several plans, up to ten. See Mixed Carts for pricing, shipping, and refunds on that shape. The session recurring block still works for a single plan on a cart of goods. It needs amount whenever the cart holds other lines ( 422 recurring_amount_required ), and it cannot be combined with subscription lines on the same cart ( 422 recurring_source_conflict ). A cart or order line item's recurring.count is read-only display data ("· 4 payments"): it mirrors the line's totalPayments , and the cart item endpoints do not accept it on input. On a line, recurring.totalPayments is the installment count. To cap the number of charges, send totalPayments in a cart or quote line's recurring block, on the session's recurring block, or on POST /api/v1/subscriptions . Directly, with the API The route records period 1 as a paid invoice and sets paymentsMade: 1 , on the basis that you already collected it. If you did not, the buyer is charged only N − 1 times. Charge the signup first, pass its orderId when an order records it, and make sure the customer has a default vaulted card — the renewal job charges that card. Quotes A quote line can carry the same recurring block, with totalPayments . Paying the accepted quote takes installment 1 in the same capture as the rest of the q"},{"title":"Lifecycle and States","route":"/developers/subscriptions/lifecycle-and-states","headings":["Lifecycle and States","Billing intervals","Installment plans","States","State diagram","Trial behavior","Renewal","Which card a renewal charges","Dunning","When a renewal can't complete","Renewal reminder","Grace period — your call, not Throttle's","Plan changes","Upgrades — effective: "now"","Downgrades — effective: "period_end"","Cancelling a pending change","Precedence vs. cancel-at-period-end","Credits and refunds","Next"],"text":"Subscriptions Lifecycle and States How a subscription moves through its life. Understand the state machine, the renewal cron, and the dunning policy before you wire your webhook handlers. Billing intervals Throttle bills on any cadence from 1 hour to 1 year, not only the five named intervals ( weekly , biweekly , monthly , quarterly , yearly ). Send a named interval , or intervalUnit ( hour , day , week , or month ) + intervalCount , on the checkout session's recurring block, on a cart or quote line's recurring block, on POST /api/v1/subscriptions , or on change-plan . The server stores one canonical form — every 14 days comes back as biweekly , every 24 hours as custom / day / 1 — and every response carries interval (the name or "custom" ), intervalUnit , and intervalCount . An invalid cadence is 400 invalid_interval ; a named interval that disagrees with the unit and count is 400 interval_conflict . Periods are anchored, not chained. Each period end is computed from billingAnchorAt . Month cadences keep the anchor's day of month, clamped to short months: a Jan-31 monthly subscription renews Feb 28 (29 in a leap year), then Mar 31. Hour, day, and week cadences advance by exact durations. The anchor moves when billing restarts — trial conversion, a recovered payment, an immediate plan change or a scheduled cadence change, or a resume, which shifts it by the time spent paused. biweekly means every 14 days — 26 charges a year, not twice a month. Mixed-interval pricing (e.g., Pro Monthly + Pro Yearly with a 20% discount on yearly) is your job — you store the two plans in your own catalog and pass the right amount / interval to Throttle. We don't model plan tiers; see Creating Subscriptions for why. Plan changes can swap the cadence via POST /api/v1/subscriptions/:id/change-plan (rejected on an installment plan). An outage or a stalled retry can leave a subscription more than one period behind. Throttle charges it once, for whichever grid period contains the moment of the charge — the periods it missed are skipped, never billed back-to-back, and never counted toward an installment plan's totalPayments . Full guide: Custom Cadence → — every validation rule, the label helpers, the renewal-date algorithm, month-end clamping, hour cadences, trials, and cadence changes. Installment plans Set totalPayments (2–60) on the checkout session's recurring block, on a cart or quote line's recurring block, or on POST /api/v1/subscriptions to make a subscription an installment plan — a fixed number of payments that ends itself. The paid signup is payment 1 of N, and the plan becomes completed the moment payment N succeeds, emitting subscription.completed and a paid-in-full email. Merchants who gate access on status = active must treat completed as paid in full. totalPayments is set at creation only ( 400 total_payments_immutable ), an installment plan cannot have a trial ( 400 invalid_combination ), and its amount, cadence, quantity, and plan are fixed ( 409 installment_plan_locked ). Only the merchant can pause, resume, or cancel it; buyer storefront sessions get 403 installment_plan_merchant_only . Payments can also differ: a custom schedule gives each payment its own amount and gap. Its later due dates are estimates: a late payment that succeeds, a pause, or a waive moves them, and nothing is skipped. A manual period renew of a scheduled plan is refused ( 409 installment_plan_locked ). Full guide: Installment Plans → — how payments are counted, the lock and completed-state matrices, failed installments, cancelling and refunding, webhooks, and SDK examples. States trialing — Created with trialEnd in the future. No charge has been attempted. Card is already vaulted. active — Most subscriptions live here. Renews on the period boundary. paused — Manually paused. Skipped by the renewal cron. Can resume back to active. past_due — A renewal charge failed. The subscription moves here on the first failed attempt and stays while Throttle retries (up to three retries; see Dunning below). While it is past due, currentPeriodEnd holds the time of the next scheduled retry, not the date the period was due. The buyer's renewal-failed email carries an Update your card button to a Throttle-hosted page ( checkout.usethrottle.dev/billing/{token} ) where they save a new card and the past-due period is charged on the spot; a successful charge returns the subscription to active and emits subscription.renewed like any other renewal. The link is signed for that customer and subscription and valid for 14 days. See Payment Methods → Subscription self-service for the routes behind it. cancelled — Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop. completed — Terminal. Reached only from active , when an installment plan's final payment succeeds. Renewals stop; pause, resume, cancel, and plan changes are rejected like cancelled . See Installment plans above. unpaid — Accepted by the status schema and the published SDKs for forward compatibility. The customer-facing renewal engine documented on this page does not transition a subscription to it today. State diagram Trial behavior Trials always require a vaulted card. The embed collects and vaults the card at signup; the cron simply waits until trialEnd passes before attempting the first charge. On trialEnd , the cron flips the row from trialing to active and emits subscription.activated . The first paid period begins immediately after — no second tick is needed. If the buyer cancels during the trial, the subscription transitions directly to cancelled with no charge. Throttle does not yet support trials with no card on file. The card-up-front model is simpler and avoids a second flow to attach a card before trial end. Throttle automatically blocks the same physical card from claiming multiple trials on a single merchant. Checkout checks the card's fingerprint against prior su"},{"title":"Managing Subscriptions","route":"/developers/subscriptions/managing-subscriptions","headings":["Managing Subscriptions","List and filter","Cancel","Immediate cancellation","Cancel at period end","Refund a billing cycle","Pause and resume","Change the card","Change where a physical subscription ships","Change plan","Immediate change","How the credit is worked out","Scheduled change","Cancelling a pending change","Seats and quantity","From the React package","Audit log","Never change a plan through checkout","Handle the 402","Next"],"text":"Subscriptions Managing Subscriptions Pause, resume, cancel, change plans, and change the card. Same primitives whether you re calling from your backend, the dashboard, or the React package. List and filter GET /api/v1/subscriptions returns cursor-paginated results. Filter by status , interval , customerId , or externalCustomerId . Use q to search subscription ids, customer ids, plan fields, status, interval, or metadata. Cancel Two flavors. Pick based on your buyer experience. The backend examples use @usethrottle/subscriptions/server with your secret key. Immediate cancellation Use when the buyer wants to stop right now. Because this takes away access the buyer may have already paid for, it is the one cancellation that can also move money: refund accepts none (default), prorated , or last_cycle . The refund is issued before the subscription is cancelled, so a payment failure leaves the subscription untouched rather than cancelling it with the money stranded. refund is rejected together with atPeriodEnd: true ( 400 validation_error ): at period end the buyer keeps the period they paid for, so there is nothing to give back. A prorated cancel on a period nothing was paid for (a waived period, say) still cancels, with a refund of 0 . A paused or past-due subscription is refunded the unused paid time from when it was paused (paused) or from now (past due). The refund is always capped at the cash paid for the period; if a period was paid but no unused time is left, the call is 422 invalid_state and nothing is cancelled. Asking for a refund on a subscription that is already cancelled or completed returns 409 invalid_state and refunds nothing. Cancel at period end Common pattern: the buyer keeps access until the end of what they paid for. Throttle sets cancelAtPeriodEnd: true . The renewal cron sees it on the next tick after the period ends and finalizes the cancellation. To restore a subscription that was scheduled to cancel, call POST /api/v1/subscriptions/:id/resume (it clears cancelAtPeriodEnd ; PATCH does not accept that field). (Calling cancel again with atPeriodEnd: false does not undo it — that cancels immediately.) After actual cancellation the subscription is terminal — create a new one. Cancelling, either way, also clears any pending plan or seat change on the subscription. Refund a billing cycle Every subscription charge produces a subscription invoice , and each one can be refunded on its own — the signup cycle, a renewal, or a plan-change charge. Refunding a cycle is a money operation only: it does not stop billing unless you say so. intent is required and has two values: money_only — the money goes back and the subscription keeps running. The next renewal charges as normal. refund_and_cancel — the money goes back and the subscription is cancelled immediately, in that order. Omit amount to refund everything still refundable on the cycle. Partial refunds accumulate: the invoice moves to partially_refunded , then refunded once the whole amount is back. A cycle that is already fully refunded, unpaid, failed, or $0 (waived or free) is rejected with 422 . A refund with money_only leaves an active subscription active — the renewal cron will charge again on schedule. If the buyer is leaving, use refund_and_cancel (or cancel separately). The same is true of refunding the cycle s order from the Orders API: the money goes back and the cycle is marked refunded, but the subscription keeps billing. The same holds for a checkout that created several subscriptions: no refund cancels the order or any of them, and an order refund that names no cycle comes out of the goods and shipping first, then across the cycles in proportion to what each still has refundable. Refunds are placed in the order they happened, each against what was left at that moment. Refunding through either surface reaches the same numbers. The amount refunded on a cycle is derived from the payment s own transaction ledger, so a refund taken on the order that collected the cycle updates the cycle and its invoice too — and the refundable amount the next call is offered already has it subtracted. When the order billed more than the cycle — a one-time setup fee charged alongside the first period — a refund pays down the non-recurring part first, and only what is left over reaches the cycle. Refunding a $40 setup fee on a $65 order that also carried a $25 first period leaves the cycle fully refundable; refunding the remaining $25 then closes it. Refunds carry no line-item attribution, so this is the direction that keeps a one-time refund from locking you out of refunding the subscription itself. Both intents emit subscription.invoice_refunded , whose payload carries the intent so your systems can tell a goodwill refund from a cancellation. refund_and_cancel additionally emits subscription.cancelled . Requires the payment_refunds:write scope. Pause and resume Pausing an active subscription transitions it to paused . The renewal cron skips paused rows entirely — no charge attempt, no dunning. Resuming returns it to active and pushes currentPeriodEnd forward by however long the subscription was paused, so the buyer gets back the days billing was stopped instead of being charged for them. Guards: pause requires status active . Any other status (including trialing , which has nothing billed to pause) is 400 invalid_subscription_state . resume on a paused subscription always unpauses it, whether or not it also has cancelAtPeriodEnd: true — the scheduled cancellation is left exactly as it was. On a subscription that is not paused, resume clears a scheduled cancellation ( cancelAtPeriodEnd: true ) in any non-cancelled status ( active , trialing , past_due ) and leaves the status alone; on an active subscription with nothing scheduled it is a no-op that returns the subscription. For cancelled and completed it is 400 invalid_subscription_state . A paused subscription that also has cancelAtPeriodEnd: true is still cancelled at its stored curren"},{"title":"Mixed Carts","route":"/developers/subscriptions/mixed-carts","headings":["Mixed Carts","Selling one","Starting checkout from an existing cart","What the buyer is charged","What you get back","The same plan twice, and seats","Shipping and the address step","Trials","Discounts","Invoices and emails","Refunds never stop billing","Cancelling","The session intent, for one plan","Errors","Limits","Next","2. Each line with a recurring block becomes ONE subscription.","3. The one-time goods, as usual."],"text":"Subscriptions Mixed Carts One cart can hold any number of one-time lines and up to ten subscriptions — the same plan or different ones, each on its own cadence, installment plan or trial. The buyer pays once, shipping is charged once, and the checkout lands on one order linked to every subscription it created. Selling one Put a recurring block on each cart line that should become a subscription. Leave recurring off the checkout session: the lines carry it. Throttle validates every subscription line when the session is created, so the total on the hosted page, the amount authorized in the embed and the amount settled are the same number. The same recurring block works on the Cart API , the browser cart session (whose hosted cart page shows each line s terms) and quotes . Starting checkout from an existing cart A checkout session can also be created from a cart that already exists — Throttle's hosted cart page ( /cart/<id> ), an abandoned-cart recovery link, or a plain cartId hand-off. These hand-offs carry no recurring block of their own, so Throttle recovers the plan's recurring intent from the cart before anything is charged: It reuses the intent of an earlier session on the same cart for the same plan, keeping the merchant's exact fields and create mode; or, if there is none, it rebuilds the intent from the cart's plan line (its plan reference, cadence, amount, and trial days). If the earlier session's cadence disagrees with the plan line the buyer sees and pays, the plan line wins. The shipping step is kept. The session inherits the earlier session's address collection; with none to inherit, it collects a shipping address whenever the cart holds any line besides the plan line. The cart-based default described in Shipping and the address step does not apply to a hand-off. A recurring session needs a buyer identity. A hand-off that supplies no email inherits the earlier session's. If there is none to inherit and the request names no customer.email , customer.customerId , customer.externalCustomerId , or customerEmail , it is refused with 422 recurring_customer_required ("Recurring checkout sessions require a customer email or customer identifier"). If the terms cannot be determined (the plan line has no plan reference or no valid cadence), the session is refused with 422 subscription_terms_undeterminable before the buyer is charged, so a buyer is never charged for a plan whose subscription would not be created. Fix the cart's plan line, or create the session with an explicit recurring block. This applies to native Throttle carts only, and only to the plan line Throttle writes for a recurring checkout ( type: "subscription" with metadata.subscriptionLineItem: true ). A line that carries its own recurring.plan brings its own terms and needs no session intent. A cart that holds both kinds (a legacy plan line and lines with their own recurring.plan ) is refused with 422 recurring_source_conflict , whatever the line order. What the buyer is charged What you get back subscriptionIds lists every subscription on the order, in line order, and each line s subscriptionId names the one it created. subscriptionId is kept for integrations written before this: it is set when the order has exactly one subscription and is null otherwise, so read subscriptionIds . The completion response, the embed s onSucceeded , and the order and payment webhooks carry the same pair. The ?subscriptionId= filter matches an order through any of its lines. The same plan twice, and seats Every subscription line is its own subscription. Two lines with the same plan make two independent subscriptions, which cancel, refund and renew separately. Nothing is merged, not even when a guest cart is merged into a signed-in buyer s cart. For several seats on one subscription, send one line with a higher quantity : quantity is seats, and each renewal bills unit price × seats. Shipping and the address step Subscription lines are never charged shipping. They are left out of weight, item-count and subtotal rules, so a free-shipping threshold is measured on the goods alone, and requiresShipping: true on a subscription line is rejected. A subscription line ships only when it says so with deliveryMethod: shipment : a physical subscription that ships a box every cycle, still with no shipping charge. A type: subscription line with no recurring never becomes a subscription, so it ships by the usual rules. Omit collect and Throttle decides from the cart: on a checkout with subscriptions, the address step is on when the cart holds at least one shippable one-time line or a physical subscription line, and off otherwise; any other checkout keeps asking. A physical subscription line refuses an explicit collect.shippingAddress: false with 400 shipping_address_required . A collect object that leaves out shippingAddress sets it to true . An explicit value always wins. A checkout with no shipping step still needs an address to tax when your application uses a tax provider on the strict fallback policy. In that case an omitted collect.billingAddress defaults to true , and tax is calculated from the billing address. See collection flags . Trials A free-trial line is charged nothing today. Throttle stores it at a unit price of 0 and keeps your price on recurring.amount for the renewals, the same way it stores the plan line for a trial on the session intent. A free-trial line cannot carry taxAmount or discountAmount . Trial eligibility is checked once per checkout, against the card s history before this checkout, and the answer applies to every trial line in it. When it is blocked, the whole checkout is refused with 409 trial_not_available before any order exists: no subscription gets a free first period, nothing is charged, and one subscription.trial_blocked fires with subscriptionId: null . The buyer can pay with another card, or you can offer the plan without a trial. Discounts A discount "},{"title":"Subscribe a Buyer in Five Minutes","route":"/developers/subscriptions/quickstart","headings":["Subscribe a Buyer in Five Minutes","Flow","1. Backend route — create the session","2. Frontend — render the embed","3. Webhook handler — react to lifecycle events","Next"],"text":"Subscriptions · Quickstart Subscribe a Buyer in Five Minutes End-to-end recurring billing with the Throttle embed. Three files: backend session route, frontend embed, webhook handler. You have a Throttle workspace, a secret key for your backend, a configured application, and an allowed checkout origin. If you don't, run through the payment quickstart first. Flow 1. Backend route — create the session The recurring block is the only difference from a one-time payment session. Throttle stores it on the session metadata and uses it to create the subscription when the embed reports a successful vault. 2. Frontend — render the embed The recurring prop on the embed is optional; when omitted, the session s recurring metadata still drives the backend. Pass it explicitly only if you want type-safe disambiguation in your client code. 3. Webhook handler — react to lifecycle events Webhooks are the source of truth for state. The embed callback is convenient but not durable — a buyer who closes their tab still triggers webhooks. Set recurring.create: manual if you d rather call subscriptions.create from your own backend after vault success. See Creating Subscriptions for the three patterns. recurring.interval: monthly above is the common case. Send intervalUnit + intervalCount instead (or as well) for any cadence from 1 hour to 1 year, and totalPayments (2–60) to make it an installment plan that ends itself after N payments — no trial allowed together with it. See Lifecycle and States for the bounds and Creating Subscriptions for the installment rules. Next Customer Identity — why externalCustomerId beats storing Throttle s customer ID. Lifecycle and States — what happens between renewals and what dunning looks like. Buyer Portal — give buyers a way to cancel without emailing support."},{"title":"React Package · @usethrottle/subscriptions","route":"/developers/subscriptions/react-package","headings":["{sub.planName}","React Package · @usethrottle/subscriptions","Install","Backend proxy","Provider","Server-side client","Hooks · queries","useSubscriptions(filters)","useSubscription(id)","useCustomerPaymentMethods(customerId)","Hooks · mutations","useCancelSubscription()","usePauseSubscription() / useResumeSubscription()","useChangePlan()","useCreateCheckoutSession()","useTrialEligibility()","Custom cadence and installment plans","Primitive components","Boundaries"],"text":"Subscriptions React Package · @usethrottle/subscriptions Production React hooks, server proxy helpers, and primitive components for managing Throttle subscriptions end-to-end from your buyer portal. Browser code should use the provider and hooks through your backend proxy. Backend code can import createSubscriptionsClient from the server entrypoint and use it with your secret key. The package never requires putting an sk_* key in browser code. Install Backend proxy The package ships a Next.js-compatible proxy helper at @usethrottle/subscriptions/server . It allowlists the subscription, customer-by-external, trial eligibility, and checkout-session paths, pins subscription reads, customer lookups, direct subscription creates, and checkout-session creates to the authenticated buyer's externalCustomerId , and verifies ownership before forwarding subscription mutations. Provider Mount a SubscriptionProvider high in your tree. The fetcher prop is the contract with your backend proxy — every hook routes through it. Server-side client Backend routes can use the server entrypoint as a typed subscription client without bundling React provider code. Use this for admin tooling, webhook handlers, or custom business logic that should run server-side. Subscription confirmation pages and account portals can also fetch vaulted payment-method summaries without reaching around the package. Hooks · queries useSubscriptions(filters) Lists subscriptions for a customer. Filters: customerId or externalCustomerId (one is required), plus optional status (now including unpaid and completed ), interval ( custom matches every non-named cadence), installment ( true for installment plans only, false for ongoing subscriptions only), cursor , limit . useSubscription(id) Fetches a single subscription. useCustomerPaymentMethods(customerId) Fetches saved payment-method summaries. When routed through the backend proxy, payment method reads are pinned to the authenticated buyer's customer row. Hooks · mutations All mutation hooks return a mutate, mutateAsync, isPending, error, data shape. On success they invalidate the relevant useSubscription / useSubscriptions caches. useCancelSubscription() usePauseSubscription() / useResumeSubscription() useChangePlan() A real plan change — prorated and charged immediately when effective: now , or scheduled for period end with no charge today. For a term-only edit with no proration and no charge (rename a plan, correct the amount on the row), call the client s update(id, input) instead. useCreateCheckoutSession() Bridge from the management package to @usethrottle/checkout-react s <PaymentEmbed /> . Wraps the /api/v1/checkout/sessions POST with recurring metadata. recurring is optional: omit it when the cart s lines carry recurring . useTrialEligibility() Calls POST /api/v1/subscriptions/eligibility-check through your proxy. Use it when your UI needs to decide whether to offer a trial after a payment method has already been vaulted. Custom cadence and installment plans Subscription carries the fields a custom cadence and an installment plan need: intervalUnit ( hour | day | week | month ) and intervalCount alongside interval (which now also accepts "custom" ), plus totalPayments , paymentsMade , and completedAt for installment plans (on an ongoing subscription totalPayments and completedAt are null and paymentsMade is 0 ). SubscriptionStatus adds unpaid and completed . Three helpers turn those fields into copy instead of every app re-deriving its own label map — the exact bug custom cadences were built to retire: cadenceNoun(sub) — the period noun after a slash: "10 days" , "2 weeks" , "month" . cadenceLabel(sub) — a standalone label: "Monthly" , "Every 10 days" . installmentProgress(sub) — null for an ongoing subscription, or paymentsMade, totalPayments, remainingPayments, remainingCents for an installment plan. create() and useCreateSubscription() accept intervalUnit + intervalCount alongside interval , and totalPayments (2–60) to create an installment plan. Full guide: Custom Cadence → — bounds, the label table, and renewal dates. Full guide: Installment Plans → — counting, locks, completion, and a progress component. Primitive components Three presentational components. They take a subscription (or a single field) as a prop and render appropriately. No secret-key API calls. <SubscriptionStatusBadge status= status /> — chip with state-specific colour. Accepts className . <TrialCountdown endDate= date /> — Trial ends in N days . Pass the trial end date. <DunningBanner subscription= sub onUpdateCard= fn /> — shown when status is past_due . Includes an action that calls your update-card flow. Boundaries Payment collection stays in @usethrottle/checkout-react . This package creates recurring sessions and manages the subscription lifecycle around the embed. Direct browser auth via customer-scoped tokens is not exposed yet. Use the proxy helper so your backend owns authorization and secret-key storage. Composite portal layouts are application code. Components here are primitives and accept className / style for your design system."},{"title":"Testing","route":"/developers/subscriptions/testing","headings":["Testing","Sandbox checklist","Manual smoke test","Trial-abuse replay","Webhook assertions","Automated test boundaries","Common gotchas","Prove webhooks before go-live","Next"],"text":"Subscriptions Testing Verify subscription integrations against Throttle's hosted sandbox surfaces and your own staging storefront. Treat Throttle as an external API: configure keys, origins, webhooks, and assertions the same way you will in production. Keep secret keys on your backend, use a non-production workspace/application for test runs, and never put a secret key in browser code. Browser code should only receive session identifiers and embed data returned by your backend. Sandbox checklist Before exercising a subscription flow, confirm the environment pieces below. The exact values come from your Throttle dashboard or onboarding handoff. Manual smoke test A passing smoke test proves that your backend can create the session, the browser can mount the hosted payment surface, and your webhook endpoint can process durable subscription events. Open your staging pricing or subscribe page. Select a plan that creates a recurring checkout session. Confirm your backend creates the session with a recurring block. Mount the Throttle payment or checkout embed from the returned session. Complete the payment with sandbox payment details for your account. Confirm your storefront receives the success callback and renders the subscription confirmation state. Confirm your webhook endpoint receives subscription.created and any payment events needed by your ledger. Trial-abuse replay To validate trial-abuse protection, repeat the subscription flow with the same physical card fingerprint and a different customer identity. The expected result is a subscription without the requested trial and a subscription.trial_blocked webhook. subscription.trial_blocked with a reason such as card_already_used_for_trial . , ], [ Customer experience , Your storefront should explain that the buyer is subscribed without a new trial. , ] ] /> Webhook assertions Subscription state is durable only after your webhook handler processes signed events idempotently. Store event ids before applying side effects and treat delivery as at-least-once. subscription.created plus the payment event your ledger expects. , ], [ Trial ends , code: subscription.activated , ], [ Renewal succeeds , code: subscription.renewed , ], [ Renewal fails , html: subscription.payment_failed and, on the first failed attempt, subscription.past_due . , ], [ Cancellation , html: subscription.cancelled with the expected data.reason . , ] ] /> Automated test boundaries Automated browser tests should validate your application's behavior around the embed: session creation, iframe mounting, event listeners, success routing, and webhook-driven state updates. They should not reach into the hosted payment iframe or depend on private Throttle implementation details. Common gotchas Origin not allowlisted. The iframe refuses to render until the parent storefront origin is configured for the merchant. Webhook signature mismatch. Verify against the webhook secret for the same merchant environment that sent the event. Duplicate webhook side effects. Throttle retries delivery; store event ids and make every handler idempotent. Expired checkout session. Create a fresh session for every buyer attempt instead of reusing old session ids. Testing inside the iframe. The payment surface is hosted and isolated. Assert the events it emits and the state your app persists. Prove webhooks before go-live A broken tenant lookup returns 200 on every delivery. Throttle s log shows delivered / 200 , your endpoint reports no errors, and nothing happens. If your checkout return path also syncs state, the user-present flow keeps working and hides it — until the first renewal, with nobody watching. Do not wait for a real customer to find out. Use a dedicated test tenant: Give it a subscription row and record the baseline. Create a Throttle customer whose externalId is that tenant s real id. Create or modify a subscription through the API. Assert your own database changed. Restore the tenant. Everything except a renewal can be driven headlessly this way — a browser is only needed for card entry. Renewal is the one path you cannot trigger on demand, so diarise the first one for about a month after cutover. Next Quickstart — create the first recurring checkout session. Subscription Webhooks — understand event payloads and delivery behavior. Trial Fraud Protection — see how trial eligibility is evaluated."},{"title":"Trial Fraud Protection","route":"/developers/subscriptions/trial-fraud-protection","headings":["Trial Fraud Protection","How it works","Scope is per-merchant","Pre-flight API","POST /api/v1/subscriptions/eligibility-check","Using it from your backend","The trial_blocked webhook","What this does NOT protect against","Disabling the check","Next"],"text":"Subscriptions Trial Fraud Protection Throttle ships a card-fingerprint check that detects the same physical card being reused across fresh customer accounts. The check runs automatically inside the auto-create flow and is also exposed as a pre-flight API endpoint for explicit gating. How it works Every payment vault writes the processor's stable payment_method.fingerprint onto the customer_payment_methods row. When a checkout grants a trial, Throttle reads the card's fingerprint from the payment before any order exists and checks whether a customer on this merchant with the same fingerprint already had a subscription with trialEnd set. The check runs once per checkout, and its answer covers every trial line. If a match exists, the checkout is refused with 409 trial_not_available ( details.reason: "card_already_used_for_trial" ). No order, no subscription and no saved card are created, and nothing is charged. The hosted checkout says "This card has already been used for a free trial." and shows the card form again, since another card may be eligible; your storefront can offer a plan without a trial. A subscription.trial_blocked event still fires, with subscriptionId: null . On the server-authorize rail (Secure Fields) the check runs after the authorisation and before capture; a non-zero authorisation is voided, and the pending order that rail creates first is reused if the buyer retries. A refused trial never gives a free first period. Before 2026-10-05 a blocked trial created the subscription active with its first period unpaid. If two checkouts with the same card race past the first check, the check after payment catches it: the subscription is created trialing with trialEnd set to now, the renewal job charges the first period on its next run (with tax, invoice, emails and dunning as for any trial conversion), and subscription.trial_blocked fires with the subscription id. For those few minutes subscription.created reports status: trialing . If the engine can't check (no fingerprint on the row, or the card was vaulted before the v1 schema migration), the trial is allowed . We surface this in the eligibility-check response as reason: 'no_fingerprint_available' so you can layer your own checks on top. Failing-closed would be catastrophic during the rollout — many legitimate cards in your existing customer base have no fingerprint yet. Scope is per-merchant A fingerprint that's been on Merchant A's trial does not block the same card from getting a trial on Merchant B. Different businesses, different fraud surfaces. Throttle deliberately scopes the check to (workspaceId, fingerprint) so we don't leak fraud signals across workspaces. Pre-flight API POST /api/v1/subscriptions/eligibility-check Server-to-server endpoint, secured with your API key. Useful when you want to render different UI before subscribing (e.g., hide the "Start free trial" button when the buyer is ineligible). Response: Using it from your backend The trial_blocked webhook When checkout refuses a trial (without you calling the eligibility endpoint), Throttle still emits a webhook so you have a hook point to react. On a refusal no subscription exists, so subscriptionId is null and the event carries the checkout instead: cartId , checkoutSessionId and planReference . Only the race backstop above sends it with a subscription id, in addition to subscription.created . @usethrottle/webhook-types 6.3.0 types subscriptionId and paymentMethodId as nullable. What this does NOT protect against Different cards from the same buyer. The fingerprint changes when the card changes. A buyer with three different cards can claim three trials. Disposable email signups. Block disposable email domains in your signup flow if your business cares; Throttle doesn't. IP / device velocity. Throttle doesn't see the buyer's IP. Add rate-limiting or a CAPTCHA at your edge if needed. Stolen cards. That's a payment-processor concern (provider auth-decline rules) — not a subscription concern. Disabling the check The check runs automatically when customerPaymentMethods schema is wired into the subscription service (default in production). There's no per-merchant toggle in v1 — if your business model wants unlimited trials per card, contact support and we'll discuss the right primitive. Next Lifecycle and States — trial timing semantics + dunning curve. Subscription Webhooks — full event reference (now includes subscription.trial_blocked )."},{"title":"Usage Reporting","route":"/developers/subscriptions/usage-reporting","headings":["Usage Reporting","How it works","Required scopes","Report usage","List usage","Multiple usage kinds","Billing at renewal","Next"],"text":"Subscriptions Usage Reporting Report metered usage against a subscription's current billing period. Usage items are billed on top of the base subscription amount at the next renewal. How it works Each usage item is identified by a kind string (e.g. api_calls , storage_gb ). There is exactly one row per (subscription, current period, kind) combination. Reporting the same kind twice replaces the previous value — it is an absolute upsert, not an increment. The engine computes totalCents = unitCount × unitPriceCents and stores it on the row. At renewal the cron sums all usage rows for the period and adds them to the base subscription charge. The base amount and total usage overage appear as separate line items in the renewal invoice. An immediate plan or seat change ( effective: "now" ) resets the current period to the moment of the change, so any usage already reported for the period it closes out moves forward to the new period start and is billed at the next renewal instead. A waived period forgives everything due for it, usage included — that usage is never billed later. See Lifecycle & states for both. Required scopes subscription_usage:write — report (upsert) a usage item. subscription_usage:read — list usage items. subscription_usage:write also grants read access. Report usage POST /api/v1/subscriptions/{id}/usage Body fields: kind (string, 1–64 chars, required) — usage category identifier. Use a stable, lowercase slug you control. unitCount (integer ≥ 0, required) — the absolute number of units consumed this period. unitPriceCents (integer ≥ 0, required) — per-unit price in the smallest currency unit (cents for USD). Set unitCount to the total consumed so far this period, not the delta. If you called this endpoint earlier with unitCount: 1000 and 500 more units were used, report unitCount: 1500 — not 500 . Usage can't be recorded on an installment plan (a subscription with totalPayments ): it has a fixed total, so the call returns 400 invalid_state and stores nothing. Usage is likewise refused on a cancelled or completed subscription ( 422 invalid_state ), and an unknown or cross-environment subscription id is 404 not_found . List usage GET /api/v1/subscriptions/{id}/usage Returns all usage items recorded for the subscription's current billing period. Response shape per item: id — UUID of the usage row. subscriptionId — parent subscription UUID. kind — usage category string. unitCount — units reported. unitPriceCents — per-unit price. totalCents — computed unitCount × unitPriceCents . periodStart / periodEnd — ISO 8601 timestamps bounding the current billing period. createdAt — when the row was first written or last upserted. Multiple usage kinds A subscription can carry any number of independently tracked usage kinds within a period. At renewal every kind's totalCents is summed and added to the renewal charge. Billing at renewal At renewal the subscription is charged its base amount plus the sum of all usage totalCents for the ending period, and each usage kind appears as its own line item on the renewal order and invoice. No prorations are applied — the full usage overage is billed in the renewal cycle it was accrued in. After a successful renewal all usage rows for that period are finalized; new usage accumulates against the next period. Each usage line item is written with type: "usage" . It needs no fulfilment ( deliveryMethod: "none" ), and its presence does not change the renewal order's own type — the order stays recurring , same as a renewal with no usage on it. usage is written by the renewal job only: it appears in order responses, never as a line item type you can send — POST /api/v1/carts/{id}/items , quotes, and manual order line-item edits all reject it. Usage recorded after the renewal cron runs for a period is attributed to the next period. Send your final tally before the periodEnd timestamp on the subscription. Next Managing Subscriptions — pause, resume, cancel, and change plan. Subscription Webhooks — event types fired at renewal and state changes. Scopes reference — subscription_usage:read / subscription_usage:write scope details."},{"title":"Subscription Webhooks","route":"/developers/subscriptions/webhooks","headings":["Subscription Webhooks","Event types","Example payload","Plan change scheduled (downgrade deferred)","Plan changed (upgrade applied or downgrade finalized)","Completed (installment plan paid in full)","Installment plans: paymentIndex and the schedule fields","The card and the payment","Reference handler","Delivery semantics","Common pitfalls"],"text":"Subscriptions Subscription Webhooks Webhooks are the source of truth for subscription state. Your DB stays in sync without polling, and you decide which events translate to buyer emails or in-app notifications. Throttle sends transactional emails to buyers automatically for the events that matter most — welcome, renewal receipt, renewal reminder, payment-failed, cancellation, plan-changed, and paid-in-full — so you don't need to build these from webhooks. See Lifecycle and States for the full list. Webhooks are still how you drive anything beyond that: your own additional buyer-facing message, delivered by your own provider (Resend, Postmark, SES), or an in-app surface like the <DunningBanner /> component. Event types trialing subscription flips to active at trialEnd . , Optional your trial ended, you are now subscribed email. , ], [ code: subscription.renewed , html: A renewal charge succeeded and the period advanced. Carries data.payment (the public payment ) and data.order when a charge was made. A period that cost nothing carries data.freeRenewal: true and no payment or order : none is created (see Renewal ). , Ledger entry. Receipt email (optional). , ], [ code: subscription.backup_pm_used , html: The card the renewal tried first was declined and another of the buyer's cards paid. data.backupPaymentMethod is cardBrand, cardLastFour of the card that paid. data.repinned: true when the subscription's own card hard-declined (expired, an invalid card number, lost or stolen): the subscription now renews on the card that paid, and data.subscription.paymentMethodId is that card. No separate subscription.updated is sent for that move. , Tell the buyer which card was charged; on repinned, that it is now the card for this subscription. , ], [ code: subscription.invoice_refunded , html: One billing cycle was refunded. data.intent is money_only (the subscription keeps billing) or refund_and_cancel (it was cancelled too). data.amountRefunded is what this refund moved, data.totalRefunded is the cycle s running total, and data.fullyRefunded says whether the cycle is now whole. The same event is recorded in the event feed ( GET /api/v1/events ) with entityType: "subscription" and entityId set to the subscription id, so ?entityType=subscription&entityId=<id> finds it. It is emitted for both refund routes: the dedicated cycle refund and cancel with a refund . , html: Ledger entry and refund receipt. Do not revoke access on money_only — wait for subscription.cancelled . , ], [ code: subscription.payment_failed , html: Each of the first three failed renewal attempts (the fourth cancels instead). Includes attempt , nextRetryAt , and lastError in the payload. , Payment-failed email. First failure is the most important. , ], [ code: subscription.past_due , html: The subscription moved to past_due : fires once, on the first failed renewal attempt. , Mark the account past due and show an update-card prompt. , ], [ code: subscription.updated , html: Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change . Also fires when the card a subscription renews on changes: the merchant picks a card for it ( PATCH /api/v1/subscriptions/:id/payment-method , or Change card in the dashboard), the buyer does ( PATCH /v1/storefront/me/subscriptions/:id/payment-method , or PATCH /api/v1/me/subscriptions/:id/payment-method from the billing link), or a new default card moves the subscriptions that were on the previous default. Picking a card for one subscription fires once, for that subscription only; through the merchant or storefront route, picking the card it already has fires nothing. data.subscription.paymentMethodId is the new card. Deleting a card a subscription uses does not fire it: the subscription silently falls back to the default card. , Confirmation email if buyer-initiated. , ], [ code: subscription.plan_change_scheduled , html: A plan change was deferred to the next renewal via POST …/change-plan with effective: "period_end" . The data.pending object contains the new plan details, and data.effectiveAt is the period-end ISO timestamp. , html: Notify the buyer: "your plan change to X takes effect on [date from effectiveAt ]". Show the pending change in your billing UI. If the change is later cancelled via DELETE …/pending-change , subscription.updated fires. , ], [ code: subscription.plan_changed , html: A plan change was applied. Fires in two situations: (1) an immediate upgrade ( effective: "now" ) charged and applied right away, or (2) a scheduled downgrade applied by the renewal cron at period end. In both cases, the data.subscription payload reflects the new plan fields and all pending* fields are null. An immediate change also carries data.proration ( creditCents , chargedCents , fullAmount ). , Update your local plan cache. Send a plan-change confirmation email. Grant or revoke feature access matching the new plan. , ], [ code: subscription.paused , html: Subscription transitioned to paused . , Suspend feature access (or leave on, depending on your model). , ], [ code: subscription.resumed , html: Subscription transitioned back to active . , Re-enable access. , ], [ code: subscription.cancelled , html: Terminal cancellation. data.reason is one of merchant_action , dunning_exhausted , or period_end . , De-provision. Send subscription-ended email. , ], [ code: subscription.completed , html: Terminal. An installment plan's final payment ( totalPayments ) succeeded — the moment payment N clears, not the end of the period it covers. Requires the subscriptions:read scope, same as every other subscription.* event. , html: Treat completed as paid in full even if your access gate only checks status = active . Send a "paid in full" confirmation. , ], [ code: subscription.trial_blocked , html: Trial-fraud protection refused a requested trial because the card already had"},{"title":"Team Management","route":"/developers/team-management","headings":["Team Management","Workspaces vs applications","Workspace concepts","Application concepts","Roles","Workspace roles","Application roles","Who can invite","Environment grants","Invite lifecycle","Token properties","Invite states","Accept flow","Member management","Workspace-level actions","Application-level actions"],"text":"Workspace Team Management Throttle organises access around workspaces and applications . A workspace is the billing and membership boundary; each workspace can contain multiple applications and multiple workspace environments. Role assignments exist at the workspace and application levels, while environment grants decide which production or non-production environments a member can see. Workspaces vs applications A workspace is the top-level container. It holds subscription and billing state, and every member with a workspace-level role can act across all applications inside it. An application maps to one storefront or integration surface. A workspace environment is the isolation layer shared across applications: production is immutable, and custom environments such as test, UAT, QA, and staging are non-production sandbox-provider environments. Workspace concepts One owner — the Clerk user who created the workspace. Unlimited workspace admins — implicit admin on all applications. Members with per-application roles. Shared billing across all applications. Environment grants for production, all non-production, or selected environments. Application concepts Scoped API keys, webhooks, and checkout sessions. Per-member roles: admin , developer , finance , viewer . Reads and writes are filtered by the active workspace environment. Roles Throttle checks two role axes for every request: the caller's workspace role and, for application-scoped actions, the caller's application role on the specific application being accessed. A permission is granted if either axis satisfies the rule — see the Permissions reference for the full matrix. Workspace roles owner — assigned to the workspace creator; one per workspace; cannot be transferred via the API (use the transfer ownership flow). Holds every permission, including workspace deletion and billing management. workspace_admin — full administrative access across all applications. Can invite members, edit member roles, and manage application settings. Cannot delete the workspace or access billing. Application roles admin — manage application settings, API keys, webhooks, and all member roles within the application. Can invite new members to the workspace with an application-scoped role. developer — access API keys, webhooks, customers, and orders (read and write). Cannot change application settings or member roles. finance — read customers, orders, payments; issue refunds. No write access to orders or customers. viewer — read-only access to customers, orders, and payments. Who can invite Invite rights depend on the inviter's own role. You can never grant a role higher than your own. Inviter role Can invite as Application scope owner Any role (including workspace_admin) Any application in the workspace workspace_admin Member with any application role Any application in the workspace admin (application member) Member with developer , finance , or viewer Only the applications they are admin on Only the owner can grant the workspace_admin role. This maps to the workspace:invite-admin permission. Environment grants Environment grants are workspace-wide. They sit beside roles: a member must have the right role for the action and access to the active environment. Owners and workspace admins can set grants on invites and member edits; application admins can invite members only into their current application and those invites default to all non-production environments. grantType Meaning all All active environments, including production and future custom environments. all_non_production Every active non-production sandbox-provider environment. production_only Only the immutable production environment. selected Only the listed environment UUIDs. A dashboard or Clerk-authenticated API request for an environment outside the member's grant returns 403 member_env_forbidden . Invite lifecycle Invitations are short-lived signed tokens. The flow from creation to membership is: Create — POST to /api/v1/workspaces/ workspaceId /invites with the invitee email and target role(s). Throttle mints a 7-day JWT and emails the invite link. Email sent — the invitee receives a link to /accept-invite?token=... from Throttle. The email address on the invite must match the Clerk account used to accept. Resend — POST to /api/v1/workspaces/ workspaceId /invites/ id /resend . Throttle rotates the token (old token is immediately invalidated) and sends a fresh email with a new 7-day window. Revoke — POST to /api/v1/workspaces/ workspaceId /invites/ id /revoke . The token is killed immediately and the invitation can no longer be accepted. Accept — see the accept flow section below. Token properties Signed JWT — tamper-proof and server-verified on accept. 7-day TTL from creation or last resend. Single-use — accepting invalidates the token. Strict email match — the signing email must equal the Clerk account email. Invite states pending — awaiting acceptance, token valid. accepted — member joined, token consumed. revoked — cancelled by an admin; token dead. expired — TTL elapsed before acceptance. Accept flow Accepting an invitation binds a Clerk user to the workspace membership record. The invitee clicks the link in the email and lands on /accept-invite?token=... . If not already signed in, the invitee authenticates via Clerk. The Clerk email must exactly match the address the invite was sent to — mismatches are rejected with invite_email_mismatch . The browser POSTs the token to POST /api/v1/invites/accept with the Clerk session JWT in the Authorization header. On success the member record is created, role assignments are applied, and the invitee is redirected to the workspace dashboard. If the accepting user is already a member of the workspace, the API returns 409 already_member . Existing roles are not modified. Member management After a member joins, their roles and access can be adjusted at any time by users with sufficient permissions. Workspace-level actions List member"},{"title":"Use Webhooks as Your Source of Truth","route":"/developers/webhooks","headings":["Use Webhooks as Your Source of Truth","Create an endpoint","Verify signatures","Rotate the signing secret","Process idempotently","Recommended fields","Event families","Abandoned carts & cart lifecycle","Payload envelope","Event payload reference","{group.label}","Payments and saved cards in payloads","No longer sent","Every read surface uses the same shape","Event scope reference","Retries","Manage endpoints","Inspect deliveries","Send a test event"],"text":"Event delivery Use Webhooks as Your Source of Truth postMessage events make the browser feel instant. Signed webhooks tell your backend what actually happened, even if the buyer closes the tab. Create an endpoint Register an HTTPS endpoint and choose the events your backend can process. Store the returned signingSecret securely; it is shown once. enabledEvents must be exact event-type strings from the catalog — Throttle uses payment.captured and subscription.cancelled , not Stripe-style payment.succeeded or subscription.expired . Fetch the full list from GET /api/v1/event-types . An unknown name is rejected with 400 ; the error’s details[].allowedValues enumerates every accepted event type. /api/v1/webhook-endpoints is the canonical, signed outbound webhook system documented on this page ( enabledEvents , a whsec_ signing secret, X-Throttle-Signature , plus /deliveries , /replay , and /test ). The older /api/v1/webhooks family ( events , no signed delivery) is deprecated and no longer delivers events — its delivery worker has been decommissioned. Responses carry Deprecation + Sunset: 2026-10-11 headers; the routes will be removed after 2026-10-11 . Migrate any integration still pointing at it to /api/v1/webhook-endpoints . Verify signatures Each delivery includes X-Throttle-Signature (format t=<unix_seconds>,v1=<hex> ). Compute HMAC-SHA256 over <timestamp>.<raw body> . The verifier below also rejects deliveries whose timestamp falls outside a tolerance window (default 300s) so a captured delivery cannot be replayed indefinitely, and guards against a malformed v1 so a bad header returns false instead of throwing in your handler. Signature checks fail if your framework parses and reserializes JSON before verification. Capture the raw request body first, then parse JSON after the signature passes. Verify the signature, then parse the envelope, and report the two failures with different status codes. If a strict schema rejects a field it has not seen and your handler answers with the same 401 it uses for a bad signature, a routine additive change looks like a rotated secret from the outside. That is exactly how an extension lost every delivery for hours on 2026-09-08 when environmentKind shipped. Throttle retries a 401 on the normal ladder; a 4xx that names a schema problem is far easier to find in the delivery log. Rotate the signing secret POST /api/v1/webhook-endpoints/:id/rotate-secret mints a new whsec_ and returns it once. For graceSeconds (default 86400, max 604800) every delivery is signed with both secrets — the header reads t=…,v1=<outgoing>,v1=<new> — so you can swap the secret in your config on your own schedule without a failed delivery. Send graceSeconds : 0 to revoke the old secret immediately when it has leaked. The response carries previousSecretExpiresAt . The verifier above accepts the header if any digest matches your secret, which is what makes the window seamless. A verifier that keeps only the last v1 still works — it verifies from the moment you deploy the new secret, and rejects until then, exactly as before the window existed. Process idempotently Webhook delivery is at-least-once. Store each event id before applying side effects so retries do not double-fulfill, double-email, or double-book revenue. Deduping on event id covers retries . It does not cover a genuine repeat: order.created fires exactly once per order, but every other order lifecycle event is at-least-once by design, and a repeat carries a new event id. Moving an order backwards is a supported merchant action, so fulfilled → processing → fulfilled sends order.fulfilled twice. That is deliberate — a merchant who rewinds and re-fulfils has usually re-shipped, and suppressing the second event would be the one case where a shipping notification must not go missing. Make handlers that must not act twice idempotent on the entity and target state, not on the event id alone. If a paid order has line items marked fulfillmentType: 'digital' or fulfillmentType: 'access_grant' , Throttle creates and completes those fulfillment rows after the order enters processing. Listen for fulfillment.completed and order.fulfilled in addition to payment events when you need to mirror delivery state. fulfillment.shipment.delivered now fires when the arrival is recorded — a call to POST /fulfillments/ id /delivered — and no longer when the shipment fulfillment is completed. Completing a fulfillment is carrier handoff, so the old timing announced a delivery while the parcel was still moving. Merchants who complete a fulfillment and never call the new endpoint will not see this event at all; listen for fulfillment.completed if what you want is handover. The payload gained the shipment object, so data.shipment.actualDelivery carries the arrival timestamp. The buyer-facing customer-order-delivered email follows this event, and so moved with it. Recommended fields event_id unique identifier from the delivery envelope. event_type for routing and debugging. event_version from the delivery envelope for payload migrations. environment_id from the delivery envelope for environment-scoped processing. processed_at timestamp after side effects finish. payload raw JSON for audit and replay. Event families OUTBOUND_EVENT_GROUPS.map((group) => ( group.label ( group.events.length events) )) Abandoned carts & cart lifecycle Carts are authoritative in Throttle. When your storefront creates a native cart ( POST /api/v1/carts ), Throttle tracks its whole lifecycle and detects abandonment server-side — you don't build your own timer. cart.abandoned — a nightly sweep marks an open cart abandoned once it sits idle past the application's configured threshold (or you can react immediately on delivery). A cart built from an accepted quote, or in the dashboard order builder, is never swept. Throttle also sends the buyer a customer.cart_abandoned recovery email when it can resolve a"},{"title":"Zapier","route":"/developers/zapier","headings":["Zapier","Connecting your account","Triggers","What a trigger hands the next step","Searches","Actions","When something goes wrong"],"text":"Tools Zapier The Throttle Zapier integration puts your orders, payments, subscriptions, quotes and invoices in front of every other app Zapier connects to. It authenticates with OAuth — no API key to copy, and no key to rotate when someone leaves. Zapier speaks REST and webhooks, not the Model Context Protocol. This is a separate integration over the same public API, and it needs no MCP setup. If you want Throttle inside Claude, Cursor or another AI assistant, use the MCP server instead — the two are independent and can both be connected at once. Connecting your account Add Throttle as a step in any Zap and choose Connect a new account . You'll be sent to Throttle to sign in, pick the workspace, application and environment the connection should use, and approve the permissions it asks for. A connection is pinned to the workspace, application and environment you picked at approval time, and nothing inside a Zap can move it. Connect a sandbox environment first and verify the Zap end to end there; to run the same automation against production, add a second Throttle account in Zapier. The account picker labels each one Workspace · Application · environment so the two are never confused. The permissions requested cover exactly the triggers and actions listed below — read access to orders, customers, subscriptions, quotes, invoices, payments, carts and the event log, write access to customers, quotes and carts, and permission to manage the webhook endpoints your triggers create. It also asks to read customers' personal data (names, emails, phone numbers and addresses, customer_pii:read ): that is what most Zaps move, and every trigger needs it, because a webhook delivery carries the full record. Throttle's AI-assistant connections can never be granted it; Zapier can because it is registered as an automation connector. Refunds, payment capture and other money-moving verbs are not requestable over OAuth and are deliberately absent from this integration. Triggers Every trigger is a real-time webhook, not a poll — a Zap fires within seconds of the event, and there is no polling interval to wait out. Turning a Zap on creates a webhook endpoint in your workspace subscribed to that one event type; turning it off deletes it. New order — order.created Payment captured — payment.captured Payment failed — payment.failed New subscription — subscription.created Subscription cancelled — subscription.cancelled Quote accepted — quote.accepted Cart abandoned — cart.abandoned Invoice past due — invoice.past_due You'll see these endpoints in your dashboard alongside any you made by hand. They're marked as owned by Zapier — delete the Zap rather than the endpoint, or the Zap will go quiet with no error. What a trigger hands the next step Throttle delivers this envelope: Zapier flattens it so the entity's own fields are available directly in the field mapper: id is the event id, because that is what Zapier deduplicates on and what stays stable when a delivery is retried. The order, payment or subscription's own id is resourceId — map that one when the next step needs to look the record up. Searches Search customers — by name, email or your own external id. Search orders — newest first, with status and payment-status filters. Search quotes — filter by status, e.g. to chase quotes about to expire. List invoices — issued invoices, filterable by status. Receivables aging — outstanding receivables bucketed by age. List subscriptions — filterable by customer and status. Actions Create a customer — email and external ID are each unique per application, so a buyer who already exists makes the step fail with customer_already_exists . Run Search customers first when the buyer may already exist. Update a customer — only the fields you fill in are touched; everything else is left alone. Create a quote — drafts a quote with line items and terms. It is not sent to the buyer. Create a checkout link — builds a cart from line items and returns a hosted checkout URL. Every amount is an integer in the smallest currency unit: 2500 is $25.00, and 25 is 25¢. A checkout link created on a production connection is buyer-facing and takes real payment. Actions carry an idempotency key derived from the Zap step and its inputs, so a retried run lands on the record the first attempt created rather than making a second one. When something goes wrong “Reconnect your Throttle account and approve this permission” — the connection was approved without a permission the step needs. A connection's permissions are fixed at approval time, so reconnecting is the only fix. “Allow access to customers' personal data” ( oauth_customer_pii_required ) — turning on a trigger creates a webhook endpoint, and deliveries include customer details, so the connection must hold that permission. Reconnect and approve it. An environment mismatch — the step is reaching for data in an environment this connection isn't pinned to. Connect the other environment as a second Throttle account. A Zap goes quiet — check whether its webhook endpoint still exists in your dashboard under Webhooks . Turning the Zap off and on again recreates it."},{"title":"Internal Support Module","route":"/internal/support","headings":["Internal Support Module","Routes","Auth","Workspace identity model","Webhook payload contract","See also"],"text":"Internal Internal Support Module Powers the in-dashboard support feature backed by dispatchtickets.com . These endpoints are not part of the public API surface for merchant integrations — only the Throttle dashboard consumes them. This page is intentionally excluded from the docs sidebar and the search index. Merchant API keys cannot call these routes and they are subject to change without a developer changelog entry. Routes All routes are mounted under /api/v1/workspaces/:wsId/support/* and require a Clerk JWT that resolves to a member of :wsId . Method Path Permission Notes GET /tickets support:read List tickets for the workspace. GET /tickets/:id support:read Ticket detail + comments. POST /tickets support:write File a ticket. Audit-logged. Idempotent. POST /tickets/:id/comments support:write Reply to a ticket. Audit-logged. Idempotent. POST /tickets/:id/read support:read Mark ticket read for the current user. GET /unread-count support:read Badge count for the current user. POST /attachments/presign support:write Issue a presigned upload URL. POST /attachments/:attachmentId/confirm support:write Finalise an upload once the PUT completes. Plus the public webhook receiver POST /api/v1/support/dispatch-webhook , which has no Clerk auth and is gated solely by the signed X-Dispatch-Signature header. Auth The workspace-scoped routes accept Clerk JWTs only . API-key callers receive 403 application_key_workspace_route because these endpoints sit under a workspace-scoped router rather than the application-scoped one. POST /tickets and POST /tickets/:id/comments require an Idempotency-Key header (UUID v4 recommended). The server falls back to a body-hash synthetic key if the header is missing, but stable client-supplied keys are required to make retries safe across browser refreshes and React strict-mode double-effects. Workspace identity model Each Throttle workspace maps to exactly one Dispatch customer record. The synthetic customer email follows the pattern workspace-<workspaceId>@support.throttle.internal . The real human filer identity is captured separately so support staff can reply to a person rather than the workspace: Ticket body footer line: Filed by: … Each comment carries authorName and authorId for the Clerk user that posted it. Ticket customFields include filer_user_id , filer_email , type , and browser_info . Webhook payload contract Dispatch posts events to /api/v1/support/dispatch-webhook with an HMAC signature header: X-Dispatch-Signature: t= ,v1= The signed payload is $ unixSeconds .$ rawBody . The server enforces a 5-minute replay window. Event types handled: comment.created — when authorType === 'STAFF' , the corresponding ticket row is marked unread for all workspace members. ticket.created — no-op locally; the filer already saw the ticket they just filed. ticket.updated / ticket.closed — propagated to the dashboard via the next list/detail read; no local caching layer needs invalidation. Other event types return 200 as a no-op. A bad signature returns 401 invalid_signature . For properly signed events, downstream errors are logged and swallowed with a 200 response to avoid Dispatch retry storms. See also Design spec: docs/superpowers/specs/2026-05-17-dispatch-support-feature-design.md Implementation plan: docs/superpowers/plans/2026-05-17-dispatch-support-feature-plan.md Runbook: docs/superpowers/runbooks/2026-05-17-support-module.md"},{"title":"Product Catalog storefront","route":"/product-catalog","headings":["Product Catalog storefront","The API","The SDK","How carts reach checkout","Pushing products in","Versioning","New in 1.2","Guides","{page.label}"],"text":"Product Catalog Product Catalog storefront The Product Catalog extension keeps a store's products in Throttle. Its storefront API serves them to the store's own website: product lists with filters and facets, product pages, categories, collections, search, prices per currency and per customer, reviews, and carts that end in Throttle's checkout. The @usethrottle/catalog SDK is a typed client for all of it, with React hooks, Next.js helpers and URL helpers for shop pages. The API Every storefront route lives under one base URL and takes a publishable key in the X-Catalog-Key header: Answers are JSON. Prices are integers in minor units (cents for USD). Only public products are ever answered: a product is public when it is active, not a draft, and has at least one active variant. No parameter widens that. The OpenAPI 3.1 document of the API is at catalog.usethrottle.dev/v1/storefront/openapi.json , and the API reference renders it. The SDK It runs on Node.js 20 or later and in evergreen browsers. Its only runtime dependency is @usethrottle/errors ; React 18.2 or 19 is an optional peer, needed only for the React entry. It has five entry points: How carts reach checkout The storefront never sends a price. Your page sends SKUs and quantities to carts.create ; the catalog prices every line itself, creates the cart in Throttle and answers cartUrl , Throttle's hosted cart page. The shopper reviews the cart there and pays on Throttle's checkout. When a line is added or changed, when the cart is refreshed and again at checkout, Throttle asks the catalog to price the lines (the line resolver ), so a cart always charges the catalog's price. To build your own cart page instead, keep the cart session the create answers and change it with carts.session(id) or React's useCart , then hand the shopper to checkout with checkout() . Optional return URLs bring the shopper back to your store after paying. Cart and checkout has the details. Publishable keys are public: they ship in your page's code and anyone can read them. A key's allowed websites protect browsers only; a script can send any Origin . Carts are protected by server-side resolution: the catalog prices every line on its side, so nothing a key holder sends can change a price. See Keys and channels . Pushing products in The storefront API reads the catalog. To write to it from a server, use the Catalog Push API : staged jobs for merchants and vendors. Start with the first feed guide , or point an AI agent at the agent reference . Versioning From 1.0.0 the package follows semantic versioning: a breaking change to an exported name, a type, an error class or code, or a hook's behaviour comes only in a major version. New answer keys, new error codes in (string & ) positions and new optional options are minor. Deprecated names keep working for at least one major. The reference store at throttle-demo-store.vercel.app runs @usethrottle/catalog 1.0.0. New in 1.2 useTags, useCollections and useReviewEligibility in @usethrottle/catalog/react/extras , and useCustomerKey in /react . keepPreviousData with dataFor and isPreviousData , and refresh errors with isStale and automatic retries of network, timeout and 5xx failures. The customer's cart on any device ( customerCart , with @usethrottle/auth 0.12.0 or later and a known currency). keyInQuery : browser reads send ?key= and need no preflight. A server key in a URL is refused with 403 KEY_NOT_ALLOWED_IN_URL . Suggestions never send the customer token , so they always show list prices, in every mode. A store that showed group prices in suggestions now shows list prices there. Product, related, suggestion, collection, category and review answers include a 640 px image size for every image at least 640 px wide; the legacy search, vehicles and lite product answers keep 400, 800 and 2048. Until that copy is stored it is served from the next larger stored copy. Guides pages.map((page) => ( page.label page.description Open guide ))"},{"title":"Storefront API Reference","route":"/product-catalog/api-reference","headings":["Storefront API Reference","Sending the key","{group.name}","Error codes"],"text":"Product Catalog Storefront API Reference Every operation of the storefront API at https://catalog.usethrottle.dev , rendered from its OpenAPI document. Every route except the document itself takes a publishable key in X-Catalog-Key . Sending the key A GET may send the key as ?key= instead of the header, so a browser read needs no preflight. A browser, app or legacy key is accepted this way, never together with the header (that is a 400 INVALID_REQUEST ): a server key in a URL answers 403 KEY_NOT_ALLOWED_IN_URL , and a POST always uses X-Catalog-Key . The customer token is never accepted in a URL; it travels in X-Customer-Token . The SDK's suggest never sends it, so suggestions from the SDK are list prices. See Keys and channels . Download the OpenAPI document groups.map((group) => ( group.name group.description group.endpoints.map((item) => ( )) )) Error codes Every error answers the same envelope, with a stable error.code . These are all the codes the API returns, with their HTTP statuses and whether a retry can succeed."},{"title":"Cart and checkout","route":"/product-catalog/cart-and-checkout","headings":["Cart and checkout","Resolved carts","Create a cart: carts.create","Return URLs: back to your store","Your own cart page: carts.session","In React: useCart","Signing in with a cart","Switching currency makes a new cart","“Browser carts may only hold resolved lines”","Cart errors"],"text":"Product Catalog 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. 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: /complete on Throttle's checkout). , ], [ code: cancelUrl , The cancel link on Throttle’s checkout page. , Back to the hosted cart page, which stays open. , ], [ code: 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 (otherwise 400 INVALID_REQUEST ). Before it sends a request, carts.create refuses a URL that is not absolute https with INVALID_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 : true when Throttle stored all of them, false when 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. Throttle's abandoned-cart email links to a new hosted cart session that does not carry your return URLs. A shopper who comes back through that email and pays ends on Throttle's /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 ). 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) . A provider holds one cart. The first add creates 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() sends keepOpen: 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 a cartStorage keyed by customer (see React ). The cart follows the customer. With @usethrottle/auth 0.12.0 or later and customerCart on 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 to useCart ). 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 ThrottleProvider from @usethrottle/auth/components , pass claimCartOnSignIn : a function returning the cart's cartId ( cart.cartId in useCart ). It claims after a sign-in, and after a sign-up that signs the shopper in, but it does not refresh useCart , and "},{"title":"Collections, categories, tags and featured","route":"/product-catalog/collections-categories-tags","headings":["Collections, categories, tags and featured","Categories","Collections","Renamed handles redirect","Tags","Featured products","Related products"],"text":"Product Catalog Collections, categories, tags and featured The ways a store groups its products, and the calls that render them. Each of these pages shows its products through products.list , so filters, facets, sorting and paging work the same everywhere (see Products, filters and sorting ). Every count is a count of public products, and a category, tag or collection with none is not listed. Categories categories.tree( depth ) answers the category tree, 1 to 10 levels deep (default 10). Each node has id , handle , name , description , seoTitle , productCount (products in the category or below it) and children . categories.get(handle) answers one category with its breadcrumbs (from the top of the tree down to it) and its children . Its products come from products.list( category ) , which includes the products of every subcategory. A product page carries breadcrumbs too: the path of the product's first category. An unknown handle answers 404 NOT_FOUND . Collections The merchant builds collections in Storefront › Collections . A manual collection is a hand-ordered list of up to 5,000 products; a rule collection holds whatever matches its conditions (brand, type, tag, category, attribute, option, price, featured, availability, how recently created), checked at the time of each request. Each has its own handle, title, description, image, SEO fields and default sort, and only published collections are answered. collections.list( limit, cursor ) pages through the published collections (50 a page by default, up to 100) with their kind , image and productCount . collections.get(handle) answers the collection page: title , description , descriptionHtml (sanitised by the catalog), image , seo , kind and defaultSort . products.list( collection ) answers its products. Without a sort they come in the collection's default sort; manual is a manual collection's own order. An unpublished or unknown collection answers 404 NOT_FOUND . A published collection with no products still answers its page, with an empty product list. Renamed handles redirect When the merchant renames a product or a collection, the old handle keeps working. Asking for it answers redirectTo with the new handle instead of the product or collection. It is a normal answer, not an error: check for it, and send the shopper (and search engines) to the new URL with a permanent redirect. TypeScript makes you handle it, because the answer type is a union: products.get , collections.get and reviews.list all answer either their content or redirectTo . Tags tags.list( q, limit ) answers tags with their product counts, most used first: all of them, or those starting with q (case-insensitive). The default is 50 tags and the most is 200. products.list( tags ) answers products carrying any of the tags. Featured products The merchant marks products as featured on the product page, or in bulk from the Products list. products.list( featured: true ) is the home page block, and featured is also a sort that puts them first (the default sort of a plain list). Related products products.related(handle, limit ) answers products to show beside a product (8 by default, up to 24) in the lite projection. The merchant can pick up to 24 related products per product, in order, and chooses how the list is filled in Storefront › Settings ; config().relatedMode tells you which: Similar products are ranked by what they share with the product: category most, then brand, product type, tags and filterable attribute values. The product itself and anything not public are never included, unavailable products come last, and the order is the same on every request."},{"title":"Customer prices","route":"/product-catalog/customer-prices","headings":["Customer prices","Customer groups","Send the customer token","A verified email is required","priceContext: which prices an answer carries","A refused token","In React"],"text":"Product Catalog Customer prices A merchant can give some customers their own prices: trade accounts, wholesale buyers, members. The shopper signs in with their Throttle customer account, the storefront sends their token with each request, and the catalog answers their prices. Customer groups The merchant creates customer groups in Storefront › Groups and adds Throttle customers to them. A group either has its own fixed prices per variant and currency, or takes a percent off the regular price. Each customer is in at most one group. Shoppers who are signed out, or in no group, get the regular prices. When a group price is below the regular price, the compare-at price carries the regular price ( compareAtMinMinor in a list's price range, compareAtPriceMinor on a variant), so you can show it struck through. Price sorts, price filters and price facet ranges use the regular price; only the prices shown change. Answers never name the group. Carts are priced the same way: the price a signed-in shopper sees on a product page is the price their cart charges. Send the customer token Shoppers sign in with Throttle's customer accounts, through @usethrottle/auth (see Storefront customer auth ). Give the catalog client a customerToken function that returns the shopper's current access token. With @usethrottle/auth 0.10.1 or later, that is auth.getAccessToken() : it refreshes the token first when it is about to expire, and answers null when the shopper is signed out. The client calls the function on every request that can use the token, so a refreshed token is used at once. It sends the token only in the X-Customer-Token header, never in a URL, never stores it on the client and never logs it. getAccessToken() can throw when a refresh fails for a moment (a network error, a 5xx). The client treats a throw, or anything that is not a string, as signed out for that request: it answers regular prices instead of failing the page. The token read and the request each get the full timeoutMs (10 seconds by default), so a call that waits on a slow refresh can take up to about twice that. A token function that never answers ends in TimeoutError . Sign shoppers in in the browser. A Throttle pk_ is a browser key: Throttle checks the page's origin against your allowed origins, so a sign-in from server code is refused with origin_not_allowed . On the server, take the shopper's access token from the request (a header or cookie your app sets) and return it from customerToken . The token goes with product lists, product pages, related products, suggestions, carts.check , carts.create , review eligibility and review submission. Categories, collections, tags, filter values, review lists and config answer the same for everyone and never send it. A verified email is required Group prices need a verified email address. A signed-in shopper whose email is not verified gets the regular prices, on product pages and in the cart, until they verify it. Knowing a trade customer's email address is not enough to buy at trade prices. priceContext: which prices an answer carries An answer to a request that carried a token has priceContext . Use it to tell the shopper why they see the prices they see: A refused token A token the catalog refuses (expired, revoked, or for another store) answers 401 CUSTOMER_TOKEN_INVALID . The client then asks your function for the token once more, and retries with it if it changed. If it did not, a read (or carts.check ) is retried once without the token and answers the regular prices, with no priceContext : the page still renders. A call that cannot fall back to regular prices ( reviews.eligibility , reviews.submit , and carts.create with a token) throws CustomerTokenInvalidError instead. A call makes at most three requests. In React Keep one catalog client for the life of the app and pass customerToken as a function, as above; do not make a new client when the shopper signs in. Give CatalogProvider a customerKey : the signed-in customer's id, or null . The hooks' cache is partitioned by it, so a change empties the cache and every mounted hook fetches again, and one shopper never sees answers fetched for another. Change it in the same update as the token, which useAuth() does when you read the customer from it. An answer fetched with a token is personal. Storefront answers vary on X-Customer-Token , so browser caches keep them apart, and the server client sends every request that carries a token with cache: no-store . Do not put a personal answer in a cache of your own that other shoppers read. See React and Next.js SSR and caching ."},{"title":"Errors and rate limits","route":"/product-catalog/errors-and-rate-limits","headings":["Errors and rate limits","The error envelope","The SDK's errors","Codes the SDK makes","Every storefront error code","Rate limits","While the catalog is indexing"],"text":"Product Catalog Errors and rate limits Every storefront error answers one envelope with a stable code, and the SDK turns each into a typed error. This page lists them all, with the rate limits and what the client and the hooks do about them. The error envelope error.code is stable; branch on it, never on the message. error.requestId identifies the request when you ask for help. error.issues (at most 20) names the fields at fault, as path, message . CART_REJECTED adds error.throttleCode , and CART_ITEMS_INVALID adds a top-level items list of refused lines. Throttle's cart-session routes answer their own envelope, error: code, message, details? , with lowercase codes. The SDK's errors Everything the SDK throws is a CatalogError (which extends ThrottleError from @usethrottle/errors ), with code , message , statusCode (0 when nothing answered), source ( catalog , throttle for Throttle's cart session, or client when the SDK made it), retryable , retryAfterSeconds , issues , details and requestId . 429 . retryAfterSeconds is always set: from Retry-After , or 60 when that is missing or unusable. , ], [ code: OriginNotAllowedError , html: ORIGIN_NOT_ALLOWED , SERVER_KEY_IN_BROWSER , KEY_NOT_ALLOWED_IN_URL and Throttle's origin_not_allowed . , ], [ code: CustomerTokenInvalidError , A refused customer token on a call that cannot fall back to regular prices. ], [ code: CartLineError , html: CART_ITEMS_INVALID and Throttle's quantity_unavailable and line_not_purchasable , with lines: sku, code, maxQuantity? [] . , ], [ code: TimeoutError , html: TIMEOUT : no answer within timeoutMs . ], [ code: NetworkError , html: NETWORK_ERROR : the request never got an answer (DNS, a CORS refusal, a dropped connection). ], [ code: FeatureUnavailableError , html: FEATURE_UNAVAILABLE : the catalog does not have this route yet (below). , ], [ code: CatalogError , Everything else. ], ] /> FeatureUnavailableError. A 404 that carries the header X-Catalog-Route: unmatched means the catalog has no such route: an older catalog than the SDK. A 404 from a route that never answers a missing resource ( config , the category tree, tags , the collections list, suggest , carts/check and carts ) means the same. Any other 404 is NOT_FOUND . Your own abort rethrows your AbortSignal 's reason, never wrapped in a CatalogError . isCatalogError also recognises an error from another copy of the SDK, such as the CommonJS build in an app that loads both; instanceof on a class holds within one build only. Codes the SDK makes createCatalogClient for a key that is not pc_pk_… . The key is never in the message. ], [ code: CLIENT_ERROR , html: A cartStorage adapter (or another of your functions) threw inside a hook action. ], ] /> Timeouts. timeoutMs (10 seconds by default, per client or per call) covers one request. Reading the customer token gets its own full timeoutMs first, so a call that waits on a slow token refresh can take up to about twice as long. Every storefront error code Every code a /v1/storefront route answers, from the OpenAPI document , with the class the SDK throws for it. Retryable means the same request may succeed later. Throttle's cart-session codes ( quantity_unavailable , cart_prices_changed , cart_session_expired and the others) reach you with source: throttle ; they are listed on Cart and checkout . Throttle's cart_session_expired passes details.storefrontUrl through as it was stored, unvalidated. Never render it as a link or navigate to it without your own check: http or https only, on an origin you trust. Rate limits Review submission adds 5 attempts a minute per customer and 10 accepted reviews an hour per IP address (server keys skip the IP cap). Over a limit the answer is 429 RATE_LIMITED with Retry-After in seconds. Wait that long; the SDK exposes it as RateLimitedError.retryAfterSeconds . The client never retries a 429 itself , so a burst never turns into a retry storm. The per-visitor count is kept per Cloudflare location and is approximate: a fast burst can pass somewhat more than the limit before it is refused. The per-key limit still caps a key. In the hooks. A catalog 429 RATE_LIMITED that a read hook or a useCart change meets pauses every read of its CatalogProvider until retryAfterSeconds : The hook that met it keeps its data and shows the RateLimitedError . A hook mounted during the pause makes no request. With nothing cached for its query, it shows a RateLimitedError with the seconds left, not a loading state; with a cached answer, it shows that. useCart and useSubmitReview actions reject at once with that error, with no request. When the pause ends, each mounted query fetches once. Every other 429 goes to the caller only and pauses nothing: a 429 from Throttle's cart session, and every 429 from a review submit ( RATE_LIMITED from the per-customer limit or the per-IP review cap, and REVIEW_QUEUE_FULL ). While the catalog is indexing When a store first turns on the storefront, the catalog builds its storefront index. Until it is done, plain product browsing answers 200 with indexing: true (no filters, and a null total), and requests that need the index answer 503 STOREFRONT_INDEXING with Retry-After: 30 . See the first backfill . The client retries a read once after Retry-After when that is 30 seconds or less ( indexingRetry: maxWaitSeconds changes it, false turns it off); otherwise it throws with retryAfterSeconds . The hooks never wait inside a call. They show the 503 and fetch once more at Retry-After ; a list answered with indexing: true is fetched once more after 30 seconds."},{"title":"Keys and channels","route":"/product-catalog/keys-and-channels","headings":["Keys and channels","Publishable keys are public","Key types","Allowed websites","Server rendering and static builds use a server key","Sending the key in the URL: keyInQuery","Secret keys never reach a storefront","Carts need the same websites in Throttle","Rate limits by key type"],"text":"Product Catalog Keys and channels Every storefront call carries a publishable key ( pc_pk_… ) in X-Catalog-Key . A key has a type, its channel, that decides where it may be used. Create and revoke keys in the Product Catalog dashboard under Storefront › Keys . Publishable keys are public Publishable keys are public. A browser key ships in your page's JavaScript, and anyone can copy it. It reads only what every shopper already sees: public products, categories, collections and published reviews. Allowed websites protect browsers only. They stop another website from using your key in its visitors' browsers. They do not stop a script or a server, which can send any Origin header, or none. Carts are protected by server-side resolution. A storefront sends SKUs and quantities, never prices. The catalog prices every line on its side, when the cart is created and each time Throttle asks again, so nothing sent with a publishable key can change what a shopper pays. Key types Choose the type when you create the key. The catalog enforces it on every storefront route: 403 ORIGIN_NOT_ALLOWED , html: 403 ORIGIN_NOT_ALLOWED , Your website’s browser code. , ], [ App , html: Must be on the key's allowed websites (refused when the list is empty) , Allowed , A native app or another client that is not a web page. , ], [ Server , html: Always refused: 403 SERVER_KEY_IN_BROWSER , Allowed , Server rendering, static builds and scripts. , ], [ Legacy , Any website , Allowed , Keys made before key types existed, shown as “Legacy (any website)”. , ], ] /> A legacy key keeps working from any website until you give it a type in Storefront › Keys; the key itself does not change. A store can have up to 20 active publishable keys. Revoking a key deletes it, and the next request with it answers 401 AUTHENTICATION_FAILED . Allowed websites Browser and app keys carry a list of up to 20 allowed websites. Each is an exact origin: https, a host and an optional port, with no path, such as https://shop.example.com or https://shop.example.com:8443 . A subdomain or another port is a different origin and needs its own line. http: origins, including http://localhost , cannot be listed; during development, call the catalog from your dev server with a server key, or serve the site over https. Server rendering and static builds use a server key Pages rendered on a server (Next.js, Remix, Astro and the like), static site builds, sitemap jobs and scripts call the catalog without an Origin . Give them a server key. It is refused on any request that carries an Origin , so it cannot be lifted into a browser page, and it has the higher per-key limit with no per-visitor bucket: server traffic arrives from a few hosting addresses, which a per-visitor limit would throttle. Use the browser key in the client-side part of the same app. A server key is still a publishable key and reads only public data. Keep it in a server environment variable anyway, so its higher limit stays yours. The Next.js page shows the full setup. Sending the key in the URL: keyInQuery By default every request sends the key in the X-Catalog-Key header, which makes a browser preflight each new URL. Set keyInQuery: true (1.2.0 and later) on a browser client and a read (a GET ) sends ?key= instead, with no custom header, so a plain read needs no preflight. Reads only. A POST always sends the header. createServerCatalogClient ignores the option. A revalidation with If-None-Match , or any client headers , still preflights. Never a server key in a URL. The catalog refuses it with 403 KEY_NOT_ALLOWED_IN_URL (an OriginNotAllowedError in the SDK). Use keyInQuery with browser and app keys only. A URL is recorded wherever full URLs are kept (CDN and zone logs, analytics, browser history), so rotate any server key that was ever sent in a URL, even though it was refused. The customer token is never put in a URL , in any mode. It travels only in X-Customer-Token , so a read that carries a token still preflights. Suggestions never send the customer token and, with keyInQuery on, send no custom header at all: see Search and suggestions . Secret keys never reach a storefront The catalog also has secret keys ( pc_sk_… ) for writing products from your own systems. A secret key is never for a storefront: storefront routes refuse it with 401 AUTHENTICATION_FAILED , and the SDK refuses it before any request. createCatalogClient throws CatalogError INVALID_PUBLISHABLE_KEY for anything that is not a pc_pk_ key, and for a pc_sk_ key the message says so: “Secret keys (pc_sk_) must never be used in a storefront. Use a publishable key (pc_pk_).” The key itself is never part of an error message. Carts need the same websites in Throttle Creating a cart goes through the catalog, but changing it from the browser (adding lines, the useCart hook, the checkout hand-off) talks to Throttle's cart-session routes directly, and Throttle checks the Origin against its own list. Add the same websites to Throttle's allowed origins in your application's settings (see Cart sessions ), or cart calls from them are refused with Throttle's origin_not_allowed . Rate limits by key type Over a limit the answer is 429 RATE_LIMITED with Retry-After . Errors and rate limits covers what the SDK does with it."},{"title":"Line resolvers","route":"/product-catalog/line-resolvers","headings":["Line resolvers","When Throttle calls it","The request","The signature","The answer","The reserved metadata namespace","Refusals and failing closed"],"text":"Product Catalog Line resolvers For extension builders. The Product Catalog is a line resolver: the catalog carts a storefront creates are resolved carts , and Throttle calls the catalog to price their lines. This page describes the catalog's resolver, POST /throttle/line-resolver , as an example of the contract. Throttle's Line resolvers page is the full contract for building your own. When Throttle calls it Throttle never takes a price from the browser for a line the catalog owns. It calls the resolver with an intent : Throttle waits at most 3 seconds and does not retry an add or update (the shopper is waiting); it retries a checkout once. The request type is always cart.lines.resolve , never a webhook event type; id is Throttle's request id. installationId , applicationId and environmentId only say which installation to look up. The signature decides: the catalog checks that the signed body names the same application and environment as that installation. customerId is the cart's customer, or null ; group prices apply only when customerEmailVerified is true. locale picks the language of the line names. 1 to 50 lines , each lineId, referenceId, quantity . referenceId is the variant SKU, matched exactly; the body is at most 64 KB. The signature Throttle signs every call with the installation's webhook signing secret, in the header X-Throttle-Resolver-Signature: t=<unix seconds>,v1=<hex> . The signed string is throttle.line-resolver.v1.<t>.<raw body> , HMAC-SHA256. The purpose label throttle.line-resolver.v1 keeps resolver and webhook signatures apart, though they share a secret: neither can be replayed as the other. The catalog verifies the raw body before it parses it as JSON, with a constant-time compare. t must be within 5 minutes; during a secret rotation the header carries one v1 per secret, and any match passes. Every verification failure is the same 401 , whatever the reason. The answer 200 , no-store , with one line per requested line, in order. A priced line carries ok: true and the name, unit price in minor units, currency, image, quantity limits and shipping flag; a refused line carries ok: false and a code . The answer has one other field, internal to Throttle's stock handling. maxQuantity is the most that can be ordered now, and can be 0. ], [ code: BELOW_MIN , Under the variant’s minimum order quantity. ], [ code: ABOVE_MAX , Over the variant’s maximum order quantity. ], [ code: NO_PRICE_IN_CURRENCY , No price in the cart’s currency. ], [ code: PRICE_SOURCE_MISMATCH , The source that set the price does not hold the cart’s currency. ], ] /> The price always comes from the catalog, never from the request. Stock quantities are never answered: maxQuantity on a priced line is the order limit, and on a refused line only what a refused quantity needs. The reserved metadata namespace A priced line's metadata is stored on the Throttle line, and a browser can never write it. The catalog fills it with catalogInstallationId , catalogPriceMinor , sku and variantId . It is at most 2 KB of JSON; an answer over that is never sent. The catalog never trusts line metadata alone on an order: a line counts as a catalog line only when its SKU is one of the catalog's variants and its unit price is a price the catalog would have answered. Refusals and failing closed Throttle fails closed . When the resolver times out, refuses the call or answers anything malformed (a missing line, an invalid line), the cart change is refused with 503 line_resolver_unavailable and nothing on the cart changes. Throttle never falls back to a browser price or a stale one. The catalog does the same on its side: if it cannot read the catalog, or would build an answer Throttle would refuse, it answers 503 rather than guess. Storefronts never call this route; they see its results as cart errors ."},{"title":"Next.js SSR and caching","route":"/product-catalog/nextjs","headings":["Next.js SSR and caching","A server key on the server, a browser key in the client","How the server client caches","App Router","Refresh when the catalog changes: syncCatalogGeneration","A sitemap","Pages Router"],"text":"Product Catalog Next.js SSR and caching A Next.js store uses two keys: a server key for everything rendered on the server, and a browser key for the hooks in the browser. @usethrottle/catalog/server adds Next.js cache options to every catalog read, without importing Next.js. The examples target Next.js 15. A server key on the server, a browser key in the client Server Components, route handlers, getServerSideProps , static builds and sitemaps call the catalog without an Origin , from a few hosting addresses. Give them a server key: it has the higher per-key limit and no per-visitor limit, and the catalog refuses it on any request from a browser page ( 403 SERVER_KEY_IN_BROWSER ). Keep it in a variable without the NEXT_PUBLIC_ prefix. See Keys and channels . In the browser, the CatalogProvider gets a browser key, which is public, with your site's origins as its allowed websites: For customer prices, give that browser client the customer token as shown on React . Render public prices on the server and let the hooks show the signed-in shopper's prices. How the server client caches createServerCatalogClient takes the options of createCatalogClient plus next : During the catalog's first index build, a read that meets 503 STOREFRONT_INDEXING waits its Retry-After (30 seconds at most by default; indexingRetry: maxWaitSeconds changes the limit) and asks once more. Pass indexingRetry: false to fail fast inside a page render or route handler instead. Every catalog read's fetch carries next: revalidate, tags . revalidate defaults to 60 seconds; next.tags on the client are added to each route's own tags. A call can pass its own next , which replaces the derived one for that call: include the route's tags yourself, with catalogTags . Everything else is sent cache: no-store with no next : cart calls, Throttle's cart session, review eligibility, and any request that carries a customer token. A personal answer never enters Next.js's cache. , html: Every read of one route: config , products , filters , product , related , categories , category , tags , collections , collection , suggest , reviews . , ], [ code: throttle-catalog: : , A read for one handle: a product, its related products or reviews, a category or a collection. , ], ] /> catalogTags(route, handle?) builds them, for revalidateTag or a per-call next . Two catalog installations in one Next.js app share these tags, so revalidating one also refreshes the other. App Router A product page with generateStaticParams . staticProductParams(catalog, limit ) answers up to limit (1,000 by default) public product handles, handle each, most recently updated first. Refresh when the catalog changes: syncCatalogGeneration Every change to what a storefront can see moves the catalog's generation , a number config() answers. syncCatalogGeneration reads it without the cache and, when it differs from the one you saw last, awaits onChange , where you call revalidateTag . Call it from a cron job, every minute for example. On a serverless host (Vercel and the like) a cron call often lands on a fresh instance, where a module variable starts empty: that call only records the generation, and a change made since the last call is never revalidated. Store previous in KV, a database row or an edge config instead. If a change is missed anyway, the pages' time-based revalidate still bounds how stale they get. Generations are compared, never clocks. The first call only records the generation; an error (an indexing 503 included) is thrown and onChange is not called. revalidateTag( throttle-catalog ) refreshes every catalog read; a narrower tag from catalogTags refreshes less. A sitemap The paging helpers walk every public handle, following cursors, and always end: handle [] for generateStaticParams . ], ] /> Pages Router Fetch on the server with the server key, and hand the answer to the hook as initialData . The browser then refreshes it through the provider in pages/_app.tsx , with the browser key. Next.js keeps fetch answers in its data cache only in the App Router; in the Pages Router the next options change nothing. To cache pages there, use getStaticProps with revalidate , and staticProductParams for getStaticPaths , with each handle mapped to params: handle ."},{"title":"Prices, currencies and languages","route":"/product-catalog/prices-currencies-languages","headings":["Prices, currencies and languages","Price lists, one per currency","The currency option","Languages","In the SDK"],"text":"Product Catalog Prices, currencies and languages A store can sell in several currencies and answer in several languages. Both are options on the client or on a single call, and config() says which ones the store offers. Price lists, one per currency Every variant has a base price in its own currency. To sell in another currency, the merchant enables it in Storefront › Prices , which creates a price list, and enters prices in it (by hand, by import or through the API). A variant's price in a currency is: its entry in that currency's price list, when there is one; else its base price, when its base currency is that currency; else none: the variant has no price in that currency. The catalog never converts a price. A product the merchant has not priced in euros has no euro price, and is treated as not sold in euros. Each price-list entry has its own price, sale price and compare-at price. config() answers currencies (every base currency in use plus every enabled price list) and defaultCurrency (the catalog's default, used when a cart is created without a currency). The currency option Without currency , each variant is priced in its own base currency and a list answer's currency is null . With it: Every price in the answer is in that currency: price ranges, variants, sorts and price filters. Product lists, search, suggestions and facet counts leave out products with no public variant priced in that currency. A product page still answers such a product, with availableInCurrency: false , priceRange: null , and every variant unpriced and purchasable: false . It cannot be added to a cart in that currency: the cart refuses the line ( NO_PRICE_IN_CURRENCY ). A currency the store has not enabled answers 400 CURRENCY_NOT_ENABLED . A cart has one currency. Switching currency makes a new cart with the same lines; see Cart and checkout . Languages The merchant sets a default language and up to 19 more in Storefront › Languages , and translates products, categories, collections, options, attribute labels, filter labels and filter value labels. Ask for a language with the locale option, a BCP 47 tag such as fr or fr-CA . The locale used. The catalog tries the tag, then its language ( fr-CA → fr ), and takes the first one the store has enabled. A locale the store does not have is not an error: the answer comes in the default language. The answer's locale names the one used. Field by field. A field without a translation in that locale falls back the same way, to the language and then to the default text, so a half-translated product still shows every field. What is not translated: variant fields, and reviews, which are shown as written. Search with a locale also matches the locale's translated titles and descriptions. In the SDK Set currency and locale on createCatalogClient as defaults, and override them on any call. Each call sends only what its route takes: Prices for a signed-in shopper (customer groups) are on Customer prices ."},{"title":"Products, filters and sorting","route":"/product-catalog/products-filters-sorting","headings":["Products, filters and sorting","Parameters","Filters","Facets and filter values","Sorting","Paging","Fields and include","Keep the shop page in its URL","While the catalog builds its index"],"text":"Product Catalog Products, filters and sorting products.list ( GET /v1/storefront/products ) answers one page of public products for a selection, with the filters a shop page shows beside them. Shop, category, collection, search and home pages are all this one call. Parameters products.list(query, options) takes the selection first and the per-call options ( signal , timeoutMs , currency , locale ) second. (repeated) , Selected filter values, keyed by the filter key. A range is one a..b value. ], [ code: handles / skus , code: handles / skus (repeated) , A batch of up to 100 products by handle or SKU, in the order asked. ], [ code: vehicle , code: year, make, model, submodel, engine , Parts that fit a vehicle; make and model are required. ], [ code: sort , code: sort , The order. See Sorting. ], [ code: limit , code: limit , Products per page, 1 to 100. Default 24. ], [ code: page / cursor , code: page / cursor , A page number, or the previous page’s nextCursor. Not both. ], [ code: facets , code: facets , true for every filter, or a list of filter keys. ], [ code: fields , code: fields , lite (the default), full, or a list of product keys. ], [ code: include , code: include , Heavy parts to add: fitment, oemNumbers, supersessions, jsonLd, descriptionHtml. ], ] /> An unknown parameter, or a value it does not accept, answers 400 INVALID_REQUEST with an issue naming it. A category or collection that does not exist answers 404 NOT_FOUND . Batch lookups silently leave out anything that is not public: a draft, an archived product and an unknown SKU look the same. Filters The merchant sets up filters in Storefront › Filters : each has a key, a label, a source (brand, product type, tag, category, price, availability, rating, an attribute or a product option) and a display (checkbox, swatch, range, boolean or dropdown). The examples here assume filters keyed brand , color and price . Values within a filter are OR, filters are AND. brand: [ Bosch , Brembo ] with color: [ Red ] is Bosch or Brembo products that are also red. Ranges are one a..b value : 5000..12000 , or open-ended 5000.. and ..12000 . A price range is in minor units, like every price. A malformed range answers 400 . Variant filters match one variant. Filters on variant values, such as options and price, must all hold for the same variant: “red” with “under $50” finds products with a red variant under $50, not a product whose red variant costs more and whose blue one is cheaper. An unknown filter key answers 400 INVALID_REQUEST . A filter the merchant disabled, or whose source is no longer public, is skipped and named in the answer's applied.ignored . On the wire, each value is its own filter.<key> parameter; commas and .. are literal: Facets and filter values With facets , the answer carries filters : one entry per filter with its key , label , display , its values (each value , label , count , selected and an optional swatch ), a range for range filters, and more . A value's count is the number of products that match every other selected filter plus that value, so the numbers next to a checkbox are what the shopper gets by ticking it. Values with no products are left out, unless selected: a selected value that matches nothing comes back with count: 0 , so the shopper can clear it. A list answer carries up to 50 values per filter; more: true means there are others. filters.values(key, query) ( GET /v1/storefront/filters/:key/values ) answers every value of one filter, up to 1,000, counted for the same selection. Sorting Without a sort , the list uses relevance with q (or a handles / skus batch), a collection's own default sort with collection , and featured otherwise. The answer's sort names the one used, and config().sorts lists the ones the store offers. An unknown sort, or best_rated while ratings on lists are off, answers 400 INVALID_REQUEST . Ties break by product, so pages never overlap. Paging Every list answer carries page : number , limit , total (the exact count of matching products), pageCount and nextCursor . Page by number with page , or follow nextCursor with cursor for infinite scroll; sending both answers 400 . Offsets are capped. A page number, and a cursor for relevance or manual , can reach 10,000 products deep (1,000 for relevance ). Past that the page is empty and page.capped is true . Cursors on a sort with a stable key are not capped. For featured , title, price, newest , updated and best_rated , the cursor is keyset based: it walks the whole list and stays exact while products change. A cursor is opaque. Send it back as it came, with the same selection and sort. Fields and include Lists answer the lite projection by default, sized for a product grid (a 24-product page is at most about 30 KB): handle , title , brand , productType , images (the first two, with sizes), priceRange , availability , lowStock , featured , badges (sale and new), variantCount , optionSwatches , seo.title and, while ratings show on lists, rating . fields: full answers the whole product, as a product page gets it: variants with their option values, options, breadcrumbs, descriptions, purchasable and the rest. fields: [...keys] answers only those top-level keys ( handle is always kept). An unknown key answers 400 INVALID_REQUEST naming it. include adds the heavy parts no projection carries by default: fitment , oemNumbers , supersessions , jsonLd and descriptionHtml . products.get(handle) always answers the full product and takes include . A renamed product answers redirectTo with its new handle; see Collections, categories and tags . Keep the shop page in its URL @usethrottle/catalog/url turns a shop page's query string into a selection and back. serializeShopQuery writes one canonical string per selection (a stable parameter order, so browser and CDN caches see one URL), parseShopQuery(serializeShopQuery(q)) gives q back, and parameters it does not know ( utm_source , your own) are "},{"title":"Catalog Push API","route":"/product-catalog/push","headings":["Catalog Push API","What it is","How a push works","Tax codes and quotes","In this section","Known limits"],"text":"Product Catalog · Push API Catalog Push API The push API is how a vendor's own system, or the merchant's, puts products into the Product Catalog: create, update, rename and archive them by the vendor's own product id, at any size, with a result for every row. It lives next to the storefront API, on the same host. Push keys are secret. Never put a pc_sk_ or pc_vk_ key in a browser, a mobile app or a repository. The storefront API takes publishable keys; this one does not. What it is Every route takes the key as a bearer token ( Authorization: Bearer pc_… ). Answers are JSON, and every error answers the same envelope (see Errors ). The OpenAPI document is public at GET /v1/catalog/openapi.json . Callers address a product by their own product id , externalId , which is unique per vendor. The URL handle is an ordinary field you can change. Vendors and keys covers who owns what. How a push works Pushing is async only . A push answers 202 with a job or run id and is applied in the background, so a feed of a hundred thousand products does not hold a request open. The one synchronous route is PATCH /v1/catalog/inventory , for live stock. A job is staged : nothing applies until it is closed. A job that is not closed within 24 hours expires , its staged batches are discarded and never applied, and it ends as failed ( JOB_EXPIRED ). Send the feed again in a new job. In TypeScript, jobs.run does all five steps for you ( admin SDK ): Tax codes and quotes A row's product.taxCode sets the product's tax code, such as Avalara's PF050101 . There is one per product, shared by all its variants. It must be upper case and match ^[A-Z0-9]{1,25}$ (file imports trim and upper-case it for you), and a sent null removes it. Vendor pushes and product reads carry it too. Storefront answers, feeds and JSON-LD never include it. Pushed products are what Throttle's quotes look up. When a rep picks a product for a quote line, or an API caller sends a line with only its SKU, Throttle asks the catalog through a signed, server-to-server quote lookup. The lookup answers the variant's name, description, image, price in the quote's currency (the customer's price-group price when there is one), tax code, stock status and quantity limits. Out-of-stock variants are still returned; draft and archived products are not. No push key, publishable key or browser can call the lookup. See Catalog lines in the quotes guide. In this section First vendor feed in 10 minutes , Start here: a working feed, step by step, with expected answers. ], [ html: Concepts , Keys, the job lifecycle, merge and replace, fair turns, ownership and holds. ], [ html: Limits and throughput , Every limit, and two measured production runs. ], [ html: Troubleshooting , Symptom, cause, fix. ], [ html: Reference for AI agents , How an agent runs a full sync; links the OpenAPI document. ], [ html: Vendors and keys , Ownership, key reach, SKU clashes, adoption, holds, the vendor winning, vendor on orders. ], [ html: Jobs, batches and runs , Merge and replace, staged jobs, limits, full sync, fair turns, reports. ], [ html: Products , Read, archive, restore, rename, the 30-day purge, categories. ], [ html: Stock , Set and adjust, idempotency, per-row results, stock in a job. ], [ html: Webhooks , Completion events, signatures and the retry schedule. ], [ html: Errors , The envelope and every error code. ], [ html: Admin SDK , The experimental @usethrottle/catalog/admin entry. ], [ html: API reference , Every operation, rendered from the OpenAPI document. ], ] /> Known limits None of these is a bug; plan around them. Image copies run in two phases. While a job applies, they run alongside it: about 200 to 285 copies per minute at peak in one production run (2026-11-01, Worker e2f3c98a, a single run on one store). Copies still waiting when the job ends are picked up by a sweep that runs every 5 minutes and publishes up to 100 jobs per run, about 20 copies per minute (in the first run, 1,607 leftover copies took about 40 minutes). Until a copy lands, the storefront serves the vendor's own image URL, so keep those URLs public and stable. A catalog also has a budget of 20,000 image resizes per calendar month (roughly 6,700 images), so a feed with more images than that stays partly on vendor URLs for longer. Stock in a job applies at close. Use PATCH /v1/catalog/inventory for live stock ( Stock ). The legacy checkout records no vendor on an order line. With the Vendor on orders setting on, only carts priced by the catalog's line resolver record metadata.lineResolver.vendorId ( Vendor on orders ). Vendor batches are canonical rows. A mapping profile cannot set externalId . Job CSV reports above 900 parts answer 409 REPORT_TOO_LARGE . The admin SDK entry is experimental. A breaking change to it ships as a minor release. Categories now go up to 10 levels deep everywhere the tree is used; the storefront's categories.tree takes a depth of 1 to 10."},{"title":"Catalog Push API: reference for AI agents","route":"/product-catalog/push/agents","headings":["Catalog Push API: reference for AI agents","Base URL and auth","Endpoints","Complete call sequence with exact JSON","Required call order for a full sync","Idempotency rules","Retry decision table","Stop conditions","The loop in pseudo-code","Error handling","Never"],"text":"Product Catalog · Push API Catalog Push API: reference for AI agents Follow this page literally to run a full sync of a vendor's catalog. Machine-readable contract: https://catalog.usethrottle.dev/v1/catalog/openapi.json (public, no key). Base URL and auth Base URL: https://catalog.usethrottle.dev/v1/catalog Header on every call: Authorization: Bearer <key> , with a vendor key pc_vk_… or a store key pc_sk_… . Never send either from a browser, and never print or log it. Bodies are JSON ( Content-Type: application/json ), at most 5 MiB and 1,000 rows per batch; PATCH /inventory at most 1 MiB and 1,000 rows. Archive, restore and assign take at most 100 refs per call; GET /products takes limit 1 to 100 (default 50); externalId is at most 255 characters. An Idempotency-Key is remembered 24 hours. Endpoints Complete call sequence with exact JSON Every request and response of a full sync, in order. Replace <key> , <jobId> and <batchIndex> . Bodies are real answers from production (ids replaced); the base URL is https://catalog.usethrottle.dev/v1/catalog . Required call order for a full sync Open a job with mode: "replace", fullSync: true (or merge, if you only upsert). Keep the jobId . Send batches : split the feed into batches of at most 1,000 rows and under 5 MiB. Keep all rows of one product adjacent in the same batch (replace refuses scattered rows). Every row of a vendor needs externalId , productHandle and sku . Send at most 2,000 batches per job; keep at most 3 jobs open. Check each batch answer : if rowsRefused > 0 , read issues . A refused row counts in rowsFailed for the whole job and skips the archive pass, even if you resend a fixed row. To get the archive, open a new full-sync job with the whole corrected feed. Close the job exactly once. Nothing is applied before this. Wait : poll GET /jobs/{jobId} every 5 to 10 seconds, or use a webhook, until finishedAt is set. Do not hold requests open. Judge the result : success is failureCode === null and rowsFailed === 0 . Otherwise download errors.csv and report it. Measured on 2026-11-01 in two single runs on one store (Workers e844ed62 and e2f3c98a): 120,000 products (372,242 rows) took 55 and 62 minutes end to end, an apply rate of 7,538 and 6,713 rows per minute (end-to-end rates: about 6,700 and 6,045). Plan on about 1 hour per 120,000 products with images. Image copies run alongside the job at about 200 to 285 per minute at peak, and leftovers drain at about 20 per minute afterwards; the storefront serves the vendor's image URL until a copy lands. Updates with no image changes run faster. Split very large catalogs across jobs. Details: Limits and throughput . Idempotency rules Send Idempotency-Key (1 to 200 characters) on every batch. Use a stable key such as jobId : batchIndex . A retry must repeat the same body with the same key ; the replayed answer carries Idempotent-Replayed . A different body with a used key is 409 IDEMPOTENCY_CONFLICT . An adjust stock row requires a key ( 400 IDEMPOTENCY_KEY_REQUIRED ). One key per delivery event, not per attempt. Retry decision table 0) , Resend only the fixed rows (merge); with fullSync the archive is already skipped, so run a new full-sync job with the whole corrected feed , None , A NEW key (the body changed) , The fixed rows ], [ 409 JOB_CLOSED, JOB_FULL , No, in this job , None , New job, new keys , Continue in a new job ], [ 400, 413, 422, 404, 401, 403 , No , None , None , Fix the request first ], ] /> Stop conditions Stop and report to the user (do not retry) when: The response is 401 or 403 (key invalid, vendor paused, route not allowed). Five consecutive retries of one request have failed. A batch answers 413 or 400 after you cut it to the limits. The job finished with failureCode set, or rowsFailed > 0 : report the code and the first lines of errors.csv . You have waited more than 3 hours without finishedAt for a feed under 120,000 products (see the time guidance below), or the job state is expired . The feed would archive far more products than usual: stop before close and ask the user (a full sync archives everything the job did not send). The loop in pseudo-code Error handling Every error is { error : { code , message , requestId , issues ? } } . Branch on code , never on the message. All codes: Errors . Symptoms and fixes: Troubleshooting . Never Never set fullSync unless the feed is the vendor's complete catalog: it archives everything the job did not send. Never split a product's rows across batches in replace mode. Never put a key in a browser, URL, log or repository. Never assume stock in a job is live: it applies at close. Use PATCH /inventory for live stock. Background: Concepts , first feed guide , API reference ."},{"title":"Push API Reference","route":"/product-catalog/push/api-reference","headings":["Push API Reference","{group.name}","Error codes"],"text":"Product Catalog · Push API Push API Reference Every operation of the push API at https://catalog.usethrottle.dev/v1/catalog , rendered from its OpenAPI document. Every route except the document itself takes a secret key as a bearer token: a store key ( pc_sk_ ), or a vendor key ( pc_vk_ ) on the operations that list it. Download the OpenAPI document groups.map((group) => ( group.name group.description group.endpoints.map((item) => ( )) )) Error codes Every error answers the same envelope, with a stable error.code . These are all the codes the API returns, with their HTTP statuses and whether a retry can succeed."},{"title":"Concepts","route":"/product-catalog/push/concepts","headings":["Concepts","Store key and vendor key","The staged job lifecycle","Which calls work in which state","Merge and replace","Fair turns","Ownership, adoption and holds","A SKU clash, walked through","API archive and the 30-day purge"],"text":"Product Catalog · Push API Concepts Seven ideas explain the whole push API. Each section is short and links to the page with the details. Store key and vendor key Example. The same header name, a different reach and a different way to name a product: Both are secret and server-side only. Details: Vendors and keys . The staged job lifecycle A job is a staged, all-or-nothing push. Batches are checked on arrival and stored; nothing changes in the catalog until the job closes. open closed completed failed expired close 24 h, never closed Which calls work in which state The job view has the states open , closed and expired ; completed and failed are read from finishedAt and failureCode . A completed job can carry FULL_SYNC_ARCHIVE_SKIPPED , so consumers must check failureCode (webhook: data.failureCode ) on a catalog.run.completed event. Webhooks say catalog.run.completed or catalog.run.failed . Merge and replace Merge (default) changes only what you send. Replace treats the job as the full truth for your own data: values you did not send are dropped, and variants you supplied earlier and did not send are archived. Replace needs a product's rows together in one batch. Full sync is a separate switch: after applying, it archives your products that the whole job never mentioned, and only if the job was clean. See Merge and replace . Example, run on production. A product has two variants, a brand and a stock of 5. The replace job sends only the 250 g variant, with a title and a price. Afterwards the brand is gone (the value you no longer send), the stock you did not resend is dropped to 0, and the 1 kg variant you stopped sending is archived . A merge job with the same row would have changed only the price. Fair turns Each vendor, and the store key, has its own lane. Closed jobs take turns across lanes, so a small update is not stuck behind another vendor's huge feed. An idle open job holds no turn. Within a lane, work goes oldest close first. Ownership, adoption and holds Ownership. A product has one owner: a vendor or the merchant. A vendor key never changes ownership, and a SKU clash with another owner fails with SKU_CONFLICT . Adoption. Products already in the catalog stay the merchant's until the merchant assigns them to a vendor; the vendor's first push then links each one by SKU and records its externalId . A push never claims an unassigned product. The vendor wins. On a vendor-owned product, what the vendor sends outranks the merchant's dashboard edit. Holds. If the merchant archives or drafts a vendor product in the dashboard, the vendor can still update its data but cannot republish or restore it until the merchant lifts the hold ( held in the answer). A SKU clash, walked through Vendor A owns DEMO-GUJI-250 . Vendor B pushes a different product with the same SKU. The batch is accepted (a SKU clash is found when the row is applied), the row fails with SKU_CONFLICT in the error report, and nothing of vendor A's product changes. Vendor B cannot see A's product to find out why: it answers 404 , and a store-only route answers 403 VENDOR_KEY_NOT_ALLOWED . Resolve it by choosing a SKU of your own, or ask the merchant to assign the product to the right vendor. Holds in detail . API archive and the 30-day purge Deleting through the API archives : the product leaves the storefront and can be restored. An API archive is purged 30 days later (the whole product, unless another source still supplies it). A merchant's dashboard archive never purges. An archived product stays archived when later pushes resend it, until an explicit restore or a row that sends status: "active" . The storefront edge cache holds an answer for at most 60 seconds, so an archived product can still appear for up to a minute. It is gone on the next uncached read. The purge in practice: The purge in detail ."},{"title":"Errors","route":"/product-catalog/push/errors","headings":["Errors","The envelope","Codes","Row issues","Job failure codes and other codes","In the SDK"],"text":"Product Catalog · Push API Errors Every failed request answers the same envelope with a stable error.code . A failed row is not a failed request: it is reported in the batch answer and the job's error report. The envelope issues appears on validation errors and names every problem. Quote requestId when you ask for help. A 429 and some 409 s carry Retry-After . Codes Retryable means the same request can succeed later without a change. Always resend with the same Idempotency-Key . Row issues A failed row is not a failed request. A batch answer lists refused rows in issues as { index, code } (the position in the batch, 0 based; no message) and counts them in rowsRefused . The job's errors.csv adds the row number (job-wide, from 1), SKU, field , severity, message and externalId. Rows refused or failed count in rowsFailed ; warnings leave the row applied and count in rowsWarned . Every code a push can report: [ code: r.code , r.title, r.fix]) /> Warnings are MERCHANT_HOLD , INVALID_GTIN_DROPPED , CATEGORY_TOO_DEEP , DUPLICATE_SKU_REPLACED and SCATTERED_PRODUCT_ROWS in merge mode (in replace mode it refuses the product). A currency the store has no price list for, or a locale it has not enabled, refuses a vendor row: for a currency the error is FIELD_NOT_ALLOWED with "The store has no price list in this currency."; for a locale it is LOCALE_NOT_ENABLED . Stock rows in PATCH /inventory report invalid with the stock codes above. Job failure codes and other codes The same codes, with the exact response and a corrected request, are on Troubleshooting . In the SDK The admin client throws the same classes as the rest of @usethrottle/catalog : a CatalogError with the server's code , and a RateLimitedError (with retryAfterSeconds ) for every 429 . See Errors and rate limits for the storefront side."},{"title":"Your first vendor feed in 10 minutes","route":"/product-catalog/push/guide","headings":["Your first vendor feed in 10 minutes","1. Create a key","2. Open a job","3. Send a batch","A row-level error","4. Close the job","5. Poll until completed (or use a webhook)","The webhook alternative","6. Read a product back","7. Verify on the storefront","All seven steps in one script","What to do next"],"text":"Product Catalog · Push API Your first vendor feed in 10 minutes Seven steps take two products from your system to the live storefront. Every step has the request as curl and as SDK code, and the answer you should get. All of it was run against production before publication; ids are real examples and your ids differ. Replace the demo- and DEMO- ids with your own. A vendor key from the merchant, Node 20+ (or just curl ), and npm install @usethrottle/catalog for the SDK form. The HTTP answers are the contract; the SDK returns the inner object (for example the job without the job wrapper). 1. Create a key Keys are created by the merchant, in the Product Catalog dashboard: Storefront › Vendors , open your vendor, Create key . A vendor key starts with pc_vk_ and is shown once ; copy it into your server's secret store. If you are the merchant, use a store key ( pc_sk_ ) instead. Export it, then prove it works with any read: export CATALOG_VENDOR_KEY=pc_vk_… Check before moving on: 200 with a products list means the key is valid. 401 AUTHENTICATION_FAILED means the key is wrong or revoked; 403 VENDOR_PAUSED means the merchant paused you. A vendor that already has products sees them here (the SDK returns the same object). 2. Open a job A job is a staged push: nothing changes in the catalog until you close it. Open one and keep its id. Check before moving on: 201 , state: "open" , failureCode: null and an expiresAt 24 hours away. Export the id: export JOB_ID=<jobId> . The SDK's job.get() returns the object inside job . 3. Send a batch A batch is up to 1,000 rows and 5 MiB. All rows of one product go in the same batch. Send an Idempotency-Key so that a retry never stages the batch twice. Check before moving on: 202 , rowsAccepted equals the rows you sent, rowsRefused: 0 and an empty issues . Nothing is applied yet. A row-level error One bad row does not fail the batch. Here the second row has no sku : the batch still answers 202 , the bad row is listed in issues by its position ( index , 0 based), and the good row is accepted. The error report (after the job finishes) names the field and counts rows from 1 across the whole job. This example runs in a scratch job, so the main feed stays clean. Fix: correct the row and send it in the same job as a new batch with a new Idempotency-Key (the body changed). Rows already accepted stay staged. 4. Close the job Closing tells the catalog the feed is complete; it applies the staged batches in the order they arrived, in the background. Check before moving on: 202 , state: "closed" , finishedAt: null . Closing twice is harmless (the second call answers 200 with the job). Any batch sent now answers 409 JOB_CLOSED . 5. Poll until completed (or use a webhook) Poll the job every 5 to 10 seconds until finishedAt is set. A small job took about 10 seconds; a 120,000-product feed took about an hour. Check before moving on: finishedAt is set, failureCode is null and rowsFailed is 0. If not, download GET /v1/catalog/jobs/{jobId}/errors.csv . On a repeat run of the same feed the counts read unchanged instead of created . The webhook alternative Instead of polling, the merchant sets a webhook URL for your vendor (Storefront › Vendors › webhook) and you get a signed catalog.run.completed or catalog.run.failed event. Verify the signature on the raw body, then act. Dedupe on id , because an event can arrive twice or late. Check: your receiver answers 2xx within 3 seconds, and data.runId equals your job id. See Webhooks . 6. Read a product back Address the product by your own id. This is the push API's view: status, hold, archive and purge state, variants. Check before moving on: status: "active" , held: false , the price and quantity you sent. 404 means the id is not yours or the job has not finished. 7. Verify on the storefront Shoppers see the product through the storefront API with a publishable key ( pc_pk_ , type server; ask the merchant). This is the final proof. Check before moving on: the title and priceRange match what you sent. A few seconds can pass between finishedAt and the product appearing; a 404 for longer means the product is a draft (send status: "active" ), held, or archived. All seven steps in one script What to do next Concepts : merge and replace, the job lifecycle, ownership and holds. Full sync : archive what your feed no longer lists. Stock : live stock with PATCH /inventory . Limits and throughput : plan a large feed. Troubleshooting : every error code with a fix. Reference for AI agents ."},{"title":"Stock: set and adjust","route":"/product-catalog/push/inventory","headings":["Stock: set and adjust","PATCH /v1/catalog/inventory","Adjust rules","Results per row"],"text":"Product Catalog · Push API Stock: set and adjust Every stock write chooses, per row, between set (the quantity becomes the sent value) and adjust (the sent value is a signed delta added to the quantity on hand). Set is the default, so an existing caller is unchanged. A stock set or adjust inside a sync job is staged with the job and applies at close, not on arrival ; a staged set re-bases on-hand stock at that moment. PATCH /v1/catalog/inventory is synchronous: the quantity changes before it answers. PATCH /v1/catalog/inventory The body is { rows: [...] } , 1 to 1,000 rows, at most 1 MiB. A row names a sku and can carry quantity (an integer), quantityMode ( set or adjust ), priceMinor , salePriceMinor ( null clears it), currency and availability . Adjust rules Atomic. Two adjusts that arrive together both apply (5 + 3 + 2 = 10); the last writer never wins. An Idempotency-Key is required. A request, a job batch or a vendor single batch with an adjust row needs an Idempotency-Key of 1 to 200 characters, or the answer is 400 IDEMPOTENCY_KEY_REQUIRED before anything is written. A repeated key answers the first answer again and never applies the delta twice. Choose one key per delivery event, not per attempt. Reusing a key with a different body is 409 IDEMPOTENCY_CONFLICT . The SDK generates a key when you pass none. On-hand only. An adjust changes the stored quantity. Stock reserved by open carts is not touched, and availability keeps being computed from reservations as before. Below zero. An adjust may take the quantity below zero only when the variant's backorders setting is allow . Otherwise the row fails with QUANTITY_BELOW_ZERO and the quantity stays. The check uses the backorders value stored before the row writes : a row that also turns backorders on is still judged against the old value. Nothing is silently clamped to zero. One mode and one row per SKU per request. The same SKU twice, or set and adjusted together, fails every row involved ( DUPLICATE_SKU_IN_BATCH , STOCK_MODE_CONFLICT ). Results per row The answer is { results: [...] } , one entry per row, in order: updated , unchanged , not_found (an unknown SKU, or one outside a vendor key’s reach, never SKU_CONFLICT ) or invalid . ], [ code: quantity , The quantity the row left on hand. Reconcile against this, not your own arithmetic. ], [ code: code , html: Why a row is invalid : QUANTITY_BELOW_ZERO , QUANTITY_OUT_OF_RANGE , QUANTITY_MODE_WITHOUT_QUANTITY , STOCK_MODE_CONFLICT or DUPLICATE_SKU_IN_BATCH . ], [ code: sourceWins , false when the caller’s value does not win the quantity (the store key under a value the merchant set by hand). ], ] /> A failed row never fails the others. In a job or a vendor single batch, the quantity each stock-writing row left is recorded and paged from GET …/stock (see reports )."},{"title":"Limits and throughput","route":"/product-catalog/push/limits","headings":["Limits and throughput","Limits","Throughput: two measured runs","What to do about it","Images","How fresh is the storefront?"],"text":"Product Catalog · Push API Limits and throughput Every number you can hit, what happens when you hit it, and how long a large feed really takes. Limits 400 INVALID_REQUEST ], [ Bytes per batch , 5 MiB (5,242,880 bytes) , html: 413 REQUEST_BODY_TOO_LARGE ], [ Inventory request , 1,000 rows, 1 MiB , html: 413 REQUEST_BODY_TOO_LARGE ], [ Batches per job , About 2,000 (roughly 2 million rows) , html: 409 JOB_FULL ], [ Open jobs per caller , 3 , html: 409 TOO_MANY_OPEN_JOBS ], [ Waiting batches per caller , 2,000 received, not yet applied , html: 429 PUSH_QUEUE_FULL , Retry-After: 60 ], [ Job lifetime while open , 24 hours , html: Expires: JOB_EXPIRED ], [ Request rate , 600 per 60 s per key (approximate) , html: 429 RATE_LIMITED with Retry-After ], [ Idempotency-Key , 1 to 200 characters , html: 400 INVALID_REQUEST ], [ Job CSV report , Up to 900 parts , html: 409 REPORT_TOO_LARGE ], [ Report retention , 30 days after the run finishes , html: 410 REPORT_EXPIRED ], [ Refs per archive, restore or assign call , 100 , html: 400 INVALID_REQUEST ], [ GET /products page size , 1 to 100, default 50 , html: 400 INVALID_REQUEST ], [ externalId length , 255 characters , html: 400 INVALID_REQUEST or a refused row ], [ Idempotency-Key memory , 24 hours , After that the same key is treated as new ], [ Stock adjust de-duplication (job rows) , 7 days , A repeated adjust row of the same delivery is not applied twice ], [ Vendors per store / keys per vendor , 200 / 2 active , Creation is refused ], ] /> The rate limiter is approximate : it is not an exact meter, so a burst can slip a little past 600 or be refused a little early. Treat 600 per minute as a design target, not an exact meter, and always honor Retry-After . A vendor key is limited on every route; the store key on jobs and products routes only. Throughput: two measured runs These are two measurements made on 2026-11-01 on one production store, each run once. They are not a guarantee, a benchmark of your feed, or a target. The feed was 120,000 products (372,242 rows) in 373 batches, mode merge, four parallel senders, with images on 10% of the products. Expect about 1 hour per 120,000 products with images , roughly 6.7k to 7.5k rows per minute on average, and plan on a feed with many new image URLs taking that long or longer. The apply rate falls during a run (about 12k to 13k rows per minute at the start, about 3.5k in the middle) and recovers once the image copies drain. The fix between the runs cut database reads by half and made them independent of catalog size, but did not make the run faster. Updates to existing products with no image changes run faster: a price update of about 150,000 rows on the same store applied at about 46,000 rows per minute (a single run on 2026-11-01, 195 s, Worker e844ed62). The same 120,000-product feed ran in about 4 minutes on a local test stack. Do not read that as a production figure. What to do about it Split very large catalogs across jobs (for example by category or by 20,000 products), and keep at most 3 open at once. Poll the job every few seconds, or use the webhook, rather than holding a connection open. Closing returns at once. Send updates without images as a separate, small job so they are not queued behind a big image feed (lanes take turns, but a lane applies its own work in order). Images Image copies run in two phases. While a job applies, they run alongside it: about 200 to 285 copies per minute at peak in one production run (2026-11-01, Worker e2f3c98a, a single run on one store). Copies still waiting when the job ends are picked up by a sweep that runs every 5 minutes and publishes up to 100 jobs per run, about 20 copies per minute (in the first run, 1,607 leftover copies took about 40 minutes). Until a copy lands, the storefront serves the vendor's own image URL, so keep those URLs public and stable. A catalog also has a budget of 20,000 image resizes per calendar month (roughly 6,700 images), so a feed with more images than that stays partly on vendor URLs for longer. A completion event means rows are written, not that images are copied. See known limits . How fresh is the storefront? Rows are visible a few seconds after finishedAt (8.6 s and 39 s were measured on the two large runs). An archived product can still be served for up to 60 seconds from the edge cache."},{"title":"Products: read, archive, restore, rename","route":"/product-catalog/push/products","headings":["Products: read, archive, restore, rename","Read","Naming products","Archive and restore","The 30-day purge","Rename","Categories"],"text":"Product Catalog · Push API Products: read, archive, restore, rename Writing a product is a push ( jobs and batches ). These routes read what you pushed and change its state. They are synchronous, and a vendor key reaches only its own products. Read GET /v1/catalog/products answers one product when you pass exactly one of externalId , sku or handle , and otherwise a page of the caller's products with a nextCursor ( null at the end; limit 1 to 100, default 50). A product that is not the caller's answers 404 , so a vendor cannot tell it exists. Naming products The archive, restore and rename routes name a product with a ref : an object with exactly one of externalId , sku or handle . results: [ index, status ] , status archived , unchanged , held or not_found . ], [ code: POST /v1/catalog/products/restore , code: products : [ref, …] , html: The same shape, status restored , unchanged , held or not_found . ], [ code: POST /v1/catalog/products/rename , code: product : ref, handle : new-handle , html: The renamed product. 409 HANDLE_TAKEN when the handle is in use. ], [ code: PUT /v1/catalog/products/vendor , html: Store key only. vendorId : string | null, products : [ref, …] , at most 100 refs; a null vendorId unassigns. , html: Per product: assigned , unchanged or not_found . ], ] /> Each result carries the index of the product in your request, so one missing product does not fail the rest. Archive and restore Deleting through the API archives . The product leaves the storefront at once (it answers 404 ) and can be restored for 30 days. An archived product stays archived when later pushes resend it, until an explicit restore or a row that sends status: "active" for it. Only the key that may write the product can archive or restore it. A vendor cannot archive the merchant's products. Holds stick. If the merchant archived or drafted the product in the dashboard, the vendor's restore answers held and changes nothing until the merchant lifts the hold (see Vendors and keys ). A push to an archived product keeps updating its data; it just does not bring it back. The 30-day purge A product archived through the API is purged 30 days after the archive. Merchant dashboard archives never purge. What a purge removes: The whole product (its variants, images, reviews as the existing product delete rules do, and its data) when no other non-manual source still supplies one of its variants and it has no archive or draft hold. Otherwise only the archiving source's own values, and the product stays archived. Past orders keep their own copy of what they bought. If the merchant edits an archived vendor product by hand and the purge then removes it, those edits go with it, unless another source supplies the product. Restore a product before the 30 days if its edits matter. Rename Rename changes the URL handle and nothing else: the product keeps its id, its reviews and its inbound links, and the old URL answers a permanent redirect to the new one (the storefront's redirectTo ). A handle is also an ordinary field in a push row, so a row with a new productHandle and the same externalId renames too. 409 HANDLE_TAKEN : another product already has that handle. 409 IDENTIFIER_LOCKED : a rename of a product that has no externalId and that an import or the API supplies. The lock is lifted for any product with an externalId : push the rename by externalId instead. On a vendor-owned product the vendor's rename wins over a handle the merchant set in the dashboard. Categories A product's categories are category ids or paths such as Coffee > Single origin ; missing levels are created. Categories go up to 10 levels deep (a deeper path is refused with CATEGORY_TOO_DEEP ), and the storefront's category depth takes 1 to 10. See Collections, categories and tags ."},{"title":"Jobs, batches and runs","route":"/product-catalog/push/runs","headings":["Jobs, batches and runs","Rows","Merge and replace","Sync jobs","Limits and guardrails","Idempotency","Full sync","Fair turns","Single batches","Runs and progress","Error reports and stock results"],"text":"Product Catalog · Push API Jobs, batches and runs A row describes one variant (a SKU) and the product it belongs to. Rows travel in batches ; many batches make a sync job ; every job and every single batch is applied as a run that you can follow. Rows A row is keyed by sku and grouped into a product by productHandle . A vendor row also carries externalId , the vendor's own product id, which is how the product is found again after a rename. Optional parts: product (title, description, brand, images, categories, tags, options, draft, taxCode ), variant (price, currency, stock, GTIN, dimensions), status ( draft or active ), attributes , prices (other currencies), translations and quantityMode ( set or adjust ). The API reference lists every field and its limits. Images are public URLs; the catalog copies them through its media pipeline (see the image limit ). Categories can be an id or a path, up to 10 levels deep; missing levels are created. Merge and replace Replace needs a product's rows together. All of a product's rows must sit next to each other in one batch . Rows of a product that an earlier batch of the job already claimed, or rows of it that are not adjacent, are refused with SCATTERED_PRODUCT_ROWS , and nothing of that product in the batch applies. The SDK never splits a product across a batch boundary. A row that is refused still counts as sent for the whole job, so its variant is never archived and its values are never withdrawn by a replace. A sent null blanks a field in either mode. A mode is chosen per job (or per single batch), never per row. Sync jobs Open a job with POST /v1/catalog/jobs ( mode , and optionally fullSync ), send batches to POST /v1/catalog/jobs/{jobId}/batches , then close it with POST /v1/catalog/jobs/{jobId}/close . Nothing applies until close. A batch is validated when it arrives (the answer lists refused rows in issues ) and stored. At close the batches apply in the order received, then a full sync runs if requested. The job is all-or-nothing in this sense: until the close, the catalog has not changed. Open for 24 hours at most. A job not closed within 24 hours expires. Its staged batches are discarded, never applied, and the job ends as failed with JOB_EXPIRED (the webhook and the progress view say so). Resend the feed in a new job. States. open (batches accepted), closed (applying or finished; read finishedAt and failureCode ) and expired . Each batch is staged , applied or discarded . Stock in a job applies at close, not on arrival. A staged set re-bases on-hand stock when the job closes. For live stock, use PATCH /v1/catalog/inventory ( Stock ). Limits and guardrails 400 INVALID_REQUEST ], [ Bytes per batch , 5 MiB , html: 413 REQUEST_BODY_TOO_LARGE ], [ Batches per job , About 2,000 (roughly 2 million rows) , html: 409 JOB_FULL ], [ Job lifetime , 24 hours open , html: The job expires and is discarded ( JOB_EXPIRED ). ], [ Open jobs per caller , 3 , html: 409 TOO_MANY_OPEN_JOBS (retryable once one closes or expires). ], [ Waiting batches per caller , 2,000 received but not yet applied, across open and running jobs , html: 429 PUSH_QUEUE_FULL with Retry-After: 60 . , ], [ Request rate , 600 requests per 60 seconds per key , html: 429 RATE_LIMITED with Retry-After . A vendor key is limited on every route; the store key on jobs and products routes only. , ], [ Idempotency-Key , 1 to 200 characters , html: 400 INVALID_REQUEST ], ] /> The store key is one caller: its jobs share one set of caps, and one lane. Send the next batch after a 429 only once Retry-After seconds have passed, with the same Idempotency-Key . Idempotency Send an Idempotency-Key (1 to 200 characters) with every batch. A retry with the same key answers the first answer again instead of staging the batch twice (the replay carries an Idempotent-Replayed header); the same key with a different body is 409 IDEMPOTENCY_CONFLICT . A job batch, a vendor single batch or an inventory request that contains an adjust row must carry one, or the answer is 400 IDEMPOTENCY_KEY_REQUIRED before anything is stored. The SDK makes a key for you when you pass none. Full sync fullSync: true (off by default, never implied) archives the caller's products that are missing from the whole job , once, at close. It never runs per batch. The archive pass is skipped, and the job ends with FULL_SYNC_ARCHIVE_SKIPPED , when any row in the job was refused or failed, or when nothing was seen (an empty job). Nothing is archived on a job that is not clean. A job that expires unclosed is different: it ends failed with JOB_EXPIRED . While a full-sync job is open, it refuses its own source's other writes : another job or a single batch from the same key (and for a vendor, any of that vendor's keys) answers 409 RUN_IN_PROGRESS . Other vendors are not affected. Fair turns Applying takes turns. Every vendor, and the store key, has its own lane, and within one store the lanes take turns, so a small update from one vendor is not stuck behind another vendor's multi-hundred-batch feed. Only a closed job takes a turn. An idle open job never blocks anyone: its staged batches sit in storage, take no turn and apply nothing. Within a lane, work applies one item at a time, oldest close first, so a vendor's single batch waits behind that same vendor's earlier-closed job. A job finishes in the turn that applies its last batch. Single batches For a small update, POST /v1/catalog/variants sends one batch (up to 1,000 rows and 5 MiB) without a job and answers 202 with a run to follow. With a vendor key it has the full push behaviour ( externalId , status, replace, adjust, and a mode in the body). With the store key it is today's endpoint, unchanged: no externalId , status, replace or adjust; use a job for those. A vendor single batch counts toward the same waiting-batch cap as job batches. Runs and progress GET /v1/catalog/runs/{runId} and GET /v1/catalog/jobs/"},{"title":"Admin SDK","route":"/product-catalog/push/sdk-admin","headings":["Admin SDK","Install and create the client","Methods","What the SDK does for you","Images"],"text":"Product Catalog · Push API Admin SDK @usethrottle/catalog/admin wraps the push API: it opens jobs, cuts a feed into batches, waits out a full queue, makes the idempotency keys and follows the run to the end. The admin entry is outside the package's 1.x freeze: a breaking change to it ships as a minor release, named in the changelog, until it is frozen. Every export carries a TSDoc @experimental tag. The HTTP API itself is the stable surface. Install and create the client The admin entry ships in @usethrottle/catalog 1.1.0 and later. createCatalogAdmin({ key }) takes a store key ( pc_sk_ ) or a vendor key ( pc_vk_ ). It is server-only : in a browser environment it throws CatalogError SERVER_ONLY , and it throws INVALID_ADMIN_KEY for any other key (a publishable key gets its own message). The key is never part of an error message. Options: baseUrl , timeoutMs (default 60 seconds) and fetch . Methods What the SDK does for you Idempotency keys. It makes a UUID for every job batch, vendor batch and adjust call that has none, and one retry after a network error reuses it, so a delta is never applied twice. A key you pass is 1 to 200 characters. A full queue. A 429 PUSH_QUEUE_FULL waits retryAfterSeconds , then sends the same batch again with the same key, at most 3 times; after that the RateLimitedError is thrown. wait(). job.wait() and runs.wait() poll every 5 seconds for up to 24 hours ( intervalMs , timeoutMs , signal ). They resolve even when the job failed: read failureCode (or status ). A job that failed client-side is left open and expires after 24 hours; its staged batches are discarded, never applied. A vendor run answer has pushMode beside mode . Stock in a job still applies at close; use inventory.set or inventory.adjust for live stock ( Stock ). Reports: job row numbers are job-wide, and a job whose CSV is above 900 parts answers 409 REPORT_TOO_LARGE ; page stockResults() for stock, and split a very large feed into smaller jobs for error reports. Images Image copies run in two phases. While a job applies, they run alongside it: about 200 to 285 copies per minute at peak in one production run (2026-11-01, Worker e2f3c98a, a single run on one store). Copies still waiting when the job ends are picked up by a sweep that runs every 5 minutes and publishes up to 100 jobs per run, about 20 copies per minute (in the first run, 1,607 leftover copies took about 40 minutes). Until a copy lands, the storefront serves the vendor's own image URL, so keep those URLs public and stable. A catalog also has a budget of 20,000 image resizes per calendar month (roughly 6,700 images), so a feed with more images than that stays partly on vendor URLs for longer."},{"title":"Troubleshooting","route":"/product-catalog/push/troubleshooting","headings":["Troubleshooting","Find your problem","{item.title}"],"text":"Product Catalog · Push API Troubleshooting One section per problem: the exact response, the cause, the fix with a corrected request, and how to confirm it is fixed. Bodies come from real production answers (ids shortened) except where marked as the envelope only. Quote requestId when you ask for help. The full code list is on Errors . Find your problem items.map((item) => ( item.title ( where(item.status) ) )) items.map((item) => ( item.title Where you see it: where(item.status) . item.response !== && ( ) Cause. item.cause Fix. item.fix requests[item.id] !== undefined && Confirm. item.confirm ))"},{"title":"Vendors and keys","route":"/product-catalog/push/vendors","headings":["Vendors and keys","Vendors","What a vendor key can reach","Ownership, ids and clashes","The vendor's push wins","Holds: what the vendor cannot undo","Vendor on orders"],"text":"Product Catalog · Push API Vendors and keys A vendor is a supplier whose products the merchant sells. The merchant creates the vendor and its keys; the vendor never logs in to anything and talks to the catalog only through the API. Vendors The merchant adds a vendor under Storefront › Vendors in the Product Catalog dashboard: a name (creating takes a name only), and a status of active or paused . The webhook URL is set separately, under Storefront › Vendors › webhook. A store holds at most 200 vendors. A vendor cannot be deleted; revoking its keys and archiving its products covers it. Each vendor has at most 2 active keys ( pc_vk_ ), so a key can rotate. A key is shown once when created; only a hash is kept. Revoking or pausing applies on the next request. A paused vendor's keys answer 403 VENDOR_PAUSED before the body is read. Its products stay exactly as they are. What a vendor key can reach A vendor key reads and writes its own products only : products and variants, status (draft or active), handle, archive and restore, stock, prices in the store's currencies, images by public URL, attribute values, translations and categories (a path such as Coffee > Single origin creates missing levels). It also reads its own products, jobs, runs and reports. It cannot touch another vendor's or the merchant's products, set customer-group (trade) prices, create new attribute types, or change collections, filters, price lists, locales, settings, keys, checkout or reviews. Anything outside its reach answers 404 (a resource that is not its own) or 403 VENDOR_KEY_NOT_ALLOWED (a route a vendor key may not call). The store key works on every push route and may act on any product, including assigning a product to a vendor. Ownership, ids and clashes Every product has one owner: a vendor, or none (the merchant). Ownership never changes through a vendor key. externalId is the vendor's own product id, unique per vendor. Two vendors may use the same id for two different products. The store key's own products have a separate id space. A row that creates a vendor's product needs one ( EXTERNAL_ID_REQUIRED ). SKUs stay unique per store. A row whose SKU belongs to another vendor's or the merchant's product fails with SKU_CONFLICT and never takes the SKU over. Adopting existing products. Products already in the catalog (CSV, dashboard) stay merchant-owned until the merchant assigns them to a vendor, one or in bulk. The vendor's first push then links each assigned product by SKU and records its externalId . A push never claims an unassigned product. A new product with no status is created as a draft . Send active to publish it; the vendor decides whether a product shows. Vendor batches are canonical rows . A mapping profile cannot set externalId , so a vendor batch that needs one must not use a profile. A full sync with the store key archives the store's products that its job did not send. A product the merchant has just assigned to a vendor still has the store as its only supplier until the vendor's first push, so that full sync may archive it. Once the vendor supplies the product, a store-key full sync no longer touches it. Assign products after the store's full sync, or let the vendor push first. The vendor's push wins On a vendor-owned product, a field the vendor sends ranks above the merchant's dashboard edit and above every other source. That includes a rename : the vendor's handle replaces the merchant's. A sent null blanks the field. Store-key pushes keep today's precedence: a merchant's dashboard edit still wins over them. Replace mode drops only the caller's own values for fields it did not send, so another source's value (the merchant's) shows instead, and archives only variants the caller supplied. Nothing the merchant added by hand is touched. A replace needs all of a product's rows together, adjacent in one batch (see Merge and replace ). Holds: what the vendor cannot undo If the merchant archives or drafts a vendor product in the dashboard, that is a hold. The vendor's pushes keep updating the product's data, but cannot republish or restore it until the merchant lifts the hold. This lets a merchant stop selling one vendor product without pausing the whole vendor. An archive made through the API is different: it is the vendor's own and is lifted by a restore, or by a row that sends status: "active" (see Products ). The result of a held product is held in an archive or restore answer. Vendor on orders The merchant can switch Vendor on orders on in the dashboard (it is off by default). Then each Throttle order line created from a catalog cart for a vendor-owned product carries the vendor under the reserved metadata.lineResolver key, which merchant order reads and merchant order webhooks return (for drop-ship routing, for example): Only resolver carts record a vendor, the carts the catalog prices through the line resolver . The legacy checkout records no vendor on an order line, and cannot, because it cannot write that reserved key. The vendor is never part of a buyer's view of the cart or order."},{"title":"Webhooks","route":"/product-catalog/push/webhooks","headings":["Webhooks","Set one up","Events","Verify the signature","Retries"],"text":"Product Catalog · Push API Webhooks Instead of polling a job, give the catalog a URL and it tells you when the work is done. One event per finished job or batch, signed, and retried if your server is down. Set one up The merchant sets a webhook URL per vendor, and one for the store key, in the Product Catalog dashboard ( Storefront › Vendors ). Creating an endpoint shows its signing secret ( pc_whsec_… ) once; store it with your other secrets. The URL must be https , with no explicit port, credentials or fragment, no IP literal, no single-label host and no special-use host ( localhost , .local , .internal , .test , .example , .invalid ), and at most 2,048 characters; otherwise it is refused with 422 WEBHOOK_URL_INVALID . Redirects are not followed, and each delivery times out after 3 seconds. Events fire only for sync jobs and vendor single batches . Upload runs and the store key's single batch send none. Events data.runId is the job id for a job. The counts are the same as the progress view and the error report is at …/errors.csv until reportExpiresAt . An event can be delivered more than once: dedupe on its id . The event says the rows are written, not that images are copied. Copies continue afterwards; until a copy lands the storefront serves the vendor's own image URL, so keep those URLs public (see known limits ). Verify the signature Every delivery carries X-Catalog-Signature: t=<seconds>,v1=<hex> , where v1 is the HMAC-SHA256, with your secret, of <t>.<rawBody> . The header can hold up to eight v1 digests (any match passes, so a secret can rotate). Reject a timestamp more than 300 seconds old. Check the raw body text, before any JSON parsing. verifyCatalogWebhook uses WebCrypto only and returns true or false ; it never throws. Answer any 2xx quickly and do the work afterwards. Retries A delivery your server does not accept with a 2xx is tried again. The retry schedule is a list of gaps after the previous attempt , in seconds, not offsets from the first: [ String(index + 1), index === 0 ? None: at once : gap >= 3600 ? $ gap / 3600 h : $ gap / 60 min , String(gap), ]) /> That is GAPS.length attempts over about TOTAL_HOURS hours in all. A receiver that is down longer than that misses the event. The run is still there to poll ( GET /v1/catalog/runs/{runId} or GET /v1/catalog/jobs/{jobId} ), so reconcile from the API after an outage."},{"title":"Product Catalog Quickstart","route":"/product-catalog/quickstart","headings":["Product Catalog Quickstart","1. Install the SDK","2. Create keys","3. Run your first query","From your website","With curl","Next steps"],"text":"Product Catalog Product Catalog Quickstart From nothing to a list of your products in three steps. You need a Throttle application with the Product Catalog extension installed and some products in it. 1. Install the SDK Node.js 20 or later, or any evergreen browser. In TypeScript, moduleResolution must be node16 , nodenext or bundler : the package has only an exports map, which the older node setting cannot read. 2. Create keys Storefront calls use publishable keys ( pc_pk_… ). Create them in the catalog dashboard: Open the Product Catalog extension in your Throttle dashboard and go to Storefront › Keys . For your website, create a key with type Browser and put your site's origin under Allowed websites , one per line, such as https://shop.example.com . Origins are matched exactly and must be https. For Node.js, a server or curl , create a second key with type Server . It has no websites: it refuses any request that comes from a browser. Copy each key when it is shown. Store the server key in an environment variable. A browser key cannot list http://localhost : allowed websites are https only. While you develop, call the catalog from your dev server with the server key, or serve your site over https (for example https://localhost:8443 ) and list that origin. Keys and channels explains the three key types and what each one protects. 3. Run your first query This script lists five products with their lowest price. A script sends no Origin , so it uses the server key. It prints one line per product: its handle, its title and its lowest price in minor units ( 4999 is $49.99). The client talks to https://catalog.usethrottle.dev by default and gives up on a call after 10 seconds. From your website The same call in a page, with the browser key. It works only from an origin on the key's allowed websites. With curl The API is plain HTTP. Send the key in X-Catalog-Key . Use the server key here too: a browser key refuses a request without an allowed Origin ( 403 ORIGIN_NOT_ALLOWED ). Next steps Products, filters and sorting : build a shop page with filters, facets and paging. Cart and checkout : send the shopper to Throttle's checkout. React and Next.js SSR and caching : hooks, a cart and server rendering. Pushing products in : the Catalog Push API puts a merchant's or vendor's products into the catalog; start with the first feed guide."},{"title":"React","route":"/product-catalog/react","headings":["React","CatalogProvider","The read hooks","useTags, useCollections and useReviewEligibility","Keeping previous data","Refresh errors","The cache","initialData: hand over a server answer","useCart","Customer carts","useSubmitReview"],"text":"Product Catalog React @usethrottle/catalog/react wraps the catalog client in a provider and hooks, with a small cache, a session-backed cart and review submission. It works with React 18.2 and 19 (an optional peer dependency), is marked use client , and needs no other library. CatalogProvider createCatalogClient . Required. ], [ code: customerKey , html: The signed-in customer's id, or null . It partitions the cache and the cart by customer. Required whenever customerToken can return a token. , ], [ code: cartStorage , html: Where useCart keeps the cart session id: get, set, remove , each sync or async. The default keeps it in memory only. , ], [ code: cartUrls , html: returnUrl?, cancelUrl?, storefrontUrl? , sent on every cart useCart creates, so each must be an absolute https URL on an allowed return website (otherwise leave them out and pass URLs to checkout(input) ), and used by its checkout() . See return URLs . , ], [ code: throttleBaseUrl , html: Throttle's API, for the cart session. Default https://api.usethrottle.dev . , ], [ code: throttleCheckoutUrl , html: Throttle's hosted pages, for a cart link the hook builds itself. Default https://checkout.usethrottle.dev . , ], ] /> Keep one client per currency and language , memoised on both; on a currency switch run useCart().switchCurrency() first and swap the client once it resolves. Pass customerToken as a function. With @usethrottle/auth 0.10.1 or later that is () => auth.getAccessToken() , read on every request. A new client per customer would start a new cart. Change customerKey in the same update as the token. Reading the customer from useAuth() , as above, does that. A change starts an empty cache, so every mounted hook fetches again and one shopper never sees prices fetched for another. It is required whenever customerToken can return a token: the hook drops the cart on a sign-out or a customer change only by watching it, so without it one customer's cart stays for the next shopper. In the browser, set keyInQuery: true on the client so reads need no preflight; see Keys and channels . useCatalog() answers the provider's client, for a call no hook covers. The read hooks product , or redirectTo for a renamed handle. , ], [ code: useRelated(handle, limit? ) , code: products.related , html: products ], [ code: useFilterValues(key, query?, options?) , code: filters.values , Every value of one filter for a selection. ], [ code: useCategories( depth? ) , code: categories.tree , The category tree. ], [ code: useCollection(handle) , code: collections.get , html: The collection page, or redirectTo . , ], [ code: useSuggest(q, debounceMs?, limit? ) , code: suggest , Suggestions. No request under 2 characters; one per pause in typing (200 ms by default). , ], [ code: useReviews(handle, sort?, rating?, limit?, cursor? ) , code: reviews.list , html: Reviews and their summary, or redirectTo . , ], [ code: useConfig(options?) , code: config , Currencies, locales, sorts, the review settings and the generation. ], ] /> Every read hook (and the three in the extras entry ) answers data, dataFor, error, isLoading, isPreviousData, isStale, isValidating, refetch : data : the answer, typed as the client's call answers it, or undefined . error : the CatalogError of the last attempt, or undefined . A failed refresh keeps the last data . isLoading : no data and no error yet. isValidating : a request for this key is in flight, first load or refresh. refetch() : fetch again now. It never rejects. They take enabled ( false makes no request), initialData and staleTimeMs . A null handle or key makes no request either, which is how one hook waits for another. Each hook also answers dataFor (the query the shown data answers), isPreviousData and isStale , and takes keepPreviousData ; see Keeping previous data and Refresh errors . useTags, useCollections and useReviewEligibility These three hooks (1.2.0 and later) live in @usethrottle/catalog/react/extras , which keeps the /react entry small. They use the provider and the cache of /react : import CatalogProvider from /react and these from /react/extras . tags ], [ code: useCollections( limit?, cursor? ) , code: collections.list , html: collections ], [ code: useReviewEligibility(handle) , code: reviews.eligibility , html: Whether the signed-in shopper may review the product ( canReview ). No request while signed out or while handle is null ; asked again on every mount and kept per customer. , ], ] /> useCustomerKey() , from /react , answers the provider's customerKey . The ReviewEligibility type, the answer of reviews.eligibility , is exported from either entry. Keeping previous data keepPreviousData: true keeps a hook's last settled answer on screen while the next key loads, so a filter change or a typed query does not flash empty. isPreviousData is true while data is the old answer. isLoading stays false meanwhile. dataFor is the params object the shown data answers: for useSuggest the query echo is dataFor.q . While data is undefined (a first load, or a new key without keepPreviousData ) it is the params just asked for, so check data before treating dataFor as its params. If the new key fails, error is set and the old data stays, with isPreviousData still true . isStale is for revalidation failures of the data's own key. Data is never kept across a customerKey change: one customer's answer is not shown to the next. Refresh errors isStale is true while the shown data was not confirmed by the latest attempt: a revalidation failed, or an initialData seed under a customerKey is still being revalidated. A failed revalidation keeps the data and sets error . A success clears both. Retries. The hook retries by itself only when the failure is retryable: a NetworkError , a TimeoutError or a 5xx. The retries come 2 seconds after the failure and again 10 seconds after that, so two retries. Other 4xx answers are not retried, and a 429 keeps its pause with no extra retry"},{"title":"Product reviews","route":"/product-catalog/reviews","headings":["Product reviews","The settings","List reviews: reviews.list","May this shopper review? reviews.eligibility","Submit a review: reviews.submit","Review errors","Show review text safely"],"text":"Product Catalog Product reviews Shoppers signed in with their Throttle customer account can review the products they bought. The storefront lists published reviews and their summary, checks whether the shopper may write one, and submits it. The settings The merchant turns reviews on in the Product Catalog dashboard, on the Reviews screen: pending ) until the merchant approves them. ], [ Ratings on product lists , html: List answers carry rating: average, count , and the best_rated sort and rating filters become available. , ], [ Show imported reviews , Reviews imported from a file are shown and counted. They are never marked verified. ], ] /> The first time reviews are turned on, “Verified buyers only” and “Hold new reviews for approval” come on with it, and the catalog looks through the last 365 days of orders for buyers. A full product carries rating while reviews are on; config() answers reviews: enabled, onLists . List reviews: reviews.list reviews.list(handle, options) answers the product's published reviews, a summary and nextCursor : sort : newest (the default), highest or lowest . rating : 1 to 5, an exact match (4 means four-star reviews, not “4 and up”). limit 1 to 50 (default 10), and cursor for the next page. summary is average, count, histogram : the average rating ( null with no reviews), the number of reviews, and the number per star from 1 to 5. Each review has rating , title , body , displayName , verified (a verified buyer), imported , createdAt and the merchant's reply (or null ). A renamed product answers redirectTo . An unknown product, or reviews turned off, is 404 NOT_FOUND . Review lists are public and cached like the rest of the catalog. In React, useReviews(handle, sort, rating, limit, cursor ) reads them. May this shopper review? reviews.eligibility reviews.eligibility(handle) needs the shopper's customer token (the client's customerToken , see Customer prices ) and answers canReview, reason? . The reasons, checked in this order: Eligibility is a quick check. It does not read the order again, so a purchase refunded since can still answer canReview: true ; the submit then refuses it. Submit a review: reviews.submit reviews.submit(handle, rating, title?, body, displayName ) needs the customer token too. rating is 1 to 5; title is at most 120 characters, body 1 to 5,000 and displayName 1 to 60. It answers 201 review: id, status : A verified email is always required , even with “Verified buyers only” off. With Verified buyers only on, the shopper needs a paid order for the product, and the catalog reads that order from Throttle again before it accepts the review. One review per customer and product. A rejected review keeps the slot; only the merchant deleting it frees it. With Hold new reviews for approval on, the review is pending and appears only once approved; otherwise it is approved and shows at once. Send an idempotencyKey and reuse it when you retry the same submission: a replay answers the first review instead of making a second. useSubmitReview does this for you (see React ). A refused token throws CustomerTokenInvalidError : there is no signed-out fallback for a review. Review errors Retry-After: 900 ; the SDK throws it as RateLimitedError ; it never pauses the React provider's reads. , ], [ code: RATE_LIMITED , 429 , More than 5 submissions a minute from one customer, or 10 accepted reviews an hour from one IP address. Retry-After says when. , ], [ code: REVIEW_VERIFICATION_UNAVAILABLE , 503 , The catalog could not read the order from Throttle to confirm the purchase. Nothing was saved; retry (Retry-After: 10). , ], [ code: EXTENSION_UPGRADE_REQUIRED , 409 , The store has not accepted the Product Catalog extension version that reads customer accounts. , ], [ code: CUSTOMER_TOKEN_INVALID , 401 , No customer token, or one the catalog refuses. ], [ code: NOT_FOUND , 404 , No such product, or reviews are off. ], ] /> Show review text safely A review's title, body, display name and reply are plain text written by a shopper or the merchant, stored as given. The API never turns them into HTML and never cleans them: your storefront must escape them when it renders them. React escapes text in JSX for you; never pass review text to dangerouslySetInnerHTML or innerHTML ."},{"title":"Search and suggestions","route":"/product-catalog/search-and-suggestions","headings":["Search and suggestions","Search results","Suggestions","Search as you type in React"],"text":"Product Catalog Search and suggestions Two calls cover search: products.list with q for a results page, and suggest for the dropdown under a search box. Search results A search results page is a product list with q (up to 200 characters). It matches product titles, brands, product types, Google categories, public attribute values and descriptions, plus SKUs, GTINs and MPNs. With a locale , it also matches the locale's translated titles and descriptions. Everything else on products.list combines with it: filters and facets (with counts for the search), a category or collection, paging and fields. Without a sort , results come by relevance . Any other sort works too. Relevance pages stop at 1,000 products deep: past that the page is empty and page.capped is true . Shoppers rarely go that far; offer filters instead. The answer's applied.q echoes the search. Keep the search in the page URL with the URL helpers ; q is part of the shop query they read and write. Suggestions suggest(q, limit ) ( GET /v1/storefront/suggest ) answers what matches the start of what the shopper typed. q must be 2 to 100 characters; anything else answers 400 INVALID_REQUEST . limit : 6 by default, at most 8 , ], [ code: categories , handle, name and productCount , Up to 5 ], [ code: brands , name and productCount , Up to 5 ], [ code: tags , tag and productCount , Up to 5 ], [ code: collections , handle, title and productCount , Up to 5 ], ] /> Categories, brands, tags and collections match by case-insensitive prefix and come most products first. Every name comes from public products only, so a draft title, a hidden attribute or an archived brand never shows up. Suggestions take the client's currency and locale . They always show list prices: a suggestion request never carries the customer token, so a signed-in shopper's group prices (see Customer prices ) appear on product pages and cards, not here. One request per keystroke pause. Without keyInQuery the answer is cached under the guest partition. With keyInQuery: true (1.2.0 and later), suggest bypasses the SDK's ETag cache and sends no If-None-Match : the request carries only accept plus any client headers you set, so it needs no preflight (a client with custom headers preflights again). See Keys and channels . Link each kind where it belongs: a product to its page, a category or collection to its page, a tag to products.list( tags: [tag] ) , and a brand to a shop page filtered by your brand filter (its key is whatever the merchant named it in Storefront › Filters). Search as you type in React useSuggest(q, debounceMs, limit ) from @usethrottle/catalog/react sends no request while the trimmed text is shorter than 2 characters, and one request per pause in typing: debounceMs , 200 ms by default. Answers are cached in the provider, so typing back to an earlier text shows its suggestions at once. Like every hook, it answers data, dataFor, error, isLoading, isPreviousData, isStale, isValidating, refetch ; with keepPreviousData: true the last suggestions stay while the next ones load, and dataFor.q is the text they answer. Suggestions have their own rate limit for browser, app and legacy keys: 600 requests a minute per visitor, twice the limit of the other routes, so a fast typist is not cut off. The React page covers the provider and the cache."},{"title":"Stock and availability","route":"/product-catalog/stock-and-availability","headings":["Stock and availability","What the API tells you","A quantity the store cannot fill","Reserve stock at checkout","Oversold orders"],"text":"Product Catalog Stock and availability The storefront API never answers a stock quantity. It tells you whether a product or variant can be bought and whether stock is running low, and the cart refuses a quantity it cannot fill. What the API tells you in_stock , out_of_stock , preorder , backorder or null (not set). , ], [ code: lowStock , Products and variants , A tracked variant at or below the store’s low-stock threshold. A product whose stock is all held in checkouts answers false (it is out of stock instead). , ], [ code: purchasable , Full products and variants , Public, priced in the answer’s currency, and available or backorderable. , ], ] /> That is all: no counts, ever. A storefront can show “In stock”, “Only a few left” or “Sold out”, never “3 left”. The one number the API answers is maxQuantity , and only when a requested quantity is refused (below). Stock is counted only for variants that track inventory, and a tracked variant that allows backorders is never refused for stock. A quantity the store cannot fill carts.check , carts.create and every change to a resolved cart judge each line against the stock available right now. A tracked line that wants more than that is refused with QUANTITY_UNAVAILABLE and maxQuantity , the most that can be ordered now (also capped by the variant's maximum order quantity). A line with none on hand is OUT_OF_STOCK . On carts.check the refusal is a line of the answer ( ok: false ). On carts.create it is 422 CART_ITEMS_INVALID , and on a cart session Throttle's 409 quantity_unavailable ; the SDK throws both as CartLineError , whose lines carry maxQuantity . maxQuantity can be 0 while units are still on hand: with reservations on, other shoppers' checkouts can hold the last units (the variant then reads out_of_stock until a hold lapses). Say so (“In other shoppers' checkouts right now”) rather than “Sold out”: a hold that is not paid for lapses within minutes. Reserve stock at checkout Reserve stock at checkout is a per-store setting in the Product Catalog dashboard, under Storefront › Settings › Stock . It is off by default ; with it off, stock is the quantity you import or enter, and nothing below happens. With it on, the catalog keeps its own count: Holds at checkout. When a shopper starts checkout from a catalog cart, the catalog holds the cart's tracked lines for 15 minutes , only if every line fits; otherwise it holds nothing and refuses the lines that do not fit. Starting checkout again replaces the cart's holds. A cart's own holds never count against it. Holds lapse on their own. An unpaid hold simply expires; nothing has to release it. An abandoned cart releases its holds earlier when Throttle reports it. Sales. An order takes its catalog lines off stock and releases its cart's holds. Cancels and returns. A cancelled order puts its lines back, and so does a return once it is received with restocking on. A refund of money alone changes no stock. Counting starts from the quantities you have when you turn it on; earlier orders never move stock. A new quantity from an import or the API becomes the new starting point. Turning it on needs the Product Catalog extension version that reads carts and returns; the dashboard says so if the store has not accepted it yet. Oversold orders The order is the truth. A paid order is never refused or changed because of stock: its sale is always recorded, even when that takes a variant below zero. This can happen because Throttle's checkout session lasts 30 minutes, longer than the 15-minute hold, so a shopper who pays late can buy units another checkout has since taken. The product's Inventory card and the Products list show Oversold by N for a variant that sold N more units than it has. The merchant fixes it by restocking, with “Adjust stock”, or by cancelling an order, which puts its units back. See Cart and checkout for every cart error, and Line resolvers for how Throttle asks the catalog to check stock."}]}
|