@usethrottle/mcp 0.2.2 → 0.4.0
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/dist/contract-index.json +1 -1
- package/dist/docs-index.json +1 -1
- package/dist/lib.cjs +70 -19
- package/dist/lib.cjs.map +1 -1
- package/dist/lib.js +70 -19
- package/dist/lib.js.map +1 -1
- package/dist/mcp.js +70 -19
- package/dist/mcp.js.map +1 -1
- package/package.json +3 -3
package/dist/docs-index.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"generatedAt":"2026-09-28T11:14:49.523Z","entries":[{"title":"Docs","route":"/","headings":["Docs","{section.label}"],"text":"Documentation Docs Look up how one thing works. Each page covers a single feature — what it does, how it behaves at the edges, and where it hands off to something else. Want to be walked through a job end to end instead? Start with Guides . Integrating the API? Developer docs . Building an app for the marketplace? Extensions . sections.map((section) => ( section.label section.blurb section.items.map((doc) => ( doc.data.title doc.data.description )) )) .hub max-width: 820px; margin: 0 auto; padding: 48px 24px 96px; .hub-head margin-bottom: 52px; .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: 60ch; margin: 0 0 14px; .hub-aside font-size: 15px; color: var(--muted); .hub-aside a color: var(--blue); .hub-section margin-bottom: 44px; .hub-section h2 font-size: 20px; letter-spacing: -0.02em; margin: 0 0 4px; .hub-blurb font-size: 14.5px; color: var(--muted); margin: 0 0 16px; .hub-list list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule-soft); .hub-list li padding: 14px 0; border-bottom: 1px solid var(--rule-soft); .hub-list a display: block; font-size: 16px; font-weight: 600; color: var(--ink); text-decoration: none; margin-bottom: 3px; .hub-list a:hover color: var(--blue); .hub-list span font-size: 14.5px; line-height: 1.55; color: var(--muted);"},{"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","Native cart endpoints","{title}"],"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","Endpoints","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 publishable quote token ( pk_… — the same token used for shipping/tax quotes) from an allowed origin. 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 . 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. 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 the cart.abandoned sweep and recovery email described in Cart states . Shipping & discounts Fetch live rates from POST /api/v1/shipping-tax/quotes (same publishable token), then select a method on the session. Apply a promo code the same way. /shipping \\\\ -H Origin: https://shop.example.com -H Content-Type: application/json \\\\ -d methodId : fedex_ground , displayName : FedEx Ground , rateAmount : 599 # Apply a discount code curl -X POST https://api.usethrottle.dev/api/v1/cart-sessions/cart_ /discount \\\\ -H Origin: https://shop.example.com -H Content-Type"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","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)","2026-09-21 — Completing a session with paymentMethod \"card\" is now refused","2026-09-20 — Read a checkout session back from the SDK","2026-09-14 — PayPal connector form explains its fields","2026-09-12 — Paid orders can no longer be set back to Draft or Pending","2026-09-11 — Mint an update-card link from the API","2026-09-11 — Renewal-failed emails link to the hosted update-card page","2026-09-11 — Hosted \"update your card\" page","2026-09-11 — Buyer-scoped subscription routes for the update-card flow","2026-09-11 — Deposit terms on any quote revision","2026-09-11 — Overdue renewals stand out in the subscriptions list","2026-09-11 — Paying at the end of a trial unlocks Production","2026-09-11 — Ship part of an order from the dashboard","2026-09-11 — A \"not shipped yet\" orders filter","2026-09-11 — Undo a scheduled cancel while past due","2026-09-11 — Recording delivery completes the fulfillment","2026-09-11 — Signup charge counts as the first payment","2026-09-11 — Merchants can decline a quote request","2026-09-11 — Partner referrals report handoff progress","2026-09-11 — Billing writes are owner-only for dashboard sessions","2026-09-10 — Endpoints that stop verifying are reported in minutes, not days","2026-09-10 — Signing-secret rotation with a grace window","2026-09-10 — An example delivery for every event","2026-09-10 — Envelope evolution policy, after a strict-schema outage","2026-09-09 — Envelope says which environment kind; replays keep their timestamp","2026-09-09 — Extensions are told when an installation ends","2026-09-09 — Script external domains: one wildcard label, and CSP blocks are reported","2026-09-08 — Fulfillment events carry the buyer; shipped fires on create"],"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-09-28 — Metered usage lines, stuck-renewal visibility, paid-based credits Metered usage bills as its own line item type (additive). A renewal order that billed usage now carries usage line items alongside the plan line, needing no fulfilment ( deliveryMethod: "none" ); the order's own type stays recurring . usage is written by the renewal job only — it appears in order responses, never in a request body: carts, quotes, and order line-item edits still reject it. New read-only renewalIssue field on subscriptions (additive). Set only by the renewal job; PATCH /api/v1/subscriptions/{id} with it is 400 , and it is absent from the buyer-facing storefront response. A stable reason code ( tax_quote_failed , internal_error , or record_failed ) comes with a fixed message sentence — never raw error text. kind: "system_error" counts consecutive renewal failures and alerts (one in-app admin notification, one Sentry error) at the 3rd, with no status or dunning change. kind: "orphan_charge" means a renewal charge succeeded but could not be recorded; it alerts immediately and pauses every renewal path on that subscription — the cron, merchant retry-charge , merchant waive-period , an immediate change-plan or change-quantity , and the buyer's own retry-charge all return 409 renewal_needs_attention until Throttle support resolves it. See Lifecycle and States . Plan-change, seat-change, and prorated-cancel credits are now based on what was actually paid (behavior change). Credits and refunds are capped at the net of the current period's paid (or partially-refunded) subscription_invoices row, not always the plan price. A waived, free, or fully-credited period credits 0 . A prorated cancel ( refund: "prorated" on POST /api/v1/subscriptions/{id}/cancel ) with no payment on the current period now returns 422 invalid_state instead of refunding the plan price. A buyer who paid full price sees no change. Subscriptions with no invoice history at all (created before invoice recording, June 2026) keep the old plan-price behavior. Packages. @usethrottle/api-client 5.1.0 types the usage line item type and the read-only renewalIssue field (with its reason codes). Upgrade recommended: @usethrottle/auth 0.5.0 shows Pause, Resume and Cancel on installment plans, which the API refuses; 0.6.0 hides them and adds installment progress. Update with pnpm add @usethrottle/auth@^0.6.0 . 2026-09-26 — Custom billing cadence and installment plans Any cadence, not just five intervals (contract change). Send interval (the existing named values, now including custom ), or intervalUnit ( hour | day | week | month ) + intervalCount for any cadence from 1 hour to 1 year, on POST /api/v1/subscriptions , .../change-plan , and the checkout recurring intent. The server canonicalizes to the largest exact unit and names it when a named interval matches (every 14 days → biweekly ). Conflicting input is 400 interval_conflict ; out-of-bounds or malformed input is 400 invalid_interval . Every subscription response now carries interval , intervalUnit , and intervalCount . Installment plans (contract change). Set totalPayments (2–60) on create or on the checkout recurring intent to make a subscription a fixed number of payments that ends itself. The paid signup is payment 1; every later charge and every waived period counts (refunds don t); the plan becomes the new terminal status completed the moment payment N succeeds — not at the end of the period it covers — and emits the new subscription.completed event. Merchants who gate access on status = active must treat completed as paid in full. No trial together with it ( 400 invalid_combination ); its amount, cadence, quantity, and plan are locked ( 409 installment_plan_locked ); totalPayments cannot be changed after creation ( 400 total_payments_immutable ). The merchant can pause, resume, and cancel one; a buyer session cannot ( 403 installment_plan_merchant_only ) — the buyer portal shows progress only. New status unpaid . Accepted by the status schema and both published SDK type unions for forward compatibility; not written by the subscription renewal engine today. Fixed a month-end billing drift bug. Month-based cadences are now computed from a fixed billing anchor instead of chaining from the previous period, so a subscription anchored on Jan 31 correctly renews Feb 28 (29 in a leap year), then Mar 31 — it no longer drifts onto the wrong day after crossing a short month. A subscription that falls behind is billed once, not caught up. A subscription more than one period behind (an outage, a stalled retry) is now charged once, for whichever grid period contains the moment of the charge; the periods it missed are skipped rather than billed back-to-back, on every cadence, named ones included. Dunning retries now scale with cadence. Retry n waits min(n × cadence, [1, 3, 7][n − 1] days) — weekly and longer cadences are unchanged at 1 / 3 / 7 days; shorter custom cadences retry proportionally faster. The pre-renewal reminder email now fires only for cadences of 7 days or longer. payment.vaulted and subscription.create_failed payloads (additive). Their recurring object now includes intervalUnit , intervalCount , and totalPayments when the session specified a custom cadence or an installment plan. React package ( @usethrottle/subscriptions ). New cadenceNoun , cadenceLabel , and installmentProgress helpers, the installment list filter, and the new Subscription fields above. Upgrade note. Integrators on an SDK version released before this date — for example @usethrottle/subscriptions 1.x or @usethrottle/auth 0.5.x — keep working against the new API without crashing, but degrade: a custom cadence renders as an unlabeled raw value (e.g. "/"},{"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 . 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","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 , the iframe renders the shipping address step and /complete requires shippingAddress . , ], [ code: collect.billingAddress , code: boolean , code: false , html: When true , the iframe emits a step: 'billing' postMessage and /complete requires billingAddress (or billingSameAsShipping: true ). , ] ] /> Omitting collect is equivalent to shippingAddress: true, billingAddress: false . Existing integrations that don't opt in keep the old shape. 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. If a credential from one workspace environment accesses a resource from another environment, Throttle returns 403 environment_mismatch . 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","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 . 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. 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. 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 dashboard pings your webhook right after install, signed with the secret it just minted — before an operator could have handed that secret to your service. A correctly implemented endpoint therefore answers 401 once. Recover without a human: on a verification failure, fetch the current secret with GET /api/v1/installations/:id/webhook-secret (authenticated as the installation), cache it, and re-verify. The ping then passes on the merchant s retry, and a rotated secret heals the same way. You are told when your endpoint stops accepting deliveries Two signals go to the publisher of the extension and to the admins of the installing application, by email and in the dashboard inbox, without anyone watching a delivery log. Unauthorized streak. The moment an endpoint has answered three consecutive deliveries with 401 or 403 — on any attempt, so retries of one event count — an alert names the endpoint, the last event type and the status. It fires once per streak. Retrying does not fix an authorization failure: the receiver is verifying with a stale secret or rejecting an envelope field it does not recognise. Deliveries keep retrying and then dead-letter; replay them once the endpoint accepts again. Signed probe. Every active installation s endpoint receives an extension.ping envelope two minutes after each Throttle deploy and every six hours. It is built by the same code as a real delivery — same body shape, headers and s"},{"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"],"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."},{"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 } }","}`;"],"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. 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. 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 stop receiving events and their launch-token endpoint returns 409 installation_not_active . Delivery logs are accessible at GET /api/v1/installations/:id/deliveries . Uninstall Uninstalling removes the install row for the current application and environment. The extension catalog entry and other applications' installs are not affected. The API key and signing secret issued at install time are immediately revoked. The extension is told. "},{"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. 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 definition and its versions) is shared across environments."},{"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}"],"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 . 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 def.sensitive && ( (sensitive) ) )) )) The complete scope catalog, including non-extension scopes, is at API key scopes and served live at GET /api/v1/scopes ."},{"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","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. Only published versions may be referenced in an install call. Attempting to install a draft version returns 422 draft_not_installable_in_live for production environment callers (and similarly for non-production environments). Publish the version first. 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. 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 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). 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) . 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","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. 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. When tax is filed on invoice terms For a card sale the tax document is filed when the money moves. For an invoice on net terms it is filed when the invoice is issued — the tax point for term billing is the moment the invoice is raised, not the moment it is paid. So an invoice issued in January and paid in March belongs in January's filing. Throttle commits the document as soon as the invoice is authorized, while the payment is still unpaid, and you will see a committed tax document against an invoice that has not settled yet. That is correct, not a bug. Filing early does not lock the document. Cancelling the order or refunding the payment voids or credits the filed document the same way it does for a card sale. REST routes quick reference All routes require invoices:read scope on the API key. The download route returns a 302 redirect to a short-lived signed S3 URL by default; pass ?format=json to get the URL as a JSON response instead. Pages in this section React Package — @usethrottle/invoices hooks, provider, and server proxy reference."},{"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. 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, and a pre-flight check of a body against the published OpenAPI contract. api_get — a GET-only escape hatch for any /api/v1 path the curated tools do not cover. It cannot write. Local server only. get_order , get_customer , search_orders , search_customers , list_subscriptions , search_quotes , list_invoices , receivables_aging — commerce reads, with related records inlined where an answer usually needs them. search_orders , search_customers , list_subscriptions and search_quotes also accept createdAfter / createdBefore"},{"title":"Metadata","route":"/developers/metadata","headings":["Metadata","Resources that accept metadata","Customers","Subscriptions","Carts and line items","Fulfillment hints on line items","Orders and payments","Checkout sessions","On checkout sessions","On webhooks","Conventions","Dashboard exposure","Next","Exact match on metadata.subscription_id. 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. For older integrations, requiresShipping: false is treated as digital unless requiresFulfillment: false is set. Orders and payments Checkout sessions The recurring block is stored under metadata.recurring for the auto-create flow. You can stack your own metadata alongside without conflict. On checkout sessions Session metadata is capped at 50 keys and a serialized payload of 10KB . Requests over either limit return 422 metadata_too_large . When a checkout session converts into an order, Throttle merges metadata from three sources with this precedence (later sources win on key collisions): Cart metadata — set via /api/v1/carts at cart create time. Session metadata — set via /api/v1/checkout/sessions . Overwrites colliding keys from the cart. Customer enrichment — Throttle stamps customerEmail on the order metadata when the buyer's identity is known. This always wins. The following keys are reserved by Throttle and are stripped from the metadata bag before persistence (they are not rejected — the rest of the payload is accepted): recurring , customer_prefill , mode , amount , currency , externalCartId . Use top-level request fields for these instead of stuffing them into metadata . On webhooks User-attached metadata rides through to outbound webhook payloads. The following events now surface the merged order/session/subscription metadata bag on their data.metadata field: order.created — carries the merged cart < session < customerEmail bag. payment.captured — carries the parent order's metadata (same as order.created ). subscription.created — carries the subscription's metadata, which inherits from the originating session. Use the metadata bag in your handler to attribute the event to your own records (campaign IDs, sales rep IDs, contract numbers) without a follow-up GET. Conventions Keep keys snake_case or camelCase consistently. Throttle preserves whatever you send. Pick one and stick with it. Don't store secrets. Metadata is returned on GET and emitted in webhook payloads. Treat it as semi-public. Update via merge, not replace. When you PATCH with metadata , you replace the entire object. Read the existing object, spread it, set your new keys, then PATCH. Size limits are enforced. Keep metadata under 50 keys and 10KB. Use it for IDs and flags, not for dumping payloads. Reserved key: subscription_id on orders Throttle writes one metadata key of its own. Every order a subscription bills — the signup charge, each renewal, and any plan-change charge — carries metadata.subscription_id pointing at the subscription that generated it. It is how the dashboard links an order to its subscription and back again. Filter on it with the dedicated query param rather than the fuzzy search, which also matches an id sitting under any other key: If you charge a subscription's first period yourself and create the subscription afterwards, pass orderId on POST /v1/subscriptions and Throttle stamps the pointer on that order for you. Without it, that order stays unlinked — see Creating Subscriptions . Do not write subscription_id by hand, and do not overwrite it: the dashboard and the subscriptionId filter both read it. Dashboard exposure Today the merchant dashboard surfaces metadata as a raw JSON viewer on order detail pages. For customers, subscriptions, and carts, use the API and SDK responses as the source of truth for now. Next API Reference — full shape of every endpoint that accepts metadata."},{"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 automatically, so you do not need to release a hold 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 and nothing else. There is no evidence submission, no due date, no dispute id on the payment, and no won/lost state. The flag exists 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: Losing a chargeback leaves disputed: true — the same state as one still in progress. Throttle does not record the outcome, so disputed means contested at some point and not resolved in your favour , not currently open . Don t build an open-disputes worklist from this field alone; reconcile against your processor. 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 . Subscribing to either 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. 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. 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 default, 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 that renews it. A subscription that belongs to another customer reads as 404 not_found — never 403 — so ids cannot be probed. Auth: X-Throttle-PM-Token header Field Type Notes status string active , trialing , past_due , paused , cancelled , unpaid planReference , planName string, string | null What the buyer is subscribed to. amount , quantity , currency , interval integer, integer, string, string Per-seat price in minor units; the period charge is amount × quantity. currentPeriodEnd ISO 8601 | null The renewal that failed, when the subscription is past due. failureCount , cancelAtPeriodEnd integer, boolean Dunning progress and whether a cancel is scheduled. paymentMethod object | null The customer's defau"},{"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","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. 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","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, ]) /> 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","State machine","The seven statuses","The eleven 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. 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 eleven 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 is derived from a chargeback flag rather than from any payment s 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. 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. expired — an authorization lapsed before it was captured. The order is unpaid and the buyer has to pay again. Setting a status by hand POST /orders/ id /status with status : fulfilled , reason : shipped manually . Any status is reachable from any other, in both directions. Real orders go wrong in ways a state machine cannot anticipate, and a merchant correcting a mistake needs to move an order back. Setting a status changes the label and records who changed it. It never rewinds a side effect: captured money stays captured, sent emails stay sent, shipped fulfillments stay shipped. To reverse money, refund the payment; to reverse a shipment, cancel the fulfillment. cancelled is the same terminal state POST /orders/ id /cancel reaches, so setting it by hand does the same money-side work: every authorized payment on the order is voided, because a cancelled order must never capture. Captured payments are left alone — refunding them is an explicit refundCapturedPayments: true opt-in on /cancel , and editing a status is not consent to move money back. For the same reason, a request whose target is cancelled additionally requires the order_cancellations:write scope and returns 403 insufficient_scopes without it. Every other target status needs only orders:write . If the order carries a subscriptionId , reaching cancelled — by /cancel or by setting the status — cancels that subscription too, immediately , not at the end of the current period. No further periods are billed. Without this, a merchant could visibly call off the order a subscription had just billed and the buyer would keep being charged for it. It runs one direction only: cancellin"},{"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. 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 (all exposed on the payment object). A payment keeps its underlying status (usually captured ) while disputed. 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 and fires payment.dispute_cleared . 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","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. Storefront quote tokens: qt_ Shipping/tax quote tokens minted from Developers → Shipping & Tax → Tokens (or POST /api/v1/shipping-tax/quote-tokens ) now start with qt_ , not pk_ . A quote token is a separate, narrower credential from a publishable API key — valid only for POST /api/v1/shipping-tax/quotes — even though older tokens were minted with a pk_ prefix before this split. Tokens minted before"},{"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. 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 a customerId (set directly, or inherited from its cart), Throttle pins the the payment provider buyer at mint time. A successful payment vaults the card against that customer, and it appears in their saved payment methods on the next read. A session with no resolvable customer mints and charges as a guest . The payment still completes normally; nothing is saved, and that is correct behaviour, not a degraded path. Testing failures The success path is the easy half. These are the failures worth driving b"},{"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","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. After three failed renewal attempts the subscription 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. Buyer pays through <PaymentEmbed /> . Card is vaulted. Throttle auto-creates the subscription. onSucceeded returns subscriptionId . 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. 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 next time the subscription renews. A subscription with no snapshot renews untaxed rather than failing, so check this field when a renewal charges less than you expected. Sequence: auto-subscribe on card capture When a session ships with a recurring block and create: 'auto' , Throttle vaults the card during the embed's capture, runs the trial-fraud check, and creates the subscription before returning to the parent page. Pages in this se"},{"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 subscription mutations. 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. Cancel at period end (or immediately, if you allow it). Change plan (your UI decides which plans are offered). 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, so this proxy pattern has to check totalPayments != null before forwarding. (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)","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 subscriptionId already populated. 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. 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. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the renewal cron has nothing to charge. 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. 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). See Lifecycle and States for the full bounds and canonicalization rules. totalPayments (2–60) makes it an installment plan. The paid signup is payment 1; the plan completes and stops billing the moment payment N succeeds, emitting subscription.completed . It cannot be combined with a trial ( 400 invalid_combination ), and cannot be changed after creation ( 400 total_payments_immutable ). A cart or order line item's recurring.count is display-only merchant metadata ( · 3 payments ) and never creates or controls billing. Use totalPayments on the recurring intent or on subscriptions.create to actually cap the number of charges. Next Customer Identity — how to identify a customer without storing Throttle s ID. Subscription Webhooks — full event reference."},{"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. 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":"Lifecycle and States","route":"/developers/subscriptions/lifecycle-and-states","headings":["Lifecycle and States","Billing intervals","Installment plans","States","State diagram","Trial behavior","Renewal","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. Send one of these on the recurring block on the checkout session, on POST /api/v1/subscriptions , or on change-plan : interval — a named interval: weekly , biweekly , monthly , quarterly , or yearly . intervalUnit + intervalCount — any cadence: intervalUnit is hour , day , week , or month , and intervalCount is how many. Bounds: at most 8,760 hours, 365 days, 52 weeks, or 12 months — one year, in whichever unit you sent. Both together, in which case they must agree. The server reduces whatever you send to its largest exact unit — every 24 hours canonicalizes to day × 1 , every 7 days to week × 1 — and then names it when a named interval matches: every 14 days comes back as biweekly ( week × 2 ); every 24 hours stays custom, as day × 1 has no name. Sending a named interval together with a unit/count that canonicalizes to something else is 400 interval_conflict ; a cadence outside the bounds above, or interval: "custom" with no unit and count, is 400 invalid_interval . Every subscription response carries all three fields: interval (the named value or "custom" ), intervalUnit , and intervalCount — a named subscription carries its shorthand ( monthly → month , 1 ), so existing code that reads only interval keeps working. Period advancement is anchored, not chained from the previous period: a month-based cadence is computed from a fixed billingAnchorAt instant, keeping the anchor's day-of-month clamped to each month's length. A Jan-31 monthly subscription rolls to Feb 28 (29 in a leap year), then back to Mar 31 the next cycle — no drift over multi-year subscriptions. Hour, day, and week cadences advance by exact durations and never need clamping. The anchor moves when billing genuinely restarts — trial conversion, recovering from a failed payment, an immediate plan change, a scheduled plan change applying at renewal, or resuming from a pause. That is what keeps the billing date from drifting after a gap instead of silently back-charging it. An ordinary renewal keeps the anchor, with one exception: if the stored currentPeriodEnd isn't on the anchor's schedule (for example, you created the subscription with an explicit period end), that end is what the buyer was promised, so the subscription re-anchors there and bills one cadence at a time from it. Waiving a past_due period keeps the anchor and opens the scheduled period that contains now; only when that period would be the one already paid for does the subscription re-anchor, at the earlier of the parked retry date and now. Resume is a special case: it doesn't reset the anchor to now, it shifts both billingAnchorAt and currentPeriodEnd forward by exactly the time spent paused, so a subscription paused for 10 days still renews 10 days later than it would have — it doesn't lose or gain a day relative to its original schedule. biweekly means every 14 days — fortnightly, 26 charges a year. It is not twice a month: there is no 1st-and-15th anchoring. For monthly-equivalent reporting a biweekly plan is valued at amount × 26 / 12 ; a custom fixed-duration cadence uses the same 52 / 12 weekly ratio scaled by its period length, and a custom month cadence uses amount / intervalCount . 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 on an existing subscription (e.g., monthly → every 10 days) via POST /api/v1/subscriptions/:id/change-plan . The new cadence applies according to effective — no proration credit is issued for the unused portion of the current period. This is rejected on an installment plan (see below). 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 . Installment plans Set totalPayments (2–60) on the checkout session's recurring block or on POST /api/v1/subscriptions to make a subscription an installment plan — a fixed number of payments that ends itself. It can only be set at creation; a later request that sends it is 400 total_payments_immutable , and a value outside 2–60 is 400 invalid_total_payments . The paid signup charge is payment 1 of N. Every later successful renewal charge, and every waived period, counts one more toward totalPayments ; a refund does not change paymentsMade . The plan becomes completed the moment payment N succeeds — not at the end of the period it covers. completedAt is set, no further renewal is scheduled, and subscription.completed fires alongside the plan-complete email. Merchants who gate access on status = active must treat completed as paid in full. No trial on an installment plan — 400 invalid_combination . No amount, cadence, quantity, or plan change once created, immediate or scheduled — 409 installment_plan_locked . Cancel it and create a new one to change the terms. Pause and resume stay allowed for the merchant; while paused nothing is charged and the payment counter waits. The buyer cannot pause, resume, or cancel an installment plan — the storefront session routes return 403 installment_plan_merchant_only ; only the merchant can. Metadata and tax-address edits stay allowed. A cart line's recurring.count stays display-only — it does not create or control an installment plan. The plan line Throttle writes for a recurring checkout does carry the cadence and the insta"},{"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 plan","Immediate upgrade","Scheduled downgrade","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, and change plans. 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 : at period end the buyer keeps the period they paid for, so there is nothing to give back. 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 PATCH /api/v1/subscriptions/:id with cancelAtPeriodEnd: false . After actual cancellation the subscription is terminal — create a new one. 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, or failed 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. 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 . Throws otherwise. resume() requires status paused — unless the subscription has cancelAtPeriodEnd: true , in which case it clears the scheduled cancellation in any non-cancelled status ( active , trialing , past_due ) and leaves the status alone. Throws for cancelled . Pause, resume, and cancel stay allowed on an installment plan when called with your merchant secret key — while paused nothing is charged and the payment counter waits. Only the buyer session in the buyer portal is blocked from these three ( 403 installment_plan_merchant_only ); see Buyer Portal . Change plan Use POST /api/v1/subscriptions/:id/change-plan for all mid-cycle plan changes. The behavior depends on the effective field. An installment plan's amount, cadence, quantity, and plan are fixed at creation ( totalPayments is set). Both change-plan and change-quantity below are rejected with 409 installment_plan_locked on one. Cancel it and create a new subscription to change the terms. Immediate upgrade Use effective: "now" when the buyer is moving to a more expensive plan and you want to grant access right away. Throttle prorates the change: it credits the unused portion of the current period on the old plan and charges the stored card for the new plan amount minus that credit, then resets the billing period from today. The response includes a proration object ( creditCents , chargedCents , fullAmount ) and the same breakdown rides on the subscription.plan_changed event. If the credit fully covers the new amount, no charge is issued. If the card is declined the route returns 402 payment_failed and nothing changes. Credit only accrues for active subscriptions — a trialing or past-due subscription has no prepaid time to credit. Scheduled downgrade Use effective: "period_end" when the buyer is moving to a cheaper plan and should finish the period they paid for. "},{"title":"Mixed Carts","route":"/developers/subscriptions/mixed-carts","headings":["Mixed Carts","Selling one","What the buyer is charged","What you get back","Fulfilment splits; billing does not","Trials","Discounts","Refunds","Limits","Next","unknown field is rejected rather than ignored.","2. Add your ONE-TIME goods. Do not add the plan here."],"text":"Subscriptions Mixed Carts A mixed cart is one checkout that sells a plan and something you hand over once — a subscription plus a starter kit, a membership plus a signup fee. Throttle treats it as a first-class shape: one cart, one capture, one order that owes both a delivery and a renewal. When a session carries a recurring block, Throttle assumes software and turns the address step off. That default is wrong the moment your cart holds something physical, and nothing downstream will tell you — the order is simply created with no address to ship to. Pass collect: shippingAddress: true on every mixed session that includes shippable goods. This is the single most common mistake with this shape. Selling one Build the cart with your one-time lines only, then hand the plan to the session. Throttle adds the plan to the cart as a type: subscription line when the session is created — before the buyer sees a total — so the amount quoted on the hosted page, the amount authorized in the embed, and the amount finally settled are all the same number. Do not add the plan line yourself. If you do — or if you mark a line with metadata.subscriptionLineItem: true — Throttle detects it and does not add a second one, which is what keeps a plan from being billed twice. What the buyer is charged Whether period 1 was really collected is read off the order , not off your intent: if the plan line is not on the order, no paid invoice is written. That is what stops a mixed checkout from producing a customer-facing PDF for money nobody was charged. What you get back The order s type is derived from its lines: one_time when nothing recurs, recurring when everything does, and mixed when both are present. It is a filter on the list endpoint too. Fulfilment splits; billing does not The plan line resolves to access_grant and is delivered by the engine with no human step. Your goods default to shipment . So a mixed order settles into partially_fulfilled and stays there until you ship — which is correct: the kit really is the order s job. That is also why a mixed order keeps the full order toolbox in the dashboard, while an order the engine minted for a renewal does not. But it still owns billing: cancelling a mixed order cancels its subscription . Anything acting on the recurring side should read subscriptionId rather than infer from the order alone. Trials A trial on a mixed cart means the buyer pays for the goods today and nothing for the plan. The plan line is still added — at zero — so the card is vaulted and the subscription starts, and the first plan charge lands when the trial ends. Discounts A discount code on a recurring session is first-charge only . Because a mixed cart s first charge includes the goods, the code applies to the whole signup total and renewals go on billing the undiscounted plan. That separation only holds if Throttle knows the real renewal price, so recurring.amount is mandatory when a discountCode is present. Omit it and session creation fails with 422 recurring_discount_requires_amount rather than silently discounting every future renewal. Refunds Refunds are reconciled against the part of the order they came back from, so refunding the kit does not make the subscription look unpaid, and refunding the cycle does not look like a returned product. A full refund of a mixed order cancels the subscription along with it. Two carts are deliberately left alone, because something else already fixed their total: a cart created from an accepted quote (adding a line after acceptance bills more than the buyer agreed to) and a cart with a deposit/balance payment schedule (the schedule is validated to sum to the cart total, so an extra line kills the completion). In both cases the subscription is created with period 1 unbilled : it starts active, no paid invoice is written, and the first plan money arrives at the first renewal. If you sell subscriptions through quotes, collect the signup charge yourself or price the quote to include it. Limits Next Creating Subscriptions — the three patterns for who calls create, and when. Subscription Webhooks — the event sequence a mixed signup emits. Orders — statuses, fulfilment, and the money rail on the order side."},{"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() useCreateCheckoutSession() Bridge from the management package to @usethrottle/checkout-react s <PaymentEmbed /> . Wraps the /api/v1/checkout/sessions POST with recurring metadata. 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 (all null on an ongoing subscription). 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. See Creating Subscriptions for the bounds and rejection codes. 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 , then subscription.past_due when the threshold is crossed. , ], [ 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 subscription is created with trialEnd set (auto-create mode), the engine looks up all payment methods on this merchant with the same fingerprint, then checks whether any of those customers have a prior subscription that had trialEnd set. If a match exists, the new subscription is created without the trial — it starts in active immediately, the buyer is charged at signup as expected, and a subscription.trial_blocked outbound event fires so you can react. 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 the auto-create flow gates a trial silently (without you calling the eligibility endpoint), Throttle still emits a webhook so you have a hook point to react. This is fired in addition to the normal subscription.created — you get both events. 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. 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 . 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)","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. All notifications flow through your webhook endpoint. You decide which events translate to a buyer-facing message and which provider (Resend, Postmark, SES) delivers it. The <DunningBanner /> component covers the in-app surface; emails are entirely yours. Event types trialing subscription flips to active at trialEnd . , Optional your trial ended, you are now subscribed email. , ], [ code: subscription.renewed , A renewal charge succeeded and the period advanced. , Ledger entry. Receipt email (optional). , ], [ 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. , html: Ledger entry and refund receipt. Do not revoke access on money_only — wait for subscription.cancelled . , ], [ code: subscription.payment_failed , html: Every failed renewal attempt. Includes attempt , nextRetryAt , and lastError in the payload. , Payment-failed email. First failure is the most important. , ], [ code: subscription.past_due , html: The first time a subscription transitions to past_due (after 3 failures). , Final-warning email with update-card link. , ], [ code: subscription.updated , html: Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change . , 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. , 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: Auto-create downgraded a trial-requested subscription to no-trial because the card fingerprint matched a prior trial on this merchant. Fires alongside subscription.created . data.reason is one of card_already_used_for_trial , payment_method_not_found , or no_fingerprint_available . , Notify the buyer (optional). Log the decision for fraud analytics. , ], [ code: payment.vaulted , html: A card was successfully vaulted in a checkout session. Carries recurring metadata when the session had it — including intervalUnit , intervalCount , and totalPayments when the session specified a custom cadence or an installment plan. , Use as a hook point for Pattern C (webhook-driven subscription creation). , ] ] /> Example payload All subscription events use the same envelope shape with data.subscription containing the current subscription state. Most events carry only subscription ; plan-change events include additional fields: pending + effectiveAt for scheduled changes, or previous for completed changes. Every subscription event also carries a sibling data.customer object so you can map the event to your own tenant without a follow-up API call. It holds two different external identifiers, and which one you want depends on how you created the customer: externalId — your own customer id, set via externalId on POST /customers or customer.externalCustomerId on a checkout session. This is the one most integrations key their account lookup on. externalCustomerId — a per-connection mapping, set only when the customer arrived through a platform connection. Usually null for direct API integrations. Both are null when never set, and data.customer itself is omitted if the customer record cannot be resolved — so read it defensively rather than assuming it is present. Note that data.subscription has no external-id field of its own; it carries only the internal customerId . Plan change scheduled (downgrade deferred) When a plan change is scheduled for the next period end, data.pending holds the new plan details and data.effectiveAt is the ISO date when it will apply. Plan changed (upgrade applied or downgrade finalized) When a plan change completes (immediately or at renewal), data.subscription reflects the new plan and data.previous captures the old plan reference and amount. Completed (installment plan paid in full) data.subscr"},{"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}","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":"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-10-03T20:49:15.418Z","entries":[{"title":"Docs","route":"/","headings":["Docs","{section.label}"],"text":"Documentation Docs Look up how one thing works. Each page covers a single feature — what it does, how it behaves at the edges, and where it hands off to something else. Want to be walked through a job end to end instead? Start with Guides . Integrating the API? Developer docs . Building an app for the marketplace? Extensions . sections.map((section) => ( section.label section.blurb section.items.map((doc) => ( doc.data.title doc.data.description )) )) .hub max-width: 820px; margin: 0 auto; padding: 48px 24px 96px; .hub-head margin-bottom: 52px; .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: 60ch; margin: 0 0 14px; .hub-aside font-size: 15px; color: var(--muted); .hub-aside a color: var(--blue); .hub-section margin-bottom: 44px; .hub-section h2 font-size: 20px; letter-spacing: -0.02em; margin: 0 0 4px; .hub-blurb font-size: 14.5px; color: var(--muted); margin: 0 0 16px; .hub-list list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--rule-soft); .hub-list li padding: 14px 0; border-bottom: 1px solid var(--rule-soft); .hub-list a display: block; font-size: 16px; font-weight: 600; color: var(--ink); text-decoration: none; margin-bottom: 3px; .hub-list a:hover color: var(--blue); .hub-list span font-size: 14.5px; line-height: 1.55; color: var(--muted);"},{"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","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).`;"],"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","Endpoints","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 . 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 the cart.abandoned sweep and recovery email described in Cart states . Shipping & discounts Fetch l"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","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)","2026-09-21 — Completing a session with paymentMethod \"card\" is now refused","2026-09-20 — Read a checkout session back from the SDK","2026-09-14 — PayPal connector form explains its fields","2026-09-12 — Paid orders can no longer be set back to Draft or Pending","2026-09-11 — Mint an update-card link from the API","2026-09-11 — Renewal-failed emails link to the hosted update-card page","2026-09-11 — Hosted \"update your card\" page","2026-09-11 — Buyer-scoped subscription routes for the update-card flow","2026-09-11 — Deposit terms on any quote revision","2026-09-11 — Overdue renewals stand out in the subscriptions list","2026-09-11 — Paying at the end of a trial unlocks Production","2026-09-11 — Ship part of an order from the dashboard","2026-09-11 — A \"not shipped yet\" orders filter","2026-09-11 — Undo a scheduled cancel while past due","2026-09-11 — Recording delivery completes the fulfillment","2026-09-11 — Signup charge counts as the first payment","2026-09-11 — Merchants can decline a quote request","2026-09-11 — Partner referrals report handoff progress","2026-09-11 — Billing writes are owner-only for dashboard sessions","2026-09-10 — Endpoints that stop verifying are reported in minutes, not days","2026-09-10 — Signing-secret rotation with a grace window"],"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-03 — Custom installment schedules Payments that differ. An installment plan can now charge different amounts on different gaps — "$40 today, $15 36 hours later, $35 a month after". Send recurring.schedule on a cart or quote line, or schedule on POST /subscriptions . See Installment plans . Behaviour change: discount codes on installment plans. A code applied to a cart with an installment line now applies to the whole plan and is spread across its payments. Before, it reduced only the first payment. Existing subscriptions are unchanged. New fields. Every subscription returns paymentSchedule , scheduleTotal and nextPaymentAmount , in API responses, webhook payloads and events stored from this release on ( GET /api/v1/events ); subscription.renewed and subscription.completed carry paymentIndex . New errors: invalid_schedule and schedule_first_payment_mismatch . Behaviour change: embedded completion re-checks subscription lines. POST /api/v1/checkout-sessions/:id/complete with a processorTransactionId the embed only authorised now answers 409 subscription_lines_changed and voids the authorisation when the cart's subscription lines changed after the session was created, for every subscription checkout, not only schedules. If the transaction's status cannot be confirmed yet it answers the retryable 409 payment_processing . A transaction the embed already captured completes as before. See Embedded Checkout . Behaviour change: the subscription invoice list records $0 periods. GET /api/v1/subscriptions/:id/invoices rows gain waived . A waived period, or a renewal that cost nothing, now writes a $0 paid row with waived: true , on every plan, equal plans and ongoing subscriptions included. Rows written before this release are unchanged. The public quote link returns the schedule. GET /api/v1/quote-links/:token now returns a schedule line's recurring.schedule , and the hosted quote page lists every payment with its estimated date. Security fix. @usethrottle/subscriptions ' proxy no longer lets a buyer pause, resume or cancel an installment plan. If you pass your own authorizeMutation , it must enforce this itself. Packages. Minor releases: api-client 5.6.0, subscriptions 3.3.0, cart 3.12.0, quotes 0.6.0, webhook-types 6.1.0, auth 0.8.0 and mcp 0.4.0. 2026-10-03 — Editing a provider-taxed order re-quotes its tax PATCH /api/v1/orders/:id/line-items re-quotes tax with the tax provider (bug fix). When the application calculates tax with a provider ( taxMode: app_based , e.g. Avalara), an edit used to keep the tax the order was created with, so a doubled quantity charged the goods but not their tax. The edit now asks the provider for the edited lines, at the order's own date, customer, exemption and per-order overrides, and the filed tax document is adjusted to match. New error: 503 tax_provider_unavailable . With the strict tax fallback policy (the default), an edit the provider cannot answer is refused and the order is left unchanged: nothing is charged or refunded. Retry once the provider is reachable. An edit that cannot be taxed for a configuration reason answers 422 with the reason as its code ( tax_origin_missing , tax_address_missing ). New response field: warnings . With the lenient policy, an edit the provider cannot answer is taxed by your native tax rules, as at checkout, and the response carries warnings: [ code: provider_fallback , message ] . The field is absent when there is nothing to report. The edit's response and errors are in the OpenAPI spec and @usethrottle/api-client 5.5.0. The operation used to document only a bare 200 , so the generated patchApiV1OrdersLineItems returned any . It now declares the edited order, its items , the settled adjustment , the optional warnings , and every error status the edit can answer. See Error codes: order line-item edits . The filed Avalara sale is adjusted in place. An edit to an order whose sale is already filed re-files that sale with AvaTax's AdjustTransaction: same document code and date, so it stays in its original period. The refund an edit issues no longer files a separate return (it used to, measured against the edited order). Each adjustment appears in GET /api/v1/shipping-tax/documents as a SalesInvoiceAdjustment row coded <sale code>-ADJ-<hash> ; a failed one can be retried with POST /api/v1/shipping-tax/documents/:id/retry . A sale AvaTax has locked (reported on a filed return) cannot be adjusted: the row fails with the reason and the filing alert fires. 2026-09-30 — Declined embed cards answer 402, checkout-react under vite dev A declined card at /complete answers 402 payment_declined (bug fix). When the processor declined the payment embed's authorisation, POST /api/v1/checkout-sessions/:id/complete used to answer 502 payment_capture_failed with "Internal server error" and leave a pending payment on the order after a successful retry. It now answers 402 payment_declined with the processor's reason, like the server-authorize and saved-card rails, leaves no pending payment, and reopens the session for a retry. Nothing is charged either way. See Embedded Checkout . @usethrottle/checkout-react 2.1.1 loads under vite dev (bug fix). Importing the package in the Vite dev server threw "Cannot read properties of undefined (reading 'SecureFields')" and left a blank page. The package now bundles the payment provider's Secure Fields SDK, so it works in Vite dev and build, Next.js and webpack with no configuration. quoteToken is a qt_ storefront quote token (docs). useCartSession and CartSessionClient said they took a pk_… token. They take a storefront quote token ( qt_… , or a legacy pk_… quote token). A pk_ publishable API key is refused with 401 inv"},{"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 . 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","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; 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","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 . 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. 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. 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 dashboard pings your webhook right after install, signed with the secret it just minted — before an operator could have handed that secret to your service. A correctly implemented endpoint therefore answers 401 once. Recover without a human: on a verification failure, fetch the current secret with GET /api/v1/installations/:id/webhook-secret (authenticated as the installation), cache it, and re-verify. The ping then passes on the merchant s retry, and a rotated secret heals the same way. You are told when your endpoint stops accepting deliveries Two signals go to the publisher of the extension and to the admins of the installing application, by email and in the dashboard inbox, without anyone watching a delivery log. Unauthorized streak. The moment an endpoint has answered three consecutive deliveries with 401 or 403 — on any attempt, so retries of one event count — an alert names the endpoint, the last event type and the status. It fires once per streak. Retrying does not fix an authorization failure: the receiver is verifying with a stale secret or rejecting an envelope field it does not recognise. Deliveries keep retrying and then dead-letter; replay them once the endpoint accepts again. Signed probe. Every active installation s endpoint receives an extension.ping envelope two minutes after each Throttle deploy and every six hours. It is built by the same code as a real delivery — same body shape, headers and s"},{"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"],"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."},{"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 } }","}`;"],"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. 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. 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 stop receiving events and their launch-token endpoint returns 409 installation_not_active . Delivery logs are accessible at GET /api/v1/installations/:id/deliveries . Uninstall Uninstalling removes the install row for the current application and environment. The extension catalog entry and other applications' installs are not affected. The API key and signing secret issued at install time are immediately revoked. The extension is told. "},{"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. 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 definition and its versions) is shared across environments."},{"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}"],"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 . 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 def.sensitive && ( (sensitive) ) )) )) The complete scope catalog, including non-extension scopes, is at API key scopes and served live at GET /api/v1/scopes ."},{"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","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. Only published versions may be referenced in an install call. Attempting to install a draft version returns 422 draft_not_installable_in_live for production environment callers (and similarly for non-production environments). Publish the version first. 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. 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 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). 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","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. When tax is filed on invoice terms For a card sale the tax document is filed when the money moves. For an invoice on net terms it is filed when the invoice is issued — the tax point for term billing is the moment the invoice is raised, not the moment it is paid. So an invoice issued in January and paid in March belongs in January's filing. Throttle commits the document as soon as the invoice is authorized, while the payment is still unpaid, and you will see a committed tax document against an invoice that has not settled yet. That is correct, not a bug. Filing early does not lock the document. Cancelling the order or refunding the payment voids or credits the filed document the same way it does for a card sale. REST routes quick reference All routes require invoices:read scope on the API key. The download route returns a 302 redirect to a short-lived signed S3 URL by default; pass ?format=json to get the URL as a JSON response instead. Pages in this section React Package — @usethrottle/invoices hooks, provider, and server proxy reference."},{"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. 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, and a pre-flight check of a body against the published OpenAPI contract. api_get — a GET-only escape hatch for any /api/v1 path the curated tools do not cover. It cannot write. Local server only. get_order , get_customer , search_orders , search_customers , list_subscriptions , search_quotes , list_invoices , receivables_aging — commerce reads, with related records inlined where an answer usually needs them. search_orders , search_customers , list_subscriptions and search_quotes also accept createdAfter / createdBefore"},{"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","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. 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 . Orders and payments Checkout sessions The recurring block is stored under metadata.recurring for the auto-create flow. You can stack your own metadata alongside without conflict. On checkout sessions Session met"},{"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 automatically, so you do not need to release a hold 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 and nothing else. There is no evidence submission, no due date, no dispute id on the payment, and no won/lost state. The flag exists 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: Losing a chargeback leaves disputed: true — the same state as one still in progress. Throttle does not record the outcome, so disputed means contested at some point and not resolved in your favour , not currently open . Don t build an open-disputes worklist from this field alone; reconcile against your processor. 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 . Subscribing to either 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. 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. 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 default, 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 that renews it. A subscription that belongs to another customer reads as 404 not_found — never 403 — so ids cannot be probed. Auth: X-Throttle-PM-Token header Field Type Notes status string active , trialing , past_due , paused , cancelled , unpaid planReference , planName string, string | null What the buyer is subscribed to. amount , quantity , currency , interval integer, integer, string, string Per-seat price in minor units; the period charge is amount × quantity. currentPeriodEnd ISO 8601 | null The renewal that failed, when the subscription is past due. failureCount , cancelAtPeriodEnd integer, boolean Dunning progress and whether a cancel is scheduled. paymentMethod object | null The customer's defau"},{"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","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]) /> 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. 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. 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","State machine","The seven statuses","The eleven 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. 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 eleven 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 is derived from a chargeback flag rather than from any payment s 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. expired — an authorization lapsed before it was captured. The order is unpaid and the buyer has to pay again. Setting a status by hand POST /orders/ id /status with status : fulfilled , reason : shipped manually . Any status is reachable from any other, in both directions. Real orders go wrong in ways a state machine cannot anticipate, and a merchant correcting a mistake needs to move an order back. Setting a status changes the label and records who changed it. It never rewinds a side effect: captured money stays captured, sent emails stay sent, shipped fulfillments stay shipped. To reverse money, refund the payment; to reverse a shipment, cancel the fulfillment. cancelled is the same terminal state POST /orders/ id /cancel reaches, so setting it by hand does the same money-side work: every authorized payment on the order is voided, because a cancelled order must never capture. Captured payments are left alone — refunding them is an explicit refundCapturedPayments: true opt-in on /cancel , and editing a status is not consent to move money back. For the same reason, a request whose target is cancelled additionally requires the order_cancellations:write scope and returns 403 insufficient_scopes without it. Every other target status needs only orders:write . If the order is linked to subscriptions ( subscrip"},{"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. 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 (all exposed on the payment object). A payment keeps its underlying status (usually captured ) while disputed. 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 and fires payment.dispute_cleared . 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","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. Storefront quote tokens: qt_ Shipping/tax quote tokens minted from Developers → Shipping & Tax → Tokens (or POST /api/v1/shipping-tax/quote-tokens ) now start with qt_ , not pk_ . A quote token is a separate, narrower credential from a publishable API key — valid only for POST /api/v1/shipping-tax/quotes — even though older tokens were minted with a pk_ prefix before this split. Tokens minted before"},{"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. 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 a customerId (set directly, or inherited from its cart), Throttle pins the the payment provider buyer at mint time. A successful payment vaults the card against that customer, and it appears in their saved payment methods on the next read. A session with no resolvable customer mints and charges as a guest . The payment still completes normally; nothing is saved, and that is correct behaviour, not a degraded path. Testing failures The success path is the easy half. These are the failures worth driving b"},{"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","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 subscription mutations. 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 (your UI decides which plans are offered). 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 ); 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)","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. Pre-condition: a customerPaymentMethods row already exists with isDefault: true for the customer — otherwise the renewal cron has nothing to charge. 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; the plan completes and stops billing the moment payment N succeeds, emitting subscription.completed . It cannot be combined with a trial ( 400 invalid_combination ), and cannot be changed after creation ( 400 total_payments_immutable ). Full guide: Installment Plans → schedule (2–60 rows) makes it an installment plan whose payments differ in amount and in the gap before each. Row 0 is the payment already collected; amount is then optional (if sent it must equal schedule[0].amount ) and currentPeriodEnd is optional too: Throttle derives it as payment 2's due time. schedule cannot be combined with interval , intervalUnit , intervalCount , totalPayments or trialEnd ( 400 invalid_combination ). Mistakes answer 400 invalid_schedule or 400 schedule_first_payment_mismatch . Full guide: Custom schedules → A cart or order line item's recurring.count is read-only display data ( · 3 payments ) that mirrors the line's totalPayments ; it is not accepted on input. Use totalPayments in a cart or quote line's recurring block, on the session's recurring intent, or on subscriptions.create to cap the number of charges. See Mixed Carts for subscription lines. Next Customer Identity — how to identify a customer without storing Throttle s ID. Subscription Webhooks — full event reference."},{"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","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. The auto-create flow checks the card's fingerprint agai"},{"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 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, and change plans. 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 currentPeriodEnd — paus"},{"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 never ship. 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 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 and off otherwise; any other checkout keeps asking. 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, each trial line s subscription starts active without its trial; nothing was charged for it today, so its first charge is its first renewal. One subscription.trial_blocked fires per affected subscription. Discounts A discount code — yours or one the buyer types — applies to the first charge only . It is spread across the order s lines in proportion to their subtotals, so each subscription s first period records its own share. Renewals bill the undiscounted price. Before payment, the public session s subscriptions[].firstChargeAmount states each line s discounted first charge (before tax), and the hosted page s "today" figure uses"},{"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 subscription is created with trialEnd set (auto-create mode), the engine looks up all payment methods on this merchant with the same fingerprint, then checks whether any of those customers have a prior subscription that had trialEnd set. If a match exists, the new subscription is created without the trial — it starts in active immediately, and a subscription.trial_blocked outbound event fires so you can react. The checkout charged nothing for the trial (it was priced at 0), so no paid first-period invoice is written: the subscription s first charge is its first renewal. On a checkout with several trial lines the check runs once, against the card s history before this checkout, and the answer applies to every trial line. When it is blocked, each trial line s subscription starts active without its trial, and one subscription.trial_blocked fires per affected subscription. 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 the auto-create flow gates a trial silently (without you calling the eligibility endpoint), Throttle still emits a webhook so you have a hook point to react. This is fired in addition to the normal subscription.created — you get both events. 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","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 , A renewal charge succeeded and the period advanced. , Ledger entry. Receipt email (optional). , ], [ 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 . , 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: Auto-create downgraded a trial-requested subscription to no-trial because the card fingerprint matched a prior trial on this merchant. Fires alongside subscription.created , once per affected subscription. data.reason is one of card_already_used_for_trial , payment_method_not_found , or no_fingerprint_available . , Notify the buyer (optional). Log the decision for fraud analytics. , ], [ code: payment.vaulted , html: A card was successfully vaulted in a checkout session. Carries recurring metadata when the session had it — including intervalUnit , intervalCount , and totalPayments when the session specified a custom cadence or an installment plan. Absent when the cart's lines carry the subscriptions: read one subscription.created per line instead. , Use as a hook point for Pattern C (webhook-driven subscription creation). , ] ] /> Example payload All subscription events use the same envelope shape with data.subscription containing the current subscription state. Most events carry only subscription ; plan-change events include additional fields: pending + effectiveAt for scheduled changes, or previous for completed changes. Every subscription event also carries a sibling data.customer object so you can map the event to your own tenant without a follow-up API call. It holds two different external identifiers, and which one you want depends on how you created the customer: externalId — your own customer id, set via externalId on POST /customers or customer.externalCustomerId on a checkout session. This is the one most integrations key their account lookup on. externalCustomerId — a per-co"},{"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}","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":"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"}]}
|