@usethrottle/mcp 0.1.0 → 0.2.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/README.md +2 -0
- package/dist/contract-index.json +1 -1
- package/dist/docs-index.json +1 -1
- package/dist/lib.cjs +1702 -0
- package/dist/lib.cjs.map +1 -0
- package/dist/lib.d.cts +165 -0
- package/dist/lib.d.ts +165 -0
- package/dist/lib.js +1668 -0
- package/dist/lib.js.map +1 -0
- package/dist/mcp.js +348 -26
- package/dist/mcp.js.map +1 -1
- package/package.json +11 -1
package/dist/docs-index.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"generatedAt":"2026-07-29T03:04:46.010Z","entries":[{"title":"Docs","route":"/","headings":[],"text":"export default function HomePage() redirect( /developers );"},{"title":"Build a checkout that settles cleanly.","route":"/developers","headings":["Build a checkout that settles cleanly.","Choose Your Path","{title}","Canonical URLs"],"text":"export const metadata: Metadata = title: Developers , ; const paths = [ href: /developers/quickstart , title: Accept a payment , body: Create a session, mount the payment embed, and handle success. , Icon: CreditCard, , href: /developers/embedded-checkout , title: Embed checkout , body: Use PaymentEmbed, CheckoutEmbed, a raw iframe, or a hosted page. , Icon: Code2, , href: /developers/cart-api , title: Manage native carts , body: Create carts, add items, calculate shipping/tax, and checkout. , Icon: Box, , href: /developers/webhooks , title: Listen to webhooks , body: Verify HMAC signatures and process events idempotently. , Icon: Webhook, , href: /developers/packages , title: Choose a package , body: Pick the right SDK or CLI for your integration surface. , Icon: Route, , href: /developers/api-reference , title: Browse endpoints , body: Use the curated reference, then jump to full OpenAPI when needed. , Icon: ShieldCheck, , ]; export default function DevelopersPage() return ( 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. 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":"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":"export const metadata: Metadata = title: Product Feeds , ; const feedRouteSnippet = // app/feeds/google-merchant.xml/route.ts import getProducts from @/lib/cms ; // your catalog source export const revalidate = 3600; // regenerate hourly function esc(value: string): string return value .replace(/&/g, & ) .replace(/ /g, > ); export async function GET() const products = await getProducts(); // CMS query const items = products .map( (p) => \\ \\$ esc(p.sku) \\$ esc(p.name) \\$ esc(p.description) https://shop.example.com/products/\\$ esc(p.slug) \\$ esc(p.imageUrl) \\$ p.inStock ? in_stock : out_of_stock \\$ (p.priceMinor / 100).toFixed(2) \\$ esc(p.currency) \\$ esc(p.brand) new \\ , ) .join( ); const xml = \\ Example Shop https://shop.example.com Product feed \\$ items \\ ; return new Response(xml, headers: Content-Type : application/xml; charset=utf-8 , ); ; const lineItemSnippet = // Adding a cart line item — referenceId MUST equal the feed s await carts.items.add(cart.id, type: product , name: Premium Widget , referenceId: WIDGET-PREM-001 , // WIDGET-PREM-001 in the feed unitPrice: 2599, quantity: 1, ); ; export default function ProductFeedsPage() return ( 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":"export const metadata: Metadata = title: Track Conversions , ; const embedPurchaseSnippet = import CheckoutEmbed from @usethrottle/checkout-react ; const value = (total ?? 0) / 100; // minor units -> major const items = lineItems ?? []; window.gtag?.( event , purchase , transaction_id: orderId, value, currency: currency ?? USD , items: items.map((item) => ( item_id: item.sku ?? item.id ?? item.name, item_name: item.name, quantity: item.quantity, price: item.unitPrice / 100, )), ); window.fbq?.( track , Purchase , value, currency: currency ?? USD , content_type: product , content_ids: items .map((item) => item.sku ?? item.id) .filter((id): id is string => Boolean(id)), , eventID: orderId , ); router.push(\\ /thank-you?order=\\$ orderId \\ ); />; ; const trackingSettingsSnippet = curl -X PUT https://api.usethrottle.dev/api/v1/tracking-settings \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d ga4MeasurementId : G-XXXXXXXXXX , metaPixelId : 1234567890123456 , requireConsent : true ; const serverSideSettingsSnippet = curl -X PUT https://api.usethrottle.dev/api/v1/tracking-settings \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d serverSideEnabled : true, ga4ApiSecret : your-ga4-mp-api-secret , metaCapiAccessToken : your-meta-capi-access-token ; const clientContextSnippet = // Your server, at session create time const session = await checkout.createSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, cartId: cart.id, returnUrl: https://shop.example.com/checkout/success , cancelUrl: https://shop.example.com/cart , clientContext: landingPage: https://shop.example.com/spring-sale?utm_source=google , referrer: https://www.google.com/ , utmSource: google , utmMedium: cpc , utmCampaign: spring-sale , gclid: Cj0KCQjw... , gaClientId: 123456789.1700000000 , gaSessionId: 1700000000 , fbp: fb.1.1700000000000.1234567890 , fbc: fb.1.1700000000000.IwAR2... , consent: analytics: true, marketing: true ); ; const hostedSteps = [ title: Configure tracking settings , body: PUT /api/v1/tracking-settings with your ga4MeasurementId and/or metaPixelId. Settings are per application and per environment, so configure your production environment separately from sandboxes. , , title: Pass the consent signal , body: Append consent=granted (or consent=denied) to the checkout URL when you hand the buyer off. With requireConsent true (the default), no consent signal means no pixels are injected. If you run your own consent platform upstream and only send consenting buyers, you can set requireConsent to false instead. , , title: Add the checkout domain to GA4 cross-domain linking , body: In GA4 Admin, add checkout.usethrottle.dev to your cross-domain linking domain list. Throttle configures gtag.js with linker.accept_incoming, so the _gl parameter your storefront appends stitches the buyer into the same GA4 session instead of starting a new one on the checkout domain. , , ]; export default function TrackConversionsPage() return ( 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. , <> 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 C"},{"title":"API Surface by Integration Task","route":"/developers/api-reference","headings":["API Surface by Integration Task","{group.title}"],"text":"export const metadata: Metadata = title: API Reference , ; export default function ApiReferencePage() return ( 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":"export const metadata: Metadata = title: Cart API , ; const cartSteps = [ title: Configure your application , body: Copy your Application UUID + add at least one allowedOrigins entry from the dashboard. POST /carts and the embed both refuse to render until these are set. , , title: Create the cart , body: Start with applicationId. Currency is optional — it inherits your application currency, and a mismatched value is rejected (currency_mismatch). Keep cart operations on your backend. , , title: Add commerce state , body: Add items, apply discounts, select shipping, and calculate tax before checkout. , , title: Lock totals for checkout , body: Run checkout_final calculation when the buyer commits to shipping and payment. , , title: Convert to a draft order , body: POST /carts/ id /finalize converts the cart to a draft order. (Legacy alias: /checkout — same handler.) To collect payment, mint a checkout session afterwards (see Embedded Checkout). , , ]; const cartFlowChart = sequenceDiagram participant B as Browser participant M as Merchant Backend participant T as Throttle API participant C as Checkout Iframe B->>M: add item / apply code / choose shipping M->>T: POST /api/v1/carts M->>T: POST /api/v1/carts/ id /items M->>T: POST /api/v1/carts/ id /apply-discount M->>T: POST /api/v1/shipping-tax/carts/ id /calculate T-->>M: totals + shipping/tax snapshot M->>T: POST /api/v1/checkout/sessions M-->>B: sessionId + embed URL B->>C: render checkout C->>T: POST /api/v1/checkout-sessions/ id /complete T-->>B: throttle.completed ; const addressSnippet = // One canonical address shape (CartAddress). Required: addressLine1, city, await client.carts.update(cartId, shippingAddress: firstName: Ada , addressLine1: 1 Market St , addressLine2: Suite 400 , city: San Francisco , stateProvince: CA , postalCode: 94105 , countryCode: US , , ); const shippingSelectSnippet = // Selecting a shipping method is ONE call. The response is the full cart with const cart = await client.shipping.select(cartId, methodId: standard , displayName: Standard (3-5 days) , rateAmount: 799, ); cart.selectedShipping; // methodId, displayName, rateAmount, currency, ... cart.shippingTotal; // 799 cart.taxTotal; // recomputed cart.total; // recomputed const fresh = await client.carts.get(cartId); fresh.selectedShipping; // bind your UI directly to this — it never drifts. ; export default function CartApiPage() return ( 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_production_* . 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 pk_ and are scoped by application and allowed origin. They can estimate totals, but they cannot mutate carts or create orders. carts.create and carts.update accept an optional customerEmail — a way to attach a buyer’s email to a cart without minting a full customer record (e.g. an “email me my cart” field). When set, an otherwise-anonymous abandoned cart becomes recoverable: the cart.abandoned webhook carries it as customer.email (with id: null ). A fully linked customer’s email always takes precedence. Pass null on update to clear it. The cart is the source of truth for shipping and totals Selecting a shipping method is a single, atomic call. The response is the full cart with shippingTotal , taxTotal , and total already recomputed — you do not need a calculate-before or a recalculate-after round trip, and you do not need to keep a parallel copy of the selected method or totals in client state. shipping.select and ca"},{"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","Shipping & discounts","Hand off to checkout","One hook for the whole flow: useThrottleCheckout","Endpoints","Update an item","Remove an item","Read the current cart","Apply a discount code"],"text":"export const metadata: Metadata = title: Cart sessions (backend-less carts) , ; export default function CartSessionsPage() return ( 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 , and a cart snapshot. 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 /> 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: application/json \\\\ -d code : SAVE10 /> Hand off to checkout When the buyer is ready, convert the cart session into a checkout session and redirect them to the returned checkoutUrl . This marks the cart session converted (no further edits). /checkout-session \\\\ -H Origin: https://shop.example.com -H Content-Type: application/json \\\\ -d returnUrl : https://shop.example.com/thanks , cancelUrl : https://shop.example.com/cart /> One hook for the whole flow: useThrottleCheckout useThrottleCheckout (in @usethrottle/checkout-react ) wraps all of the above: it binds totals and selectedMethod straight to the cart (no shadow copy to drift), locks shipping in one call via selectMethod , exposes a status state machine, and auto-recovers from a stale cart — if the cart was converted by a completed order or abandoned, it rebuilds a fresh session from the last-known line items and retries ( status === 'recovering' ). createSession returns the checkoutSessionId for <PaymentEmbed> . = useThrottleCheckout( applicationId, environmentId, quoteToken ); const start = async () => const checkoutSessionId = await createSession( returnUrl: https://shop.example.com/thanks , cancelUrl: https://shop.example.com/cart , ); setSessionId(checkoutSessionId); ; return sessionId ? : Pay totals?.total ; /> The hook does not write addresses to the cart — the hosted/full checkout embed collects shipping & billing. The hook focuses on cart state, shipping selection, totals, recovery, and the session handoff. Endpoints POST /api/v1/cart-sessions — create a cart + session. GET /api/v1/cart-sessions/ id — read the session + cart. POST /api/v1/cart-sessions/ id /items — add an item. PATCH /api/v1/cart-sessions/ id /items/ itemId — update an item. DELETE /api/v1/cart-sessions/ id /items/ itemId — remove an item. POST /api/v1/cart-sessions/ id /shipping — select a shipping method. DELETE /api/v1/cart-sessions/ id /shipping — clear the selected method. POST /api/v1/cart-sessions/ id /discount — apply a discount code. DELETE /api/v1/cart-sessions/ id /discount — remove the discount. POST /api/v1/cart-sessions/ id /checkout-session — hand off to checkout. invalid_quote_token (401) — bad token, app/environment, or origin. origin_not_allowed (403) — origin not on the allowlist. cart_session_expired (410) — session is no longer active. item_not_found (404) — the item isn't in this session's cart. rate_limit_exceeded (429) — too many POST /cart-sessions creates from one client; back off and retry. );"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","2026-07-19 — Money-correctness: returns, order edits, cancel, currency","Returns & exchanges","Orders","Carts","2026-07-04 — Embedded checkout: webhooks, receipts, prefill & retry fixes","Webhooks","Emails","Checkout","2026-07-04 — Abandoned-cart recovery works end to end","Webhooks & email","2026-07-04 — Subscription checkout, typed request bodies, cart lifecycle","API & SDK","API","SDK","Docs","2026-06-20 — Cancel a checkout session","2026-06-20 — Canonical cart address + typed cart errors","2026-06-20 — Abandoned-carts read APIs","Embed config","2026-05-11 — Team management & per-app roles","Workspace invitations","Legacy compatibility","2026-05-04 — Collect flags, billing, metadata propagation","Embedded checkout","Discounts","Metadata + webhooks"],"text":"export const metadata: Metadata = title: Changelog , ; export default function ChangelogPage() return ( 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-07-19 — Money-correctness: returns, order edits, cancel, currency Returns & exchanges Return refunds now reflect what the buyer actually paid. The refund for a returned line is its subtotal plus tax, minus discounts (including a proportional share of order-level code discounts), prorated exactly across partial-quantity returns. Previously refunds used bare unitPrice × quantity , under-refunding tax and over-refunding discounted items. Orders PATCH /orders/:id/line-items now recalculates tax. When tax is configured for the application, edited orders re-quote tax for the resulting item set (added items no longer land with taxAmount: 0 ) and the captured delta charge/refund includes the tax movement. POST /orders/:id/cancel now settles payments. Open authorizations are always voided; pass refundCapturedPayments: true to also refund captured money. The response reports a paymentActions array. Carts Cart currency is validated against the application. POST /carts rejects a currency that differs from the application's configured per-environment currency (422 currency_mismatch ); omitting currency now inherits the application currency instead of silently defaulting to USD. Legacy deprecated discount types fail validation. Surviving free_shipping / buy_x_get_y rows (retired types) now fail checkout validation loudly instead of applying with a silent $0 effect while consuming a usage slot. 2026-07-04 — Embedded checkout: webhooks, receipts, prefill & retry fixes Webhooks payment.captured and payment.vaulted now deliver for embedded checkout. These events (and all webhooks from proxy/embed sessions, including order.created ) were silently dropped because the emit omitted application context. They now reach every subscribed endpoint with the correct signature. Emails customer.payment_receipt now sends for embedded checkout captures. The synchronous embed capture path previously bypassed the internal event bus, so no receipt email fired for card/one-time or subscription checkouts. Checkout Buyer prefill now reaches the checkout UI. Passing a customer on session create now pre-fills the buyer's name and address in the hosted/embedded form (the prefill was being stripped from the public session response). Failed payments are retryable. If a capture fails, returning to the same checkout session now retries cleanly instead of erroring; a duplicate submit returns the existing order without double-charging. 2026-07-04 — Abandoned-cart recovery works end to end Checkout Recovery links can now complete a purchase. Creating a checkout session against an abandoned cart now reopens it ( status → open ), so a buyer who returns via a cart.abandoned recovery link can finish checkout on that same cart. Previously the cart was frozen and completion dead-ended with cart … is in 'abandoned' status . Webhooks & email Guest carts get recovery emails. The abandoned-cart sweep now sends the customer.cart_abandoned recovery email to carts that captured only a customerEmail (no full customer record), not just carts linked to a customer. Configure the recovery link. The recovery URL in the cart.abandoned webhook payload and the recovery email is built from your per-app cartRecoveryUrlTemplate (must contain cartId ) — set it in the dashboard under Abandoned carts , or via PUT /api/v1/embed-config . Without it, recovery is webhook-only with a null URL. 2026-07-04 — Subscription checkout, typed request bodies, cart lifecycle API & SDK Checkout session request bodies are now documented. POST /api/v1/checkout/sessions and POST /api/v1/checkout/sessions/ id /complete now publish their requestBody in the OpenAPI spec. @usethrottle/api-client@2.6.1 regenerates postApiV1CheckoutSessions / postApiV1CheckoutSessionsComplete with a typed body parameter — no more raw-fetch workaround for creating or completing a session. Request/response validation is unchanged. Checkout Plan-based & free-trial subscription checkouts can use an empty cart. When a checkout session carries a recurring block and its cart has no line items, Throttle now synthesizes a single subscription line item from the plan at completion (amount due today for an immediate charge, or $0 for a free trial), so the order converts cleanly. Previously an empty cart failed with cart_empty (“Cart cannot be converted to an order without line items”). recurring.create: 'auto' governs subscription creation after payment; it does not add cart items itself. Webhooks Checkout-session expiry reaches the cart. A checkout session now stamps its expiry window onto the parent cart, and when a session expires the cart is released — its status moves to abandoned and cart.abandoned fires (with the standard recovery payload). Previously the cart stayed open with no event. 2026-07-04 — Actionable unavailableReason on payment methods API GET /api/v1/checkout-sessions/ id /payment-methods unavailableReason . When methods is empty because a payment provider is connected but can’t currently render (for example, not yet configured for the checkout’s environment), the response now includes an optional unavailableReason: code, message . Surface message to the buyer instead of a blank “no payment methods” state. The field is additive and only present on the empty path. The list itself now also reflects exactly what the embed will render, so the “available” view and the embed no longer diverge. 2026-06-21 — Cart email capture + richer cart.abandoned payload API Cart customerEmail . POST /api/v1/carts and PATCH /api/v1/carts/ id accept an optional customerEmail — lightweight email capture without a full customer record. The cart response now also returns customer"},{"title":"API Conventions","route":"/developers/conventions","headings":["API Conventions","Field naming: camelCase, single shape","Errors","Pagination","Identifier prefixes"],"text":"export const metadata: Metadata = title: Conventions , ; const requestExample = // POST /api/v1/carts request body applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , currency : USD ; const responseExample = // POST /api/v1/carts response data : id : 9f6e... , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , currency : USD , netN : null, lineItems : [] , meta : requestId : ... ; export default function ConventionsPage() return ( 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: 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_production_* and pk_production_* — production environment keys. whsec_* — webhook signing secret. );"},{"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":"export const metadata: Metadata = title: Discounts , ; const steps = [ title: Create a discount , body: Use the dashboard or API to create a percentage or fixed amount discount code. , , title: Preview in your storefront , body: Call /api/v1/discounts/preview from your server when the buyer enters a code. , , title: Apply to a cart , body: When checkout starts, apply the valid code to the Throttle cart before creating the checkout session. , , title: Lock totals , body: Run shipping/tax calculation and checkout_final against the discounted cart before payment. , , ]; const paymentFlowDiagram = sequenceDiagram participant B as Browser participant M as Merchant Backend participant T as Throttle API participant C as Checkout Iframe B->>M: buyer enters SUMMER25 M->>T: POST /api/v1/discounts/preview T-->>M: valid + discountTotal + total M-->>B: show adjusted order summary B->>M: buyer starts checkout M->>T: POST /api/v1/carts/ id /apply-discount M->>T: POST /api/v1/checkout/sessions M-->>B: sessionId B->>C: render checkout C->>T: complete payment with discounted total ; export default function DiscountsPage() return ( 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","Choosing the right surface","Session lifecycle","Sequence: postMessage handshake","Query parameter reference","postMessage event reference","Complete-session payloads","Payment-method filtering"],"text":"export const metadata: Metadata = title: Embedded Checkout , ; const handshakeChart = sequenceDiagram participant S as Storefront participant F as Throttle Iframe participant API as Throttle API S->>F: src=/s/ sessionId ?embed=1&parentOrigin=... F->>API: GET /payment-methods API-->>F: eligible methods F->>S: postMessage throttle.ready Note over F,S: buyer fills cart / address / billing F->>S: throttle.step.changed: address F->>S: throttle.step.changed: billing F->>S: throttle.step.changed: payment S->>F: buyer clicks Pay F->>S: throttle.processing F->>API: POST /checkout-sessions/ id /complete API-->>F: orderId, paymentId F->>S: throttle.completed orderId, paymentId S->>S: redirect to thank-you page ; const lifecycleSteps = [ title: Mint a session server-side , body: Your backend POSTs to /api/v1/checkout-sessions/embed-token with the cart amount and currency. The response includes the session id, a short-lived embed JWT, and both hostedUrl and embedUrl for direct rendering. (This payment-only endpoint does not accept allowedMethods — the payment widget renders the methods configured on your connection.) , , title: Render the iframe , body: Mount , , or a raw iframe pointing at hostedUrl or embedUrl. Throttle re-mints the embed JWT every render via GET /api/v1/checkout-sessions/:id/embed-token, so a buyer can land minutes after the original mint without breaking the flow. , , title: Buyer submits , body: When the buyer clicks Pay, the iframe POSTs /api/v1/checkout-sessions/:id/complete with one of three payload shapes (card, provider proxy, or net30) and surfaces processing/success/failure events to the parent. , , title: Parent reacts to events , body: Listen for throttle.completed (or onSucceeded in the React SDK) and navigate the buyer to your own thank-you page. Subscribe to outbound webhooks (order.created, payment.captured, payment.failed) for durable side-effects. , , ]; export default function EmbeddedCheckoutPage() return ( 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. 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. 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) , PaymentEmbed , ], [ <> /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 , 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. 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 ). Sequence: postMessage handshake Visualises the full conversation between your storefront page, the embedded iframe, and the Throttle API. Use it to sanity-check listener ordering — your parent page must register the message listener before the iframe emits throttle.ready . Query parameter reference Both /c and /s accept the same iframe-mode parameters. The React SDK sets these for you; documented here for raw integrations. embed , 1 , Toggles iframe mode (chromeless layout, postMessage emission, origin allowlist enforcement). Omit to render the standalone hosted page. , ], [ parentOrigin , https://shop.example.com , <> Required when embed=1 . Server-side check against the application's allowedOrigins (set in Embed Config); a missing or non-allowlisted origin renders the EmbedDenied panel instead of the checkout UI. , ], [ mode , payment-only , <> Skips the cart and address steps; jumps directly to the payment step. Use when your storefront already collected th"},{"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":"export const metadata: Metadata = title: Collection Flags , ; const sessionCreateBoth = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : true, billingAddress : true ; const sessionCreateShipOnly = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : true, billingAddress : false ; const sessionCreateBillOnly = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : false, billingAddress : true ; const sessionCreateNeither = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : false, billingAddress : false ; const completeBothBilling = POST /api/v1/checkout-sessions/sess_xxx/complete paymentMethod : card , shippingAddress : name : Jane Doe , line1 : 1 Market St , city : San Francisco , region : CA , postalCode : 94103 , country : US , billingAddress : name : Jane Doe , line1 : 1 Market St , city : San Francisco , region : CA , postalCode : 94103 , country : US , billingSameAsShipping : false ; const completeSameAsShipping = POST /api/v1/checkout-sessions/sess_xxx/complete paymentMethod : card , shippingAddress : ... : ... , billingSameAsShipping : true ; const collectMachineChart = flowchart LR Start([Session created]) --> S collect.shippingAddress? S -- true --> AddrShip[address step] S -- false --> Bcheck1 collect.billingAddress? AddrShip --> Bcheck2 collect.billingAddress? Bcheck2 -- true --> Bill[billing step] Bcheck2 -- false --> Pay1[payment step] Bcheck1 -- true --> BillOnly[billing step] Bcheck1 -- false --> Pay2[payment step] Bill --> Pay3[payment step] BillOnly --> Pay4[payment step] Pay1 --> Done([throttle.completed]) Pay2 --> Done Pay3 --> Done Pay4 --> Done ; const addressRequired422 = HTTP/1.1 422 Unprocessable Entity Content-Type: application/json error : code : address_required , message : Billing address is required for this session. , field : billingAddress ; export default function CollectionFlagsPage() return ( 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: collect.shippingAddress , boolean , true , <> When true , the iframe renders the shipping address step and /complete requires shippingAddress . , ], [ collect.billingAddress , boolean , false , <> 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 and stamped them on metadata.customer_prefill . 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 : name , line1 , line2? , city , region , postalCode , country . 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 "},{"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":"export const metadata: Metadata = title: Parent Controls , ; const reactSnippet = use client ; export function CheckoutPage( sessionId : sessionId: string ) const register, watch = useForm( mode: onChange ); const email = watch( email ); const name = watch( name ); const formValid = !!email && !!name; return ( <> router.push(\\ /orders/\\$ orderId \\ ) /> ); ; const vanillaJsSnippet = // Direct postMessage when not using @usethrottle/checkout-react. const iframe = document.querySelector( #throttle-checkout ); const throttleOrigin = https://checkout.usethrottle.dev ; function setSubmitDisabled(disabled) iframe.contentWindow.postMessage( source: throttle-parent , version: 1, type: set-submit-disabled , disabled, , throttleOrigin, ); form.addEventListener( input , () => setSubmitDisabled(!form.checkValidity()); ); ; const messageShape = source : throttle-parent , // distinguishes parent commands from version : 1, // protocol version; bumps are additive type : set-submit-disabled , disabled : true | false ; export default function ParentControlsPage() return ( 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":"Environments","route":"/developers/environments","headings":[],"text":"export metadata from ../test-mode/page ; export default from ../test-mode/page ;"},{"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 (show once — store both values immediately):","\"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","\"webhookSigningSecret\": \"whsec_...\" ← only present if webhookUrl was declared; shown once","}","}`;"],"text":"export const metadata: Metadata = title: Build an Extension , ; const steps = [ title: Create the extension in the dashboard , body: Go to Extensions in your workspace sidebar. Give it a name, declare your iframeUrl (for UI extensions) and/or a webhookUrl + eventSubscriptions (for event extensions), and list the scopes you need. , , title: Create and publish a version , body: POST /api/v1/extensions/:id/versions to snapshot the current manifest as an immutable version, then POST /api/v1/extensions/:id/versions/:vid/publish to mark it installable. , , title: Install into an application , body: POST /api/v1/extensions/:id/install with a versionId. The response includes a one-time API key and (if webhookUrl is declared) a webhookSigningSecret. Store both securely — they are never shown again. , , title: Wire up the iframe bridge , body: In your iframe page, call createBridge() from @usethrottle/extension-bridge (npm) or load the hosted script. Wait for bridge.ready, then use bridge.api.* to call Throttle REST endpoints. , , ]; const registerSnippet = # 1. Create the extension catalog entry curl -X POST https://api.usethrottle.dev/api/v1/extensions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d name : Inventory Sidebar , iframeUrl : https://extensions.example.com/inventory , webhookUrl : https://extensions.example.com/webhooks , eventSubscriptions : [ order.created , order.completed ], scopes : [ orders:read , customers:read ] # → data: id: ext_... , name: Inventory Sidebar , lifecycle: draft , ... ; const versionSnippet = # 2. Create a version (snapshots the current manifest) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d version : 1.0.0 , notes : Initial release # → data: id: ver_... , version: 1.0.0 , status: draft , ... # 3. Publish the version (marks it installable) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions/ver_xxx/publish \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d # → data: id: ver_... , status: published , ... ; const installSnippet = # 4. Install into an application (requires X-Throttle-Application-Id header) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/install \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H X-Throttle-Environment-Id: \\\\ -H content-type: application/json \\\\ -d versionId : ver_xxx # Response (show once — store both values immediately): # # 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 # webhookSigningSecret : whsec_... ← only present if webhookUrl was declared; shown once # # ; const bridgeHostedSnippet = const bridge = window.ThrottleBridge.createBridge( targetOrigin: https://app.usethrottle.dev , ); bridge.ready.then((ctx) => console.log( Extension running as , ctx.user.email, in , ctx.environment.environmentSlug); bridge.api.get( /api/v1/orders?limit=10 ).then((res) => console.log( Recent orders: , res.data); ); bridge.resize(480); bridge.toast( Inventory synced , success ); bridge.navigate( /orders ); ); ; const bridgeNpmSnippet = // Option B: npm package (React / bundled apps) const bridge = createBridge( targetOrigin: https://app.usethrottle.dev , ); const ctx = await bridge.ready; console.log( workspace: , ctx.workspace.slug, app: , ctx.application.slug); console.log( environment: , ctx.environment.environmentSlug, role: , ctx.role); console.log( scopes: , ctx.scopes); const orders = await bridge.api.get( /api/v1/orders?limit=5 ); const customer = await bridge.api.get( /api/v1/customers/cust_xxx ); await bridge.api.post( /api/v1/orders/ord_xxx/notes , note: Inventory reserved ); bridge.resize(600); // resize the iframe panel bridge.toast( Done , info ); // show a dashboard toast bridge.navigate( /orders ); // navigate the parent window bridge.destroy(); ; const bridgeContextSnippet = type SessionContext = user: id: string; email: string ; workspace: id: string; slug: string ; application: id: string; slug: string ; environment: environmentId: string; environmentSlug: string; environmentKind: production | non_production ; providerEnvironment: production | sandbox ; ; installationId: string; extensionId: string; version: string; role: string; scopes: string[]; ; const ctx = await bridge.ready; const bridgeReactSnippet = // React hook pattern export function useExtensionBridge() const bridgeRef = useRef (null); const [ctx, setCtx] = useState (null); useEffect(() => const bridge = createBridge( targetOrigin: https://app.usethrottle.dev ); bridgeRef.current = bridge; bridge.ready.then(setCtx); return () => bridge.destroy(); , []); return bridge: bridgeRef.current, ctx ; function InventorySidebar() const bridge, ctx = useExtensionBridge(); useEffect(() => if (!bridge || !ctx) return; bridge.api.get( /api/v1/orders?limit=20 ).then((res: any) => ); , [bridge, ctx]); return ... ; ; export default function ExtensionsBuildPage() return ( 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 au"},{"title":"Extension Events","route":"/developers/extensions/events","headings":["Extension Events","Payload envelope","Verify signatures","Process idempotently","Delivery inspection and replay","Auto-suspend on repeated failures","Event catalog","Replay a dead-lettered delivery","Send a verification ping to the installation's webhook URL"],"text":"export const metadata: Metadata = title: Extension Events , ; const eventsByScope = (() => const groups = new Map (); for (const type of ALL_EVENT_TYPES) const scope = eventReadScope(type); const list = groups.get(scope) ?? []; list.push(type); groups.set(scope, list); return [...groups.entries()] .map(([scope, types]) => ( scope, types: [...types].sort() )) .sort((a, b) => a.scope.localeCompare(b.scope)); )(); const envelopeSnippet = id : evt_01HZX... , type : order.created , version : 1 , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , environmentId : 8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 , createdAt : 2026-05-31T10:00:00.000Z , data : orderId : ord_01HZX... , cartId : cart_01HZX... , status : pending , currency : USD , total : 12900 ; const verifySnippet = // Node.js — import from the subpath entry (uses node:crypto) app.post( /webhooks/throttle , express.raw( type: */* ), (req, res) => const rawBody = req.body.toString( utf8 ); // ← raw string, BEFORE JSON.parse const signature = req.headers[ x-throttle-signature ] as string; const secret = process.env.EXTENSION_WEBHOOK_SECRET!; // from install response const valid = verifyWebhook(rawBody, signature, secret); if (!valid) return res.status(400).json( error: Invalid signature ); const event = JSON.parse(rawBody); console.log( Received event: , event.id, event.type); res.status(200).send(); ); ; const verifyEdgeSnippet = // Cloudflare Workers / Deno / Bun — use verify.ts (Web Crypto API, async) export default async fetch(request: Request): Promise const rawBody = await request.text(); const signature = request.headers.get( x-throttle-signature ) ?? ; const secret = env.EXTENSION_WEBHOOK_SECRET; const valid = await verifyWebhook(rawBody, signature, secret); if (!valid) return new Response( Bad signature , status: 400 ); const event = JSON.parse(rawBody); return new Response( ok ); , ; ; const idempotencySnippet = // Store event IDs before processing side effects to handle at-least-once delivery. const eventId = event.id; const alreadyProcessed = await db.query( SELECT 1 FROM processed_events WHERE event_id = $1 , [eventId], ); if (alreadyProcessed.rowCount > 0) return res.status(200).json( status: duplicate ); await processOrder(event.data); await db.query( INSERT INTO processed_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW()) , [eventId, event.type], ); ; const replaySnippet = # Inspect deliveries for an installation GET /api/v1/installations/:id/deliveries?limit=50&offset=0 # Replay a dead-lettered delivery POST /api/v1/installations/:id/deliveries/:deliveryId/replay # Send a verification ping to the installation s webhook URL POST /api/v1/installations/:id/test ; export default function ExtensionsEventsPage() return ( 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 , createdAt , data . 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. 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. Auto-suspend on repeated failures If an extension's webhook endpoint fails all five delivery attempts repeatedly, Throttle may suspend the installation ( status: "suspended" ). A suspended installation stops receiving new events. Resume via POST /api/v1/extensions/:id/suspend (which actually toggles — use it to re-activate) or reinstall the extension to get a fresh install row with a fresh endpoint. Event catalog Each event type below requires the matching read scope in your extension manifest's scopes list to subscribe to it. Throttle validates this at registration time — if eventSubscriptions references an event whose required scope is not in the manifest's scopes , the create/update call returns 400 insufficient_scopes . Extensions may only request extensionAllowed scopes — workspace events ( worksp"},{"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":"export const metadata: Metadata = title: Get Started with Extensions ; const steps = [ title: Choose an application in Test mode , body: Create or select a Throttle application and switch the dashboard to Test mode. Catalog entries belong to the publisher workspace; each installation belongs to one application and environment. , title: Prepare your application , body: Use the public starter repository when it is available, or host an HTTPS iframe, webhook handler, or both. Your extension never needs access to Throttle\\ s private source repository. , title: Expose local endpoints over HTTPS , body: Use a secure development tunnel for your iframe and webhook endpoints. Keep the tunnel URL stable while configuring and testing the catalog entry. , title: Create a draft catalog entry , body: Open Extensions → Catalog, enter the iframe URL and/or webhook URL, select event subscriptions, and request only the scopes the app actually uses. , title: Publish a private version , body: Create version 0.1.0 from the draft manifest and publish it. Keep the extension private while developing and running acceptance tests. , title: Install it into the Test environment , body: Use the install wizard for the selected application. Capture one-time server credentials in a secret manager; never put them in browser code or source control. , title: Verify both surfaces , body: Confirm the iframe receives verified identity context and send a signed test event to the webhook. Inspect delivery logs and repeat a delivery to confirm idempotency. , ]; export default function ExtensionGetStartedPage() return 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 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":"export const metadata: Metadata = title: Extension Identity JWT , ; const claimsSnippet = iss : throttle , sub : , aud : , iat : 1748000000, exp : 1748000600, // 10-minute TTL installationId : inst_01HZX... , extensionId : ext_01HZX... , version : 1.0.0 , // pinned semver of the installed version workspace : id : ws-uuid , slug : acme , application : id : app-uuid , slug : acme-store , environment : environmentId : env-uuid , environmentSlug : uat , environmentKind : non_production , providerEnvironment : sandbox , user : id : user-clerk-id , email : alice@acme.com , name : alice@acme.com , role : admin , // app-level role: admin | developer | finance | viewer scopes : [ orders:read , customers:read ] // from the pinned version ; const mintSnippet = // The dashboard calls this endpoint automatically during the bridge handshake. const res = await fetch( https://api.usethrottle.dev/api/v1/installations/inst_xxx/launch-token , method: POST , headers: Authorization: Bearer , X-Throttle-Application-Id : , X-Throttle-Environment-Id : , content-type : application/json , , body: JSON.stringify( ), , ); const data = await res.json(); const verifyJwksSnippet = import createRemoteJWKSet, jwtVerify from jose ; const JWKS = createRemoteJWKSet( new URL( https://api.usethrottle.dev/.well-known/extension-jwks.json ), ); export async function verifyExtensionToken(token: string) try const payload = await jwtVerify(token, JWKS, issuer: throttle , algorithms: [ RS256 ], ); return installationId: payload.installationId as string, extensionId: payload.extensionId as string, version: payload.version as string, workspace: payload.workspace as id: string; slug: string , application: payload.application as id: string; slug: string , environment: payload.environment as environmentId: string; environmentSlug: string; environmentKind: production | non_production ; providerEnvironment: production | sandbox ; , user: payload.user as id: string; email: string; name: string , role: payload.role as string, scopes: payload.scopes as string[], ; catch return null; // expired, bad signature, wrong issuer — always fail closed ; const bearerSnippet = // The iframe bridge automatically attaches the token as a Bearer on every const token = bridge.getToken(); const res = await fetch( https://api.usethrottle.dev/api/v1/orders?limit=10 , headers: Authorization: \\ Bearer \\$ token \\ , content-type : application/json , , ); ; const autoRefreshSnippet = // bridge.api.* handles refresh automatically. const currentToken = bridge.getToken(); export default function ExtensionsIdentityPage() return ( 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. 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 "},{"title":"Installing Extensions","route":"/developers/extensions/install","headings":["Installing Extensions","Install an extension","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 (show once — store both values immediately) ────────────────────","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":"export const metadata: Metadata = title: Installing Extensions , ; const installSnippet = # Install a published version of an extension into an application. # Both the dashboard session token (Clerk JWT) and the Application ID header # are required — installs are application-scoped. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/install \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H X-Throttle-Environment-Id: \\\\ -H content-type: application/json \\\\ -d versionId : ver_xxx , config : syncInterval : 60, notifyOnNew : true # ─── Response (show once — store both values immediately) ──────────────────── data : id : inst_01HZX... , extensionId : ext_xxx , versionId : ver_xxx , status : active , environmentId : 8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 , environmentSlug : uat , environmentKind : non_production , providerEnvironment : sandbox , config : syncInterval : 60, notifyOnNew : true , createdAt : 2026-05-31T10:00:00.000Z , apiKey : sk_uat_... , // ← never shown again webhookSigningSecret : whsec_... // ← never shown again (only if webhookUrl declared) ; const uninstallSnippet = # Uninstall removes the active install for this application + environment. # The extension catalog entry and other applications installs are unaffected. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/uninstall \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: # → 204 No Content ; const configSnippet = # Update the installation configuration after install. curl -X PATCH https://api.usethrottle.dev/api/v1/installations/inst_xxx/config \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d config : syncInterval : 120, notifyOnNew : false # → data: id: inst_xxx , config: ... , ... ; const bridgeConfigSnippet = import createBridge from @usethrottle/extension-bridge ; const bridge = createBridge( targetOrigin: https://app.usethrottle.dev ); const ctx = await bridge.ready; const installs = await bridge.api.get( /api/v1/installations ) as data: Array >; ; const install = installs.data.find((item) => item.id === ctx.installationId); const config = install?.config ?? ; ; const configSchemaSnippet = type : object , properties : apiKey : type : string , title : Provider API key , description : Issued by the upstream provider , syncInterval : type : number , title : Sync interval , notifyOnNew : type : boolean , title : Notify on new records , required : [ apiKey ] ; const listSnippet = # List all installations for the current application + environment. GET /api/v1/installations -H Authorization: Bearer -H X-Throttle-Application-Id: -H X-Throttle-Environment-Id: # Response shape: paginated list # # data : [ id : inst_... , extensionId : ext_... , status : active , ... ], # meta : requestId : ... , pagination : cursor : null, hasMore : false # ; export default function ExtensionsInstallPage() return ( 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 and webhookSigningSecret are shown exactly once in the install response. If you lose either value, the only recovery is to uninstall and reinstall — a new key and signing secret will be generated. 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 co"},{"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":"export const metadata: Metadata = title: Operating Your Extension ; export default function ExtensionOperationsPage() return 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":"export const metadata: Metadata = title: Extensions Overview , ; const lifecycleChart = stateDiagram-v2 [*] --> draft: POST /extensions draft --> private: POST /extensions/:id/lifecycle (lifecycle=private) private --> draft: lifecycle=draft private --> public: lifecycle=public (admin only) public --> private: lifecycle=private draft --> [*]: DELETE /extensions/:id ; export default function ExtensionsOverviewPage() return ( 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 shown once at install 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"},{"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\"","}","}`;","→ { data: { id: \"ext_xxx\", lifecycle: \"public\", ... } }`;"],"text":"export const metadata: Metadata = title: Publishing to the Marketplace , ; const reviewLifecycleChart = stateDiagram-v2 [*] --> not_submitted: Private extension with a published version not_submitted --> pending: Developer submits for review pending --> in_review: Staff claims the review in_review --> approved: Staff approves in_review --> changes_requested: Staff requests changes in_review --> rejected: Staff rejects changes_requested --> pending: Developer resubmits approved --> public: Developer clicks Publish to marketplace ; const submitSnippet = # Submit the latest published version of your extension for review. # Requires: an accepted Publisher Agreement + a completed Listing. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/reviews \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d # Response: # # data : # id : rev_01HZX... , # extensionId : ext_xxx , # status : pending , # versionId : ver_xxx , # createdAt : 2026-06-06T10:00:00.000Z # # ; const lifecycleSnippet = # Once the review is approved, the developer publishes the extension. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/lifecycle \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d lifecycle : public # → data: id: ext_xxx , lifecycle: public , ... ; export default function ExtensionsPublishingPage() return ( 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. The public marketplace is controlled by the EXTENSIONS_PUBLIC_MARKETPLACE environment flag. The entire pipeline ships dark; the flag is flipped once the Publisher Agreement text is finalized. All API endpoints and review tooling are available regardless of the flag. 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 or SVG uploaded via the icon-upload flow. Stored as a public S3 object and referenced by its public URL. At least one screenshot — a minimum of one screenshot or promotional image (PNG/JPEG, 1280 × 800 recommended). 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. 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/extensi"},{"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":"export const metadata: Metadata = title: Extension Scopes , ; const extensionScopeGroups = (() => return Object.entries(SCOPE_GROUPS) .map(([group, scopeIds]) => ( group, scopes: scopeIds .map((id) => SCOPE_REGISTRY[id]) .filter((def) => def && def.extensionAllowed), )) .filter((g) => g.scopes.length > 0) .sort((a, b) => a.group.localeCompare(b.group)); )(); const manifestScopeSnippet = // Extension manifest with scopes name : Order Inspector , iframeUrl : https://extensions.example.com/orders , webhookUrl : https://extensions.example.com/webhooks , eventSubscriptions : [ order.created , order.updated , payment.captured ], scopes : [ orders:read , // required for order.* events + GET /api/v1/orders payments:read , // required for payment.* events + GET /api/v1/payments customers:read // optional — allows reading customer records ] ; const ceilingSnippet = // The extension s JWT token carries the scopes from the pinned version. const orders = await bridge.api.get( /api/v1/orders?limit=10 ); export default function ExtensionsScopesPage() return ( 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":"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":"export const metadata: Metadata = title: Extension Security Model , ; const jwksVerifySnippet = import createRemoteJWKSet, jwtVerify from jose ; const JWKS = createRemoteJWKSet( new URL( https://api.usethrottle.dev/.well-known/extension-jwks.json ), ); async function verifyToken(token: string) try const payload = await jwtVerify(token, JWKS, issuer: throttle , // always throttle algorithms: [ RS256 ], // only RS256 — no HS256 ); return payload; catch return null; // expired, bad sig, wrong issuer → reject ; const sandboxSnippet = ; const scopeCeilingSnippet = // The extension token s scopes come from the pinned version s scopes list. const res = await bridge.api.get( /api/v1/team-members ); // → 403 insufficient_scopes ; const signatureVerifySnippet = import verifyWebhook from @usethrottle/extension-bridge/webhook ; function handleWebhook(rawBody: string, signatureHeader: string): boolean return verifyWebhook( rawBody, signatureHeader, process.env.EXTENSION_WEBHOOK_SECRET!, toleranceSeconds: 300 , // default — reject deliveries > 5 min old ); export default function ExtensionsSecurityPage() return ( 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 — rotating it requires uninstalling and reinstalling. 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. Uninstalling and reinstalling rotates the secret. See the Events reference for the full verification workflow and event catalog. Summary RS256 + "},{"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":"const cloneSnippet = git clone https://github.com/Epic-Design-Labs/throttle-extension-starter.git cd throttle-extension-starter corepack enable # picks up the pinned pnpm version pnpm install pnpm dev # embedded UI at http://localhost:5173 (mocked bridge) pnpm check # format, lint, typecheck, tests, build — must pass ; export const metadata: Metadata = title: Extension Starter Repository ; export default function ExtensionStarterRepositoryPage() return 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. 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":"export const metadata: Metadata = title: Testing Extensions ; export default function ExtensionTestingPage() return 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":"export const metadata: Metadata = title: Extension Versioning , ; const versionLifecycleChart = stateDiagram-v2 [*] --> draft: POST /extensions/:id/versions draft --> published: POST .../publish published --> [*]: (immutable — never changed) ; const createVersionSnippet = # Create a new version (snapshots the current manifest) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d version : 1.1.0 , notes : Added customers:read scope for contact lookup feature # Response: # # data : # id : ver_new... , # version : 1.1.0 , # status : draft , # scopes : [ orders:read , customers:read ], # notes : ... , # createdAt : ... # # ; const publishVersionSnippet = # Publish a draft version (makes it installable) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions/ver_new/publish \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d ; const listVersionsSnippet = # List all versions for an extension GET /api/v1/extensions/ext_xxx/versions # Response: data: versions: [ id, version, status, scopes, ... , ... ] ; const upgradeSnippet = # Attempt to upgrade to a new version curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_new... # 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: curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_new... , acknowledgedNewScopes : true # → data: id: inst_xxx , versionId: ver_new... , ... ; const rollbackSnippet = # Roll back to an older published version by upgrading to it. # 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. curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_old... ; export default function ExtensionsVersioningPage() return ( 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":"export const metadata: Metadata = title: Idempotency , ; const usageSnippet = // Generate a UUID v4 per logical operation; reuse it on retry. const idempotencyKey = randomUUID(); await fetch( https://api.usethrottle.dev/api/v1/carts , method: POST , headers: Authorization : \\ Bearer \\$ process.env.THROTTLE_API_KEY \\ , Content-Type : application/json , Idempotency-Key : idempotencyKey, , body: JSON.stringify( applicationId: process.env.THROTTLE_APPLICATION_ID, currency: USD , ), ); ; const replayHeaderSnippet = # Second request with the same key + same body returns the # first response byte-for-byte. The header below tells you it s a replay. HTTP/1.1 201 Created content-type: application/json idempotency-replay: true id : 9f6e... , ... ; const reusedSnippet = // 409 idempotency_key_reused — same key, DIFFERENT body. error : code : idempotency_key_reused , message : Idempotency-Key was already used with a different request body. Generate a new key for new operations. ; export default function IdempotencyPage() return ( 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":"Customer Invoices","route":"/developers/invoices","headings":["Customer Invoices","One list, three payment types","Auth and trust model","Pagination","Per-application branding","Refund behavior","REST routes quick reference","Pages in this section","Merchant API: list invoices for a specific customer","Redirect directly to PDF (default)"],"text":"export const metadata: Metadata = title: Customer Invoices , ; const proxySnippet = // app/api/throttle/invoices/[...path]/route.ts const handler = createInvoiceProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getCustomerId(request) const user = await auth(); return user?.id ?? null; , ); export handler as GET ; ; const paginationSnippet = // Cursor-paginated response envelope data : [ id : inv_abc123 , number : INV-sprinter-001 , status : paid , sourceType : order , currency : USD , subtotal : 4999, taxTotal : 400, shippingTotal : 699, discountTotal : 0, total : 6098, amountRefunded : 0, issuedAt : 2026-06-01T10:00:00Z , dueAt : null, periodStart : null, periodEnd : null, createdAt : 2026-06-01T10:00:00Z ], meta : pagination : cursor : eyJpZCI6Imlu... , hasMore : true ; const curlListSnippet = # Merchant API: list all invoices (all source types) for the application curl -G https://api.usethrottle.dev/api/v1/invoices \\\\ -H x-api-key: $THROTTLE_SECRET_KEY \\\\ --data-urlencode limit=25 \\\\ --data-urlencode sort=desc # Merchant API: list invoices for a specific customer curl https://api.usethrottle.dev/api/v1/customers/cus_xyz/invoices \\\\ -H x-api-key: $THROTTLE_SECRET_KEY ; const curlDownloadSnippet = # Get a signed PDF download URL (JSON mode) curl https://api.usethrottle.dev/api/v1/invoices/inv_abc123/download?format=json \\\\ -H x-api-key: $THROTTLE_SECRET_KEY # Redirect directly to PDF (default) curl -L https://api.usethrottle.dev/api/v1/invoices/inv_abc123/download \\\\ -H x-api-key: $THROTTLE_SECRET_KEY \\\\ --output invoice.pdf ; const curlDownloadResponseSnippet = data : url : https://s3.us-east-1.amazonaws.com/…signed-url ; export default function CustomerInvoicesPage() return ( 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. 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":"export const metadata: Metadata = title: React Package · @usethrottle/invoices , ; const installSnippet = pnpm add @usethrottle/invoices ; const proxySnippet = // app/api/throttle/invoices/[...path]/route.ts const handler = createInvoiceProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getCustomerId(request) const user = await auth(); return user?.id ?? null; , ); export handler as GET ; ; const providerSnippet = use client ; export function Providers( children : children: React.ReactNode ) return ( fetch(\\ /api/throttle/invoices\\$ path \\ , init) > children ); ; const useInvoicesSnippet = import useInvoices from @usethrottle/invoices ; function InvoiceList() const data, isLoading, error = useInvoices( limit: 20, sort: desc , ); if (isLoading) return ; if (error) return ; if (!data) return null; return ( <> data.data.map((invoice) => ( )) data.pagination?.hasMore && ( ) ); ; const useInvoiceSnippet = import useInvoice from @usethrottle/invoices ; function InvoiceDetail( id : id: string ) const data: invoice, isLoading = useInvoice(id); if (isLoading || !invoice) return ; return ( invoice.number Status: invoice.status Total: invoice.total / 100 invoice.currency invoice.amountRefunded ? ( Refunded: invoice.amountRefunded / 100 invoice.currency ) : null ); ; const useDownloadSnippet = import useDownloadInvoice from @usethrottle/invoices ; function DownloadButton( invoiceId : invoiceId: string ) const download = useDownloadInvoice(); return ( download(invoiceId) > Download PDF ); ; const useDownloadUrlSnippet = import useDownloadInvoice from @usethrottle/invoices ; function InvoiceLink( invoice : invoice: Invoice ) const download = useDownloadInvoice(); return ( e.preventDefault(); await download(invoice.id); // Opens PDF in new tab > View PDF ); ; const manualPaginationSnippet = import useState from react ; function PaginatedList() const [cursor, setCursor] = useState (null); const data, isLoading = useInvoices( cursor: cursor ?? undefined, limit: 20 ); return ( <> data?.data.map((inv) => ) data?.pagination?.hasMore && ( setCursor(data.pagination!.cursor) > Load more ) ); ; const invalidateSnippet = import useInvoiceInvalidation from @usethrottle/invoices ; function RefundButton( paymentId : paymentId: string ) const invalidate = useInvoiceInvalidation(); async function handleRefund() await fetch(\\ /api/payments/\\$ paymentId /refund\\ , method: POST ); invalidate(); return Refund ; ; export default function InvoicesReactPackagePage() return ( 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 — hu"},{"title":"MCP server","route":"/developers/mcp","headings":["MCP server","Install","What it can do","Catching field-name mistakes before they 400","Writes","Options"],"text":"export const metadata: Metadata = title: MCP server , ; const claudeCodeSnippet = # Claude Code claude mcp add throttle -e THROTTLE_API_KEY=sk_uat_... -- npx -y @usethrottle/mcp ; const configSnippet = mcpServers : throttle : command : npx , args : [ -y , @usethrottle/mcp ], env : THROTTLE_API_KEY : sk_uat_... ; const validateSnippet = // The assistant checks a body before writing integration code. validate_payload( method: POST , path: /api/v1/carts/ id /items , body: name: Widget , unit_price: 2500, storeId: store_123 ) ok : false, matched : POST /api/v1/carts/ id /items , errors : [ field : unit_price , problem : retired public field name — the API rejects it , suggestion : use \\\\ unitPrice\\\\ , field : storeId , problem : retired public field name — the API rejects it , suggestion : use \\\\ applicationId\\\\ , field : unitPrice , problem : required field is missing ] ; export default function McpPage() return ( 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. 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. 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. explain_checkout_failure — session state, cart, emitted events and recent webhook deliveries for one failing checkout, in a single call. get_integration_status — connectors, webhook endpoints and delivery health: “am I ready to go live?” search_events , list_webhook_deliveries , get_webhook_delivery — the event log and outbound delivery records, including the payload we sent and the response we got. seed_test_data — a customer, cart, line items and a finalized draft order so a fresh integration has something to read. Refuses to run against production. Catching field-name mistakes before they 400 Throttle's public API is strict camelCase and rejects retired aliases. Those aliases are exactly what a model tends to guess, so the server names the replacement instead of letting you find out from a 400. Writes The current release is read-only against real data. Sandbox seeding is the one exception, and it is refused outright on a production credential. Write tools — quotes, refunds, subscription changes, webhook replay — arrive in the next release behind an explicit --allow-writes flag, with a second --allow-live-writes gate for production keys and an idempotency key on every call. Holding a write scope means the key may write; the flag is how you say you intended it to. Options --api-key <key> — or the THROTTLE_API_KEY environment variable. --api <url> — or THROTTLE_API_URL . Defaults to https://api.usethrottle.dev . --allow-writes , --allow-live-writes — reserved for the write tools described above. A key belongs to one environment. To let an assistant see both your production and a sandbox environment, add two server entries with different keys and distinct names. );"},{"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"],"text":"export const metadata: Metadata = title: Metadata , ; const customerSnippet = // Customers — use the REST API until a focused customer SDK is published. const create = await fetch( https://api.usethrottle.dev/api/v1/customers , method: POST , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( email: jane@example.com , externalId: user_42 , metadata: plan: enterprise , cohort: q2-2026 , referredBy: partner_x , , ), ); const data: customer = await create.json(); const data: fetched = await fetch(\\ https://api.usethrottle.dev/api/v1/customers/\\$ customer.id \\ , headers: x-api-key : process.env.THROTTLE_API_KEY! , ).then((res) => res.json()); console.log(fetched.metadata.plan); // enterprise ; const subscriptionSnippet = // Subscriptions const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_API_KEY!, ); const now = new Date(); const end = new Date(now); end.setMonth(end.getMonth() + 1); const sub = await subscriptions.create( externalCustomerId: user_42 , planReference: pro_monthly , interval: monthly , amount: 2999, currentPeriodStart: now.toISOString(), currentPeriodEnd: end.toISOString(), metadata: salesRepId: rep_99 , contractId: con_2026_xyz , region: EMEA , , ); await subscriptions.update(sub.id, metadata: ...sub.metadata, contractRenewedAt: 2026-05-03 , ); ; const cartSnippet = // Carts AND cart line items const carts = new CartClient( apiKey: process.env.THROTTLE_API_KEY!, ); const cart = await carts.carts.create( applicationId, metadata: source: mobile_app , utmCampaign: summer_sale , ); await carts.items.add(cart.id, type: subscription , name: Pro Plan , unitPrice: 2999, quantity: 1, metadata: sku: PRO-001 , catalogVersion: 2026-04 , fulfillmentType: access_grant , resourceType: plan , resourceId: pro_monthly , permissions: [ read ], requiresShipping: false, , ); ; const fulfillmentMetadataSnippet = // Digital product: auto-fulfilled after payment. await carts.items.add(cart.id, type: product , name: Design System Kit , unitPrice: 4900, quantity: 1, metadata: sku: DS-KIT-2026 , fulfillmentType: digital , downloadUrl: https://cdn.example.com/design-system-kit.zip , downloadLimit: 3, downloadExpiry: 2026-12-31T23:59:59.000Z , requiresShipping: false, , ); await carts.items.add(cart.id, type: subscription , name: Pro Plan , unitPrice: 2999, quantity: 1, metadata: fulfillmentType: access_grant , resourceType: plan , resourceId: pro_monthly , permissions: [ read ], , ); ; const orderPaymentSnippet = // Orders + payments await fetch(\\ https://api.usethrottle.dev/api/v1/orders/\\$ orderId \\ , method: PATCH , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( metadata: fulfillmentVendor: shipstation , merchantNotes: priority , , ), ); await fetch(\\ https://api.usethrottle.dev/api/v1/orders/\\$ orderId /payments\\ , method: POST , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( amount: 2999, currency: USD , method: embedded , metadata: processedBy: rep_99 , urgentRefund: false , ), ); ; const checkoutSnippet = // Checkout sessions const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_API_KEY!, ); await subscriptions.createCheckoutSession( applicationId, externalCartId, customer: externalCustomerId: user_42 , email: jane@example.com , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , metadata: // sibling to recurring landingPage: /pricing/pro , referralCode: FRIEND10 , , returnUrl: https://shop.example/success , cancelUrl: https://shop.example/pricing , ); ; export default function MetadataPage() return ( 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. 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 "},{"title":"Pick the Smallest Package That Does the Job","route":"/developers/packages","headings":["Pick the Smallest Package That Does the Job","Package Boundary"],"text":"export const metadata: Metadata = title: Packages , ; export default function PackagesPage() return ( 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":"Payment Methods","route":"/developers/payment-methods","headings":["Payment Methods","Two-step auth flow","Endpoint reference","The SavedCard shape","Error reference","Security notes","React package","Step 1 — backend: mint a client token","Step 2 — frontend: render the component"],"text":"export const metadata: Metadata = title: Payment Methods , ; const flowDiagram = sequenceDiagram participant B as Buyer Browser participant M as Your Backend participant T as Throttle API M->>T: POST /api/v1/payment-methods/client-token customerId note over M,T: Authenticated with sk_* secret key T-->>M: token, expiresAt M-->>B: token B->>T: GET /api/v1/me/payment-methods note over B,T: X-Throttle-PM-Token: T-->>B: data: SavedCard[] B->>T: PATCH /api/v1/me/payment-methods/:id isDefault: true T-->>B: data: SavedCard B->>T: POST /api/v1/me/payment-methods/embed-token T-->>B: embedToken, checkoutSessionId, ... note over B,T: Buyer adds new card via the add-only vault embed ; const mintTokenSnippet = // Your backend — Node.js / Next.js App Router route handler export async function POST(request: Request) const user = await auth(); // your auth layer if (!user) return new Response(null, status: 401 ); const customerId = await resolveThrottleCustomerId(user.id); // cus_* const token, expiresAt = await mintClientToken( apiKey: process.env.THROTTLE_SECRET_KEY!, customerId, ); return Response.json( token, expiresAt ); ; const mintTokenResponseSnippet = data : token : pm_tkn_eyJhbGci... , expiresAt : 2026-06-24T11:00:00.000Z ; const listSnippet = // Browser — fetch the current customer s saved cards const res = await fetch( https://api.usethrottle.dev/api/v1/me/payment-methods , headers: X-Throttle-PM-Token : token , ); const data: cards = await res.json(); ; const listResponseSnippet = data : [ id : cpm_abc123 , methodType : card , cardBrand : visa , cardLastFour : 4242 , cardExpMonth : 12, cardExpYear : 2027, isDefault : true , id : cpm_def456 , methodType : card , cardBrand : mastercard , cardLastFour : 0001 , cardExpMonth : 8, cardExpYear : 2026, isDefault : false ] ; const setDefaultSnippet = // Browser — set a card as the default const res = await fetch( \\ https://api.usethrottle.dev/api/v1/me/payment-methods/\\$ cardId \\ , method: PATCH , headers: X-Throttle-PM-Token : token, Content-Type : application/json , , body: JSON.stringify( isDefault: true ), , ); const data: updatedCard = await res.json(); ; const removeSnippet = // Browser — remove a saved card const res = await fetch( \\ https://api.usethrottle.dev/api/v1/me/payment-methods/\\$ cardId \\ , method: DELETE , headers: X-Throttle-PM-Token : token , , ); const embedTokenSnippet = // Browser — get an add-only vault embed token so the buyer can add a new card const res = await fetch( https://api.usethrottle.dev/api/v1/me/payment-methods/embed-token , method: POST , headers: X-Throttle-PM-Token : token , ); const data: embedPayload = await res.json(); throttleEmbed.mount( #add-card-container , embedToken: embedPayload.embedToken, checkoutSessionId: embedPayload.checkoutSessionId, processorInstanceId: embedPayload.processorInstanceId, environment: embedPayload.environment, amount: embedPayload.amount, currency: embedPayload.currency, ); ; const embedTokenResponseSnippet = data : embedToken : eyJhbGci... , checkoutSessionId : cs_xyz , amount : 0, currency : USD , processorInstanceId : spider-store , merchantAccountId : acct_abc , environment : sandbox , hostedUrl : https://checkout.usethrottle.dev/... , embedUrl : https://embed.gr4vy.com/... ; const cannotRemoveDefaultSnippet = // 409 response when removing the default card while an active subscription exists error : code : cannot_remove_default , message : Add another card and make it the default before removing this one. , status : 409 ; const reactPackageBackendSnippet = // app/api/pm-token/route.ts (Next.js App Router) export async function POST(request: Request) const user = await auth(); if (!user) return new Response(null, status: 401 ); const customerId = await resolveThrottleCustomerId(user.id); // cus_* const token, expiresAt = await mintClientToken( apiKey: process.env.THROTTLE_SECRET_KEY!, customerId, ); return Response.json( token, expiresAt ); ; const reactPackageComponentSnippet = // components/Wallet.tsx use client ; const PaymentMethods = dynamic( () => import( @usethrottle/payment-methods ).then((m) => m.PaymentMethods), ssr: false , ); export function Wallet( clientToken : clientToken: string ) return ( console.log( cards updated , cards) /> ); ; export default function PaymentMethodsPage() return ( 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 hea"},{"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":"export const metadata: Metadata = title: Permissions , ; export default function PermissionsPage() return ( 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: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":"const apiClientSnippet = import OpenAPI, MerchantsService, ApiKeysService from @usethrottle/api-client ; OpenAPI.BASE = https://api.usethrottle.dev ; OpenAPI.HEADERS = X-API-Key : process.env.THROTTLE_API_KEY! ; const me = await MerchantsService.getApiV1MerchantsMe(); const keys = await ApiKeysService.getApiV1ApiKeys(); ; export const metadata: Metadata = title: Quickstart , ; const steps = [ title: Configure your application , body: In the dashboard, copy your Application ID from App Settings → General and add your storefront origin (e.g. http://localhost:3000) to Allowed origins. The embed refuses to render until both are set. , , title: Create an API key , body: On the API keys page, create a secret (sk_) key. The raw key is shown once — copy it and keep it on your backend only. , , title: Create a checkout session , body: Your backend uses @usethrottle/checkout-sdk/server to mint a session with amount, currency, and an optional cart reference. , , title: Render the React embed , body: Your storefront mounts PaymentEmbed from @usethrottle/checkout-react and handles the UI callbacks. , , title: Subscribe to webhooks , body: Your backend treats signed webhook events as the durable source of truth for fulfillment and order state. , , ]; export default function QuickstartPage() return ( 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, no wildcards. http://localhost and http://127.0.0.1 are accepted in every environment for local development. 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 it — e.g. sk_test_* or sk_live_* . 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","Hosted request forms","The buyer link","Accept a quote","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.","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).`;"],"text":"export const metadata: Metadata = title: Quotes , ; const createQuoteSnippet = curl -X POST https://api.usethrottle.dev/api/v1/quotes \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d customerEmail : buyer@example.com , buyerReference : PO-1042 , items : [ name : Widget (bulk) , unitPrice : 1500, quantity : 200 , name : Install service , unitPrice : 50000, quantity : 1, type : service , optional : true ], paymentTerms : mode : net_terms , netN : 30 , expiresAt : 2026-08-15T00:00:00Z ; const issueQuoteSnippet = # Freeze the working revision and propose it to the buyer. curl -X POST https://api.usethrottle.dev/api/v1/quotes/ quoteId /issue \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json -d # The response includes quote.shareToken — the buyer link is # https://checkout.usethrottle.dev/q/ shareToken (hosted page ships with the # public-link release). ; const requestFromCartSnippet = # Turn an existing cart into a quote request without re-keying anything. # The cart itself is unchanged. curl -X POST https://api.usethrottle.dev/api/v1/carts/ cartId /request-quote \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d buyerReference : PO-1042 , note : Need delivery by end of month ; const acceptBuyerSnippet = # The buyer accepts straight from the hosted link — no account, no API key. curl -X POST https://api.usethrottle.dev/api/v1/quote-links/ shareToken /accept \\\\ -H Content-Type: application/json \\\\ -d revisionId : the currently issued revision id , acceptedByName : Bea Buyer , acceptedByEmail : bea@example.com , itemSelections : [ itemId : optional line id , selected : false ] # Response: data : quote : ... , checkoutUrl : https://checkout.usethrottle.dev/s/... # Redirect the buyer to checkoutUrl to complete payment (card or Net-N, # depending on the quote s payment terms). ; const acceptRepSnippet = # A rep records a phone/email acceptance on the merchant s behalf. curl -X POST https://api.usethrottle.dev/api/v1/quotes/ quoteId /accept \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d revisionId : the currently issued revision id , acceptedByName : Bea Buyer , acceptedByEmail : bea@example.com , reason : Verbal agreement over the phone ; export default function QuotesPage() return ( 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. 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 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. 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/{"},{"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":"export const metadata: Metadata = title: React Package · @usethrottle/quotes , ; const installSnippet = pnpm add @usethrottle/quotes ; const quotePageSnippet = // app/q/[token]/page.tsx export default function QuotePage( params : params: token: string ) return ; ; const hookSnippet = import useQuote, formatMoney from @usethrottle/quotes ; function MyQuote( token : token: string ) const quote, total, selections, setQuantity, setSelected, accept, error = useQuote(token); if (!quote) return null; return ( <> quote.currentRevision?.items.map((item) => ( setQuantity(item.id, q) onToggle= (on) => setSelected(item.id, on) /> )) formatMoney(total, quote.currency) const checkoutUrl = await accept( acceptedByName: Ada Lovelace , acceptedByEmail: ada@example.com , ); window.location.href = checkoutUrl; > Accept & checkout ); ; const errorSnippet = import ThrottleQuotesError from @usethrottle/quotes ; try await accept( acceptedByName, acceptedByEmail ); catch (e) if (e instanceof ThrottleQuotesError) if (e.isExpired) return showExpiredNotice(); if (e.isSuperseded) return showRefreshedPricing(); console.error(e.code, e.statusCode, e.message); ; const rfqSnippet = // app/q/request/[formToken]/page.tsx export default function RequestPage( params : params: formToken: string ) return ; ; const proxySnippet = // app/api/throttle/[...path]/route.ts const handler = createQuoteProxyHandler(); export handler as GET, handler as POST ; ; const proxyUseSnippet = ; export default function QuotesReactPackagePage() return ( 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"],"text":"export const metadata: Metadata = title: Cart states , ; const chart = stateDiagram-v2 [*] --> open: POST /carts open --> open: mutations (items / shipping / discount / tax) open --> checkout: POST /carts/ id /checkout (creates draft order) checkout --> converted: order is paid (payment.captured) open --> abandoned: 24h idle (background sweep) checkout --> abandoned: 24h idle OR order cancelled before payment abandoned --> [*] converted --> [*] ; export default function CartStatesPage() return ( 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 (when configured): Throttle sends the customer.cart_abandoned email automatically when the cart has a known customer email and the application has a cartRecoveryUrlTemplate set. The template is a storefront URL containing a cartId placeholder, e.g. https://shop.example.com/cart?c= cartId . The email respects customer email opt-out / unsubscribe; in non-production environments it carries a [TEST] subject banner. 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":"export const metadata: Metadata = title: Error codes , ; const envelope = // Every Throttle 4xx / 5xx response follows this shape. error : code : cart_already_checked_out , // stable contract — use this message : Cart has already been converted to a draft order. , details : [ // optional, field-level field : cartId , code : conflict , message : Already checked out ] ; const rows: Array = [ [ cart_not_found , 404 , Cart id is invalid or belongs to another workspace. ], [ cart_not_open , 409 , A mutating cart operation targeted a cart no longer in status=open (converted by a completed order, or abandoned). In the cart SDK this surfaces as CartNotOpenError. , ], [ cart_already_checked_out , 409 , POST /carts/ id /checkout was called twice on the same cart. , ], [ cart_already_converted , 409 , Cart is in status=converted (linked order is paid). Read-only. , ], [ customer_not_found , 404 , Customer id is invalid or belongs to another workspace. ], [ application_not_found , 404 , Application UUID is invalid or belongs to another workspace. ], [ application_required , 400 , Endpoint requires X-Throttle-Application-Id header or applicationId in the body. , ], [ application_mismatch , 403 , API key is scoped to a different application than the request target. , ], [ application_key_workspace_route , 403 , API keys cannot access workspace-level routes (Clerk JWT required). , ], [ missing_application_id , 400 , POST /carts called without applicationId in the request body. , ], [ idempotency_key_reused , 409 , Same Idempotency-Key sent with a different request body within 24h. , ], [ invalid_origin , 400 , Origin in allowedOrigins isn t https:// or http://localhost: . , ], [ invalid_quote_token , 400 , pk_ token expired, scope-mismatched, or signed with a stale secret. , ], [ invalid_signature , 401 , Webhook signature failed verification (HMAC mismatch or stale timestamp). , ], [ embed_token_invalid , 401 , Provider embed JWT expired or was tampered with. Re-mint via the proxy endpoint. , ], [ embed_token_failed , 502 , The payment provider refused to mint an embed token (auth/permissions issue upstream). , ], [ permission_denied , 403 , Caller authenticated but lacks the required role / scope. ], [ workspace_access_denied , 403 , Caller is not a member of the workspace in :workspaceId. ], [ workspace_not_entitled_for_live , 402 , A production-environment credential was used without active subscription or partner status. , ], [ environment_mismatch , 403 , A credential or dashboard request targeted a resource from a different workspace environment. Use a key for the correct environment, or send the matching X-Throttle-Environment-Id. , ], [ environment_not_found , 404 , Requested workspace environment does not exist, is archived, or is not accessible to the workspace. , ], [ member_env_forbidden , 403 , Clerk-authenticated member tried to use an environment outside their environment grants. , ], [ environment_slug_taken , 409 , A workspace environment with that slug already exists. ], [ system_environment_locked , 400 , Production or another system environment cannot be archived. , ], [ validation_error , 400 , Request body failed zod validation. See details[] for field-level errors. , ], [ rate_limit_exceeded , 429 , Per-workspace rate limit hit. Retry-After header indicates the cooldown. , ], [ clerk_required , 403 , Endpoint requires a Clerk JWT (API key auth rejected). ], [ workspace_mismatch , 403 , Clerk JWT workspace does not match the workspace in the URL. ], [ no_file , 400 , Multipart upload route received a request without a file part. ], [ mime_not_allowed , 415 , Upload file type is outside the route’s ALLOWED_MIME set. ], [ logo_too_big , 413 , Logo upload exceeded the per-route byte cap. ], [ asset_too_big , 413 , Email asset upload exceeded the per-route byte cap. ], [ file_too_big , 413 , Digital-fulfillment upload exceeded the per-route byte cap. ], [ fulfillment_not_found , 404 , Digital-upload route’s :id is not a fulfillment in this application. , ], [ fulfillment_not_digital , 404 , Upload route targeted a non-digital fulfillment type. ], [ missing_token , 400 , Buyer download URL missing ?token= query param. ], [ invalid_token , 401 , Buyer download token failed JWT verification. ], [ token_mismatch , 401 , Buyer download token was minted for a different fulfillment. ], [ download_expired , 410 , Buyer download window has elapsed. ], [ download_limit_reached , 410 , Buyer download count has reached the configured limit for this delivery. , ], [ invalid_logo_url , 400 , logoUrl on /embed-config is not a valid https URL. ], [ invalid_primary_color , 400 , primaryColor on /embed-config is not a #rrggbb hex string. ], [ invalid_storefront_base_url , 400 , storefrontBaseUrl on /embed-config is not an absolute https URL. , ], [ image_url_unresolvable , 400 , A relative line-item imageUrl was sent but the application has no storefrontBaseUrl (or allowedOrigin) to resolve it against. , ], ]; export default function ErrorsPage() return ( 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 , 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 T"},{"title":"Fulfillment states","route":"/developers/reference/fulfillment-states","headings":["Fulfillment states","State machine","States","When the order is considered fulfilled"],"text":"export const metadata: Metadata = title: Fulfillment states , ; const chart = stateDiagram-v2 [*] --> pending: POST /fulfillments pending --> in_progress: shipment label created / digital prepared / access pending in_progress --> fulfilled: shipped+delivered / file delivered / access granted / service complete / in-person handoff pending --> cancelled: merchant cancels before in_progress in_progress --> cancelled: merchant cancels mid-fulfillment cancelled --> [*] fulfilled --> [*] ; export default function FulfillmentStatesPage() return ( 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.shipped for shipment type. 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. When the order is considered fulfilled The parent order transitions to fulfilled only when every attached fulfillment row reaches fulfilled . A mix of fulfilled + cancelled rows means the order moves to fulfilled as long as at least one row was fulfilled and no rows remain in non-terminal states. );"},{"title":"Order states","route":"/developers/reference/order-states","headings":["Order states","State machine","States","Outbound events"],"text":"export const metadata: Metadata = title: Order states , ; const chart = stateDiagram-v2 [*] --> draft: cart.checkout draft --> pending: checkout-session created pending --> paid: payment.captured pending --> cancelled: buyer abandons or merchant cancels paid --> fulfilled: all fulfillments delivered paid --> partially_refunded: payment.refunded (partial) paid --> refunded: payment.refunded (full) fulfilled --> [*] refunded --> [*] partially_refunded --> [*] cancelled --> [*] ; export default function OrderStatesPage() return ( State reference Order states An order s status drives whether you can capture payment, fulfill items, or refund. Each transition emits an outbound webhook. State machine States draft — created via POST /carts/ id /checkout (or directly via the orders API). No payment attached. pending — a checkout session has been minted against the order. Payment is in flight (PaymentEmbed mounted on the buyer s storefront). paid — payment was captured. Fulfillments can now be created. fulfilled — every fulfillment row attached to this order reached its terminal state (shipped/delivered for shipment, granted for access, delivered for digital, etc.). partially_refunded / refunded — a refund event was processed against the linked payment. Partial refunds remain mutable for further refunds up to the original captured amount. cancelled — terminal. Reached from pending before payment. Outbound events order.created on entering draft . order.updated on every status transition. order.cancelled on entering cancelled . order.fulfilled on entering fulfilled . order.refunded on entering refunded / partially_refunded . );"},{"title":"Payment states","route":"/developers/reference/payment-states","headings":["Payment states","State machine","States","Net30 special cases","Disputes & chargebacks"],"text":"export const metadata: Metadata = title: Payment states , ; const chart = stateDiagram-v2 [*] --> pending: POST /payments or buyer click pending --> authorized: Provider/Net30 authorizes pending --> failed: provider declines authorized --> captured: capture call authorized --> voided: void before capture captured --> refunded: full refund captured --> partially_refunded: partial refund partially_refunded --> refunded: subsequent refunds total to full refunded --> [*] voided --> [*] failed --> [*] ; export default function PaymentStatesPage() return ( 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. 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. voided — authorisation released before capture. Funds never moved. 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. 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","Scope catalog","{group}","Migration from coarse scopes","Machine-readable catalog"],"text":"export const metadata: Metadata = title: API key scopes , ; function flagBadges(def: ScopeDef) const badges: label: string; tone: string; title: string [] = []; if (def.sensitive) badges.push( label: ⚠ sensitive , tone: warning , title: Financial, destructive, or PII access — shown with a warning in the picker. , ); if (def.staffOnly) badges.push( label: staff only , tone: danger , title: Reachable only by Throttle Staff via Clerk; not grantable to workspace keys. , ); if (def.publishableAllowed) badges.push( label: pk_ ok , tone: ok , title: May be held by a publishable (pk_) browser-facing key. , ); return badges; export default function ScopesPage() const groupNames = Object.keys(SCOPE_GROUPS); return ( 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 stateless compute scopes only — shipping_quotes:write and tax_calculations:write — and can never read carts, orders, customers, payment methods, or PII. (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): the two stateless compute scopes shipping_quotes:write and tax_calculations:write . 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 . Scope catalog Every scope, grouped as it appears in the dashboard picker. A scope marked ⚠ sensitive triggers a picker warning (financial, destructive, or PII); pk_ ok means a publishable key may hold it; staff only means it is unreachable by any workspace key. Unless a row lists both, the resource exposes only the action shown. groupNames.map((group) => ( group Scope Label Flags SCOPE_GROUPS[group].map((id) => const def = SCOPE_REGISTRY[id]; const badges = flagBadges(def); return ( id def.label badges.length === 0 ? ( — ) : ( badges.map((b) => ( b.label )) ) ); ) )) Migration from coarse scopes Throttle previously used ~27 coarse scopes (for example a single payments:write ). Those were replaced with the granular catalog above. Existing keys keep working unchanged: Each legacy scope expands to the superset of granular scopes it used to cover — so a key with the old payments:write now holds payment_authorizations:write , payment_captures:write , payment_refunds:write , and the rest of the payment family. No power is lost. stores:read and stores:write are deprecated aliases for applications:read / applications:write , still accepted on input and expanded at evaluation time. New keys should request applications:read / applications:write directly. There is no endpoint to edit the scopes on an existing API key. To change a key's scopes, revoke it and create a new one with the scopes you want. (Extension manifest scopes, by contrast, are editable via PATCH /api/v1/extensions/ id .) Machine-readable catalog GET /api/v1/scopes returns the full catalog as JSON — every scope definition ( id , resource , action , group , label , sensitive , staffOnly , publishableAllowed , extensionAllowed , implies ) plus the group ordering. Build a scope picker or validate requested scopes against it rather than hard-coding the list. data : scopes : [ id : payment_refunds:write , resource : payment_refunds , action : write , group : Payments , label : Refund payments , sensitive : true, staffOnly : false, publishableAllowed : false, extensionAllowed : true, implies : [ payment_refunds:read ] ], groups : Payments : [ payments:read , payments:write , ...], ... );"},{"title":"Subscriptions","route":"/developers/subscriptions","headings":["Subscriptions","What ships in v1","What is intentionally out of v1","How the pieces fit","Sequence: auto-subscribe on card capture","Pages in this section"],"text":"export const metadata: Metadata = title: Subscriptions , ; const autoSubscribeChart = sequenceDiagram participant B as Buyer participant F as Throttle Iframe participant P as Payment Provider participant T as Throttle Backend participant H as Webhook Endpoint B->>F: fills card details F->>P: authorize + tokenize P-->>F: paymentMethodId F->>T: POST /complete (recurring + create:auto) T->>P: capture + vault payment method P-->>T: vaulted token T->>T: checkTrialEligibility(card fingerprint) alt eligible for trial T->>T: set trialEnd = now + trialDays else fingerprint already used T->>H: subscription.trial_blocked end T->>T: subscriptionService.create(...) T-->>F: orderId, paymentId, subscriptionId T->>H: subscription.created F->>B: throttle.completed ; export default function SubscriptionsOverviewPage() return ( 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. React management package. @usethrottle/subscriptions exposes hooks and primitive components so your buyer-facing portal does not have to re-derive the API plumbing. Built-in dunning. Hardcoded retry curve of [1, 3, 7] days; 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 . 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. Mid-period proration. PATCH /api/v1/subscriptions/:id swaps the plan/amount; the new value applies on the next renewal. No proration credit is issued for the current period. Recurring discounts. Promotion codes apply to cart-backed checkout totals today. Ongoing subscription-cycle discounts are deferred. Configurable dunning curve. The retry schedule is fixed at [1, 3, 7] days. 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 [1, 3, 7] 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 . 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 section Quickstart — five-minute end-to-end. Creating Subscriptions — auto, manual, and webhook patterns. Customer Identity — externalCustomerId pattern + alternates. Lifecycle and States — state machine, trials, dunning. Trial Fraud Protection — card-fingerprint check + eligibility-check API. Testing — sandbox smoke tests and webhook assertions. Managing Subscriptions — pause, resume, cancel, change plan. Buyer Portal — the proxy pattern with templates. React Package — @usethrottle/subscriptions reference. Subscription Webhooks — event types and payloads. Usage Reporting — metered usage via POST/GET .../usage ; billed at renewal. );"},{"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":"export const metadata: Metadata = title: Buyer Portal , ; const nextRouteHandlerSnippet = // app/api/throttle/[...path]/route.ts const handler = createSubscriptionProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getExternalCustomerId() const user = await auth(); return user?.id ?? null; , ); export handler as GET, handler as POST, handler as PATCH ; ; const expressSnippet = // MERCHANT BACKEND — Express variant. const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); app.get( /api/throttle/api/v1/subscriptions , requireAuth, async (req, res) => const result = await subscriptions.list( externalCustomerId: req.user.id, status: req.query.status, ); res.json(result); ); app.post( /api/throttle/api/v1/subscriptions/:id/cancel , requireAuth, async (req, res) => if (!(await assertUserOwnsSub(req.user.id, req.params.id))) return res.sendStatus(403); const sub = await subscriptions.cancel(req.params.id, atPeriodEnd: req.body?.atPeriodEnd ?? true, ); res.json(sub); ); const ownershipSnippet = // Verify the buyer owns the subscription before mutating. async function assertUserOwnsSub(userId: string, subId: string) const sub = await subscriptions.get(subId); if (!sub) return false; const customer = await subscriptions.getCustomerByExternalId(userId); return customer?.id === sub.customerId; ; const providerWiringSnippet = // Mount the React provider with a fetcher that hits your proxy. use client ; export function ThrottleProvider( children : children: React.ReactNode ) return ( return fetch(\\ /api/throttle\\$ path \\ , init); > children ); ; export default function BuyerPortalPage() return ( 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. 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)","Next"],"text":"export const metadata: Metadata = title: Creating Subscriptions , ; const autoSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const session = await subscriptions.createCheckoutSession( applicationId, externalCartId, returnUrl, cancelUrl, customer: externalCustomerId: user.id, email: user.email , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , ); router.push(\\ /account/\\$ subscriptionId \\ ) />; ; const manualEmbedSnippet = // Set create: manual to opt out of auto-create. const session = await subscriptions.createCheckoutSession( recurring: plan: pro_monthly , interval: monthly , amount: 2999, create: manual , ); await fetch( /api/subscribe , method: POST , body: JSON.stringify( orderId, paymentId, plan: pro_monthly ), ); />; ; const manualBackendSnippet = // MERCHANT BACKEND — call subscriptions.create yourself const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const userId, plan = await req.json(); const now = new Date(); const periodEnd = new Date(now); periodEnd.setMonth(periodEnd.getMonth() + 1); const sub = await subscriptions.create( externalCustomerId: userId, planReference: plan, interval: monthly , amount: 2999, currentPeriodStart: now.toISOString(), currentPeriodEnd: periodEnd.toISOString(), metadata: source: manual_post_checkout , ); await db.userSubscriptions.create( userId, subId: sub.id ); return Response.json( subscriptionId: sub.id ); ; const webhookSnippet = // MERCHANT BACKEND — webhook-driven creation. const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (event.type !== payment.vaulted ) return Response.json( ok: true ); const customerId, paymentMethodId, recurring = event.data; if (!recurring) return Response.json( ok: true ); // not a subscription intent const existing = await subscriptions.list( customerId, status: active ); if (existing.data.some((s) => s.metadata?.checkoutSessionId === event.data.sessionId)) return Response.json( ok: true ); await subscriptions.create( customerId, planReference: recurring.plan, interval: recurring.interval, amount: recurring.amount, currentPeriodStart: new Date().toISOString(), currentPeriodEnd: addInterval(new Date(), recurring.interval).toISOString(), metadata: checkoutSessionId: event.data.sessionId , ); return Response.json( ok: true ); ; export default function CreatingSubscriptionsPage() return ( 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 Direct 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. 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":"export const metadata: Metadata = title: Customer Identity , ; const patternASnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const session = await subscriptions.createCheckoutSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, externalCartId: \\ sub-\\$ user.id -\\$ Date.now() \\ , returnUrl: https://shop.example.com/account , cancelUrl: https://shop.example.com/pricing , customer: externalCustomerId: user.id, email: user.email , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , ); const subs = await subscriptions.list( externalCustomerId: user.id ); const customer = await subscriptions.getCustomerByExternalId(user.id); const patternBSnippet = // Persist Throttle s customerId in your user table. const customer = await subscriptions.getCustomerByExternalId(user.id); if (!customer) throw new Error( Throttle customer was not created yet ); await db.users.update( where: id: user.id , data: throttleCustomerId: customer.id , ); const subs = await subscriptions.list( customerId: user.throttleCustomerId ); ; const webhookSyncSnippet = // MERCHANT WEBHOOK — keep your DB in sync via customer.created const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (event.type === customer.created ) const id, externalId, email = event.data; if (externalId) await db.users.updateMany( where: id: externalId , data: throttleCustomerId: id , ); return Response.json( ok: true ); ; export default function CustomerIdentityPage() return ( 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","States","State diagram","Trial behavior","Renewal","Dunning","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","Next"],"text":"export const metadata: Metadata = title: Lifecycle and States , ; const stateMachineDiagram = stateDiagram-v2 [*] --> trialing: create with trialEnd [*] --> active: create without trial trialing --> active: trialEnd cron tick trialing --> cancelled: cancel() active --> paused: pause() paused --> active: resume() paused --> cancelled: cancel() active --> active: renewal succeeds / period advances active --> active: renewal fails 1-2 times active --> past_due: third renewal failure active --> cancelled: cancel() past_due --> active: payment recovers past_due --> cancelled: next failure / dunning exhausted past_due --> cancelled: cancel() cancelled --> [*] ; const renewalSnippet = // Renewal job shape. for (const sub of dueSubscriptions) if (sub.status === trialing && sub.trialEnd = sub.currentPeriodStart) continue; // idempotency guard const result = await paymentProvider.chargeStored( paymentMethodId: vault.processorToken, buyerId: vault.providerBuyerId, amount: sub.amount, idempotencyKey: \\ \\$ sub.id :\\$ sub.currentPeriodStart.toISOString() \\ , ); if (result.success) await subscriptionService.renew(sub, newPeriodStart, newPeriodEnd); else await subscriptionService.recordPaymentFailure(sub.id); ; export default function LifecycleAndStatesPage() return ( 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 supports four intervals out of the box: weekly , monthly , quarterly , and yearly . The interval is set on the subscription at create time (via the recurring block on the checkout session, or directly on POST /api/v1/subscriptions ) and drives the period math the renewal cron uses. Period advancement uses calendar arithmetic with day-of-month clamping: a Jan 31 subscription rolls to Feb 28 (or 29 in leap years) on a monthly interval, then back to Mar 31 the next cycle. No drift over multi-year subscriptions. 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 interval on an existing subscription (e.g., monthly → yearly) via PATCH /api/v1/subscriptions/:id . The new interval applies on the next renewal — no proration credit is issued for the unused portion of the current period. 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 — Three or more renewal attempts have failed. Still being retried, but no further attempts will succeed without a new card. cancelled — Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop. State diagram trialing , trialEnd cron tick , active , Activates the subscription and emits subscription.activated without charging. , ], [ active , renewal succeeds , active , Advances the current period and emits subscription.renewed. , ], [ active , first or second renewal failure , active , Records the failure, schedules the next retry, and emits subscription.payment_failed. , ], [ active , third renewal failure , past_due , Marks the subscription past_due and emits subscription.past_due. , ], [ past_due , next renewal failure , cancelled , Cancels with reason dunning_exhausted and emits subscription.cancelled. , ], [ Any non-terminal state , cancel() , cancelled , Stops future renewals and emits subscription.cancelled. , ], ] /> 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 against prior subscriptions; on a match the new subscription is created without a trial and a subscription.trial_blocked webhook fires. There's also a pre-flight POST /api/v1/subscriptions/eligibility-check endpoint for explicit gating. See Trial Fraud Protection for the full guide, including what this does NOT cover (different cards, disposable emails, IP velocity). Renewal A cron runs every 5 minutes selecting subscriptions whose currentPeriodEnd has passed. For each one it calls the provider's chargeStored with an idempotency key derived from the subscription ID and the period start, then advances the period on success. Two guards prevent double-billing: a DB check ( lastPaymentAt >= currentPeriodStart ) and the provider-level idempotency key. Either alone would not be enough. Dunning When a renewal charge fails, the cron records the failure and schedules the next attempt. The schedule is fixed: past_due , Send the final-warning notice and surface update-card UI. , ], [ 4th+ , No further schedule , cancelled , De-provision or downgrade according to your offboarding policy. , ], ] /> On every failed attempt, Throttle emits subscription.payment_failed . The first time a subscription transitions to past_due , it additionally emits subscription.past_due . When dunning is exhausted, it transitions to cancelled with reason: dunning_exhausted . Throttle does not send emails to buyers. Subscribe to subscription.payment_failed and use y"},{"title":"Managing Subscriptions","route":"/developers/subscriptions/managing-subscriptions","headings":["Managing Subscriptions","List and filter","Cancel","Immediate cancellation","Cancel at period end","Pause and resume","Change plan","Immediate upgrade","Scheduled downgrade","Cancelling a pending change","Seats and quantity","From the React package","Audit log","Next"],"text":"export const metadata: Metadata = title: Managing Subscriptions , ; const cancelImmediateSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); await subscriptions.cancel( sub_xyz , atPeriodEnd: false ); const cancelAtPeriodEndSnippet = // Cancel at the end of the current period. await subscriptions.cancel( sub_xyz , atPeriodEnd: true ); const pauseResumeSnippet = // Pause an active subscription. Renewal cron skips it. await subscriptions.pause( sub_xyz ); await subscriptions.resume( sub_xyz ); ; const upgradeNowSnippet = // Immediate upgrade: prorated charge now, resets the billing period. const result = await fetch( https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/change-plan , method: POST , headers: x-api-key : process.env.THROTTLE_SECRET_KEY!, content-type : application/json , body: JSON.stringify( planReference: pro_yearly , planName: Pro Yearly , interval: yearly , amount: 29900, effective: now , // sub.status === active && ( pause.mutate( id: sub.id ) >Pause ) sub.status === paused && ( resume.mutate( id: sub.id ) >Resume ) cancel.mutate( id: sub.id, atPeriodEnd: true ) > Cancel at period end change.mutate( id: sub.id, planReference: pro_yearly , interval: yearly , amount: 29900 ) > Switch to yearly ); ; export default function ManagingSubscriptionsPage() return ( 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. You may want to combine this with a prorated refund (handled outside Throttle s subscription engine). 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. 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 ; the period continues from where it was paused. Guards: pause() requires status active . Throws otherwise. resume() requires status paused . Throws otherwise. Change plan Use POST /api/v1/subscriptions/:id/change-plan for all mid-cycle plan changes. The behavior depends on the effective field. 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. No charge is made now. The four pending* fields ( pendingPlanReference , pendingPlanName , pendingInterval , pendingAmount ) are written, and the renewal cron applies the change on the next period end. Cancelling a pending change If the buyer changes their mind about a scheduled downgrade, use DELETE /api/v1/subscriptions/:id/pending-change to clear the pending fields and keep the current plan. Calling this when no change is pending is a safe no-op. If the subscription already has cancelAtPeriodEnd: true , the cancellation takes precedence and the pending plan change will never apply. Clear the cancel flag first ( PATCH with cancelAtPeriodEnd: false ), then schedule the plan change. Seats and quantity A subscription carries a quantity (default 1). The amount is the per-seat price, so the amount billed each period is amount × quantity (the renewal invoice line item carries the seat count). Set quantity when you create the subscription, and change it mid-cycle with POST /api/v1/subscriptions/:id/change-quantity . Like a plan change, the seat change is either effective: "now" or effective: "period_end" : Immediate — prorated exactly like an immediate plan change: it credits the unused time on the old total ( amount × oldQuantity ) and charges the net of the new total ( amount × newQuantity ), then resets the billing period. Removing seats where the credit exceeds the new total issues no charge. The response carries the same proration object. Period end — writes pendingQuantity ; the renewal cron applies it at the next period boundary and bills the new seat count. No charge now. From the React package The hooks invalidate the right cache entries on success — your useSubscription(id) and useSubscriptions() data update without a manual refetch. Audit log Every state change is recorded in the audit log with the actor (API key, user, or system) and the change set. View it in the dashboard under Customers → Subscriptions → Activity . Next Buyer Portal — let buyers do this themselves. Subscript"},{"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":"export const metadata: Metadata = title: Subscriptions Quickstart , ; const steps = [ title: Create a checkout session with a recurring block , body: Your backend POSTs to /api/v1/checkout/sessions. The recurring block tells Throttle to vault the card and seed a subscription on payment success. Pass externalCustomerId so you can reconcile the subscription to your customer record. , , title: Render the embed with the recurring prop , body: handles the buyer-facing card form. Card collection, 3DS, and vault all happen inside the iframe — your storefront does not see card data. , , title: Read the success payload , body: onSucceeded fires with subscriptionId, customerId, paymentMethodId . Persist customerId to your user table if you want lookups by Throttle ID, or skip it and continue using your own ID via externalCustomerId. , , title: Subscribe to the webhook stream , body: Listen for subscription.created, subscription.renewed, subscription.past_due, and subscription.payment_failed. Your DB stays in sync without polling. , , ]; const sessionSnippet = // MERCHANT BACKEND (e.g. /api/checkout/start) const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const user, planId = await req.json(); const session = await subscriptions.createCheckoutSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, cartId: undefined, // skip cart for plan-based subs externalCartId: \\ sub-\\$ user.id -\\$ Date.now() \\ , customer: externalCustomerId: user.id, // your own user ID email: user.email, firstName: user.firstName, lastName: user.lastName, , returnUrl: https://yourstore.com/account/subscriptions , cancelUrl: https://yourstore.com/pricing , recurring: plan: planId, // your own plan reference interval: monthly , amount: 2999, // cents; defaults to cart total if omitted trialDays: 14, // optional create: auto , // default — Throttle creates the sub , ); return Response.json( sessionId: session.sessionId ); ; const embedSnippet = // MERCHANT FRONTEND use client ; export function SubscribeForm( sessionId : sessionId: string ) const router = useRouter(); return ( router.push(\\ /account/subscriptions/\\$ result.subscriptionId \\ ); onFailed= (err) => console.error( Subscribe failed: , err) /> ); ; const webhookSnippet = // MERCHANT BACKEND (/api/webhooks/throttle) export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); switch (event.type) case subscription.created : break; case subscription.payment_failed : break; case subscription.cancelled : break; case subscription.renewed : break; return Response.json( ok: true ); ; export default function SubscriptionsQuickstartPage() return ( 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. 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()","Primitive components","Boundaries"],"text":"export const metadata: Metadata = title: React Package , ; const installSnippet = npm install @usethrottle/subscriptions @usethrottle/checkout-react ; const nextProxySnippet = // app/api/throttle/[...path]/route.ts const handler = createSubscriptionProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getExternalCustomerId() const user = await auth(); return user?.id ?? null; , ); export handler as GET, handler as POST, handler as PATCH ; ; const providerSnippet = use client ; export function Providers( children : children: React.ReactNode ) return ( fetch(\\ /api/throttle\\$ path \\ , init) > children ); ; const serverClientSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); await subscriptions.cancel( sub_123 , atPeriodEnd: true ); ; const serverPaymentMethodsSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const paymentMethods = await subscriptions.listCustomerPaymentMethods( cus_123 ); ; const useSubscriptionsSnippet = import useSubscriptions from @usethrottle/subscriptions ; function MySubs() const data, isLoading, error = useSubscriptions( externalCustomerId: user_42 , // or customerId: cus_xyz status: active , // optional ); if (isLoading) return ; if (error) return ; return data.data.map((sub) => ); ; const useSubscriptionSnippet = import useSubscription from @usethrottle/subscriptions ; function SubDetail( id : id: string ) const data: sub, isLoading = useSubscription(id); if (isLoading || !sub) return ; return ; ; const usePaymentMethodsSnippet = import useCustomerPaymentMethods from @usethrottle/subscriptions ; function SavedCards( customerId : customerId: string ) const data: methods, isLoading = useCustomerPaymentMethods(customerId); if (isLoading) return ; return methods?.map((method) => ( method.cardBrand ending in method.cardLastFour )); ; const useCancelSnippet = import useCancelSubscription from @usethrottle/subscriptions ; function CancelButton( subId : subId: string ) const cancel = useCancelSubscription(); return ( cancel.mutate( id: subId, atPeriodEnd: true ) > cancel.isPending ? Cancelling… : Cancel at period end ); ; const usePauseResumeSnippet = import usePauseSubscription, useResumeSubscription from @usethrottle/subscriptions ; function PauseToggle( sub ) const pause = usePauseSubscription(); const resume = useResumeSubscription(); return sub.status === paused ? resume.mutate( id: sub.id ) >Resume : pause.mutate( id: sub.id ) >Pause ; ; const useChangePlanSnippet = import useChangePlan from @usethrottle/subscriptions ; function PlanSwitcher( subId : subId: string ) const change = useChangePlan(); return ( change.mutate( id: subId, planReference: pro_yearly , interval: yearly , amount: 29900, ) > Switch to yearly ); ; const useCreateCheckoutSessionSnippet = import useCreateCheckoutSession from @usethrottle/subscriptions ; function SubscribeFlow( user, plan ) const create = useCreateCheckoutSession(); if (!create.data) return ( create.mutate( applicationId: 00000000-0000-0000-0000-000000000000 , externalCartId: \\ sub-\\$ user.id -\\$ plan \\ , customer: externalCustomerId: user.id, email: user.email , returnUrl: https://shop.example.com/account , cancelUrl: https://shop.example.com/pricing , recurring: plan, interval: monthly , amount: 2999 , ) > Subscribe ); return ( router.push(\\ /account/\\$ subscriptionId \\ ) /> ); ; const trialEligibilitySnippet = import useTrialEligibility from @usethrottle/subscriptions ; function TrialGate( paymentMethodId : paymentMethodId: string ) const check = useTrialEligibility(); return ( check.mutate( paymentMethodId ) > Check trial eligibility ); ; const componentsSnippet = import SubscriptionStatusBadge, TrialCountdown, DunningBanner, from @usethrottle/subscriptions ; function SubCard( subscription: sub ) return ( sub.planName sub.status === trialing && sub.trialEnd && ( ) sub.status === past_due && ( openUpdateCardFlow(sub) /> ) Next charge: sub.currentPeriodEnd ); ; export default function ReactPackagePage() return ( 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 , 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 authentic"},{"title":"Testing","route":"/developers/subscriptions/testing","headings":["Testing","Sandbox checklist","Manual smoke test","Trial-abuse replay","Webhook assertions","Automated test boundaries","Common gotchas","Next"],"text":"export const metadata: Metadata = title: Testing Subscriptions , ; const sandboxConfig = THROTTLE_API_URL=https://api.usethrottle.dev THROTTLE_CHECKOUT_ORIGIN=https://checkout.usethrottle.dev THROTTLE_SECRET_KEY=sk_uat_... THROTTLE_APPLICATION_ID=e7efb0a6-892e-46b2-97ab-296bb04c5b29 THROTTLE_WEBHOOK_SECRET=whsec_... ; const subscriptionIntentSnippet = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H x-api-key: sk_uat_... \\\\ -H content-type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : sub-test-001 , returnUrl : https://staging.shop.example.com/account , cancelUrl : https://staging.shop.example.com/pricing , customer : externalCustomerId : user_test_001 , email : buyer@example.com , recurring : plan : pro_monthly , planName : Pro Monthly , interval : monthly , amount : 2999, trialDays : 14 , metadata : testRunId : sub-flow-2026-05-05 ; const webhookHandlerSnippet = import verifyThrottleWebhook from @/lib/throttle/webhooks ; export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (await db.webhookEvents.exists(event.id)) return Response.json( ok: true ); await db.webhookEvents.insert( id: event.id, type: event.type, receivedAt: new Date(), ); if (event.type === subscription.created ) await provisionAccess(event.data.subscription); if (event.type === subscription.cancelled ) await revokeAccess(event.data.subscription); return Response.json( ok: true ); ; export default function TestingSubscriptionsPage() return ( 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. Receive 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 , subscription.activated , ], [ Renewal succeeds , subscription.renewed , ], [ Renewal fails , <> subscription.payment_failed , then subscription.past_due when the threshold is crossed. , ], [ Cancellation , <> 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. 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":"export const metadata: Metadata = title: Trial Fraud Protection , ; const eligibilityCheckSnippet = // MERCHANT BACKEND const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const result = await subscriptions.checkTrialEligibility( paymentMethodId: pmRowId, ); if (!result.eligible) return showNoTrialOption( reason: result.reason ); return showTrialOption(); ; const responseShape = data : eligible : true | false, reason : card_already_used_for_trial | payment_method_not_found | no_fingerprint_available | undefined // when eligible:true with no caveat ; const trialBlockedWebhookSnippet = // MERCHANT WEBHOOK case subscription.trial_blocked : await notifyTrialDeclined(event.data); break; ; export default function TrialFraudProtectionPage() return ( 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":"export const metadata: Metadata = title: Usage Reporting , ; const reportUsageSnippet = // Report a metered usage item for the current billing period. const res = await fetch( \\ https://api.usethrottle.dev/api/v1/subscriptions/\\$ subscriptionId /usage\\ , method: POST , headers: X-API-Key : process.env.THROTTLE_SECRET_KEY, Content-Type : application/json , , body: JSON.stringify( kind: api_calls , unitCount: 1500, unitPriceCents: 2, // $0.02 per call ), , ); const data = await res.json(); const listUsageSnippet = // Retrieve all usage items for the current billing period. const res = await fetch( \\ https://api.usethrottle.dev/api/v1/subscriptions/\\$ subscriptionId /usage\\ , headers: X-API-Key : process.env.THROTTLE_SECRET_KEY, , , ); const items = await res.json(); const multiKindSnippet = // Report multiple usage kinds in the same period. await reportUsage(subscriptionId, kind: api_calls , unitCount: 1500, unitPriceCents: 2 ); await reportUsage(subscriptionId, kind: storage_gb , unitCount: 12, unitPriceCents: 50 ); await reportUsage(subscriptionId, kind: api_calls , unitCount: 1800, unitPriceCents: 2 ); export default function UsageReportingPage() return ( 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. 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)","Reference handler","Delivery semantics","Common pitfalls"],"text":"export const metadata: Metadata = title: Subscription Webhooks , ; const handlerSnippet = // app/api/webhooks/throttle/route.ts export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); switch (event.type) case subscription.created : await provisionAccess(event.data.subscription); break; case subscription.activated : break; case subscription.renewed : break; case subscription.payment_failed : await sendDunningEmail(event.data.subscription, event.data.attempt); break; case subscription.past_due : break; case subscription.paused : case subscription.resumed : case subscription.updated : await invalidateLocalCache(event.data.subscription.id); break; case subscription.plan_change_scheduled : await notifyBuyerOfScheduledChange(event.data.pending, event.data.effectiveAt); break; case subscription.plan_changed : await updateBuyerPlanAccess(event.data.subscription, event.data.previous); break; case subscription.cancelled : await deprovisionAccess(event.data.subscription, event.data.reason); break; return Response.json( ok: true ); ; const payloadSnippet = id : evt_01HF8... , type : subscription.renewed , workspaceId : merch_xyz , createdAt : 2026-05-03T12:00:00Z , data : subscription : id : sub_abc , customerId : cus_xyz , status : active , planReference : pro_monthly , planName : Pro Monthly , interval : monthly , amount : 2999, currency : USD , currentPeriodStart : 2026-05-03T12:00:00Z , currentPeriodEnd : 2026-06-03T12:00:00Z , trialEnd : null, failureCount : 0, cancelAtPeriodEnd : false, pendingPlanReference : null, pendingPlanName : null, pendingInterval : null, pendingAmount : null, metadata : , customer : id : cus_xyz , email : buyer@example.com , firstName : Ada , lastName : Lovelace , phone : null, externalId : your-user-42 , externalCustomerId : null ; const planChangeScheduledPayloadSnippet = // subscription.plan_change_scheduled — payload when a downgrade is deferred id : evt_02AB9... , type : subscription.plan_change_scheduled , workspaceId : merch_xyz , createdAt : 2026-06-01T10:00:00Z , data : subscription : id : sub_abc , status : active , planReference : pro_monthly , interval : monthly , amount : 2999, currentPeriodEnd : 2026-06-03T12:00:00Z , pending : planReference : starter_monthly , planName : Starter (Monthly) , interval : monthly , amount : 999 , effectiveAt : 2026-06-03T12:00:00Z ; const planChangedPayloadSnippet = // subscription.plan_changed — payload after upgrade or deferred downgrade applies id : evt_03CD0... , type : subscription.plan_changed , workspaceId : merch_xyz , createdAt : 2026-06-03T12:00:00Z , data : subscription : id : sub_abc , status : active , planReference : starter_monthly , planName : Starter (Monthly) , interval : monthly , amount : 999, currentPeriodStart : 2026-06-03T12:00:00Z , currentPeriodEnd : 2026-07-03T12:00:00Z , previous : planReference : pro_monthly , amount : 2999 ; export default function SubscriptionWebhooksPage() return ( 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 subscription.created , A new subscription row is inserted (auto from checkout, or via API). , Provision access. Send welcome email. , ], [ subscription.activated , <> A trialing subscription flips to active at trialEnd . , Optional your trial ended, you are now subscribed email. , ], [ subscription.renewed , A renewal charge succeeded and the period advanced. , Ledger entry. Receipt email (optional). , ], [ subscription.payment_failed , <> Every failed renewal attempt. Includes attempt , nextRetryAt , and lastError in the payload. , Payment-failed email. First failure is the most important. , ], [ subscription.past_due , <> The first time a subscription transitions to past_due (after 3 failures). , Final-warning email with update-card link. , ], [ subscription.updated , <> Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change . , Confirmation email if buyer-initiated. , ], [ subscription.plan_change_scheduled , <> 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. , <> 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. , ], [ subscription.plan_changed , <> 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. , ], [ subscription.paused , <> Subscription transitioned to paused . , Suspend feature access (or leave on, depending on your model). , ], [ subscription.resumed , <> Subscription transitioned back to active . , Re-enable access. , ], [ subscription.cancelled , <> Terminal cancellation. data.reason is one of merchant_action , dunning_exhausted , or period_end . , De-provision. Send s"},{"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":"export const metadata: Metadata = title: Team Management , ; export default function TeamManagementPage() return ( 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 rol"},{"title":"Workspace Environments","route":"/developers/test-mode","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":"export const metadata: Metadata = title: Workspace Environments , ; const keyExample = # API keys include the workspace environment slug. # Production is immutable; non-production environments can be named for your flow. THROTTLE_API_KEY=sk_uat_3e2e04df... # non-production secret key THROTTLE_PUBLISHABLE_KEY=pk_uat_290b29b1... # non-production publishable key # Production: THROTTLE_API_KEY=sk_production_fbc6cab6... THROTTLE_PUBLISHABLE_KEY=pk_production_06646787... ; const environmentHeaderExample = // Dashboard and Clerk-authenticated setup calls select an environment explicitly. await fetch( https://api.usethrottle.dev/api/v1/api-keys , method: POST , headers: Authorization: Bearer , X-Throttle-Application-Id : , X-Throttle-Environment-Id : , content-type : application/json , , body: JSON.stringify( name: UAT backend , scopes: [ orders:read , payments:write ], ), ); ; const environmentManagementExample = # List active environments curl https://api.usethrottle.dev/api/v1/workspaces/ /environments \\\\ -H Authorization: Bearer # Include archived environments for settings/audit screens curl https://api.usethrottle.dev/api/v1/workspaces/ /environments?includeArchived=true \\\\ -H Authorization: Bearer # Create a non-production sandbox-provider environment curl -X POST https://api.usethrottle.dev/api/v1/workspaces/ /environments \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d slug : uat , name : UAT # Archive a custom non-production environment curl -X DELETE https://api.usethrottle.dev/api/v1/workspaces/ /environments/8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 \\\\ -H Authorization: Bearer ; const providerEnvExample = // Provider routing comes from the selected workspace environment: export default function TestModePage() return ( 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":"Use Webhooks as Your Source of Truth","route":"/developers/webhooks","headings":["Use Webhooks as Your Source of Truth","Create an endpoint","Verify signatures","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":"export const metadata: Metadata = title: Webhooks , ; const cartAbandonedSnippet = id : evt_2b8f... , type : cart.abandoned , workspaceId : 9c1e... , createdAt : 2026-07-04T18:22:05.000Z , data : cartId : cart_9f2a... , sequence : 7, currency : USD , total : 8900, itemCount : 2, customer : id : cus_51... , email : buyer@example.com , firstName : Bea , lineItems : [ id : li_1 , name : Premium Widget , quantity : 2, unitPrice : 2999, total : 5998 ], recoveryUrl : https://checkout.usethrottle.dev/c/cart_9f2a... ; const eventsByScope = (() => const groups = new Map (); for (const type of ALL_EVENT_TYPES) const scope = eventReadScope(type); const list = groups.get(scope) ?? []; list.push(type); groups.set(scope, list); return [...groups.entries()] .map(([scope, types]) => ( scope, types: [...types].sort() )) .sort((a, b) => a.scope.localeCompare(b.scope)); )(); const retryChart = sequenceDiagram participant T as Throttle participant Q as Delivery Queue participant E as Your Endpoint participant Op as Operator T->>Q: emit event (e.g. payment.captured) Q->>E: attempt 1 E-->>Q: 5xx / timeout Note over Q: wait 5m Q->>E: attempt 2 E-->>Q: 5xx / timeout Note over Q: wait 15m Q->>E: attempt 3 E-->>Q: 5xx / timeout Note over Q: wait 1h Q->>E: attempt 4 E-->>Q: 5xx / timeout Note over Q: wait 6h Q->>E: attempt 5 E-->>Q: 5xx / timeout Note over Q: wait 24h, max attempts reached Q->>Q: mark delivery dead_letter Op->>T: POST /api/v1/webhook-deliveries/ deliveryId /replay T->>Q: requeue Q->>E: manual retry attempt E-->>Q: 2xx Q->>Q: mark delivered ; const envelopeSnippet = id : evt_01HZX... , type : payment.captured , version : 1 , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , environmentId : b7e5a40e-8c0f-4d85-a726-2ff2967f4b52 , createdAt : 2026-05-06T10:00:00.000Z , data : paymentId : pay_01HZX... , orderId : ord_01HZX... , amount : 12900, capturedAmount : 12900, authorizedAmount : 12900, currency : USD , customerId : cus_01HZX... , subscriptionId : sub_01HZX... , customer : id : cus_01HZX... , email : buyer@example.com , firstName : Ada , lastName : Lovelace , phone : null, externalId : your-user-42 , externalCustomerId : null ; const coverageSnippet = data : windowDays : 30, unsubscribed : [ type : subscription.paused , count : 12, lastEmittedAt : 2026-07-27T09:24:11.000Z ] ; const retiredEventsSnippet = data : id : wep_01HZX... , enabledEvents : [ payment.captured , subscription.updated ], retiredEventsIgnored : [ cart.expired ] ; export default function WebhooksPage() return ( 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. 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. 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.completed in addition to payment events when you need to mirror delivery state. 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 run your own follow-up. cart.converted — fires when a cart becomes an "},{"title":"Internal Support Module","route":"/internal/support","headings":["Internal Support Module","Routes","Auth","Workspace identity model","Webhook payload contract","See also"],"text":"export const metadata: Metadata = title: Internal Support Module , description: Internal-only documentation for the dispatchtickets.com-backed support module that powers the Throttle dashboard. , robots: index: false, follow: false , ; export default function InternalSupportPage() return ( 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-08-01T21:35:56.437Z","entries":[{"title":"Docs","route":"/","headings":[],"text":"export default function HomePage() redirect( /developers );"},{"title":"Build a checkout that settles cleanly.","route":"/developers","headings":["Build a checkout that settles cleanly.","Choose Your Path","{title}","Canonical URLs"],"text":"export const metadata: Metadata = title: Developers , ; const paths = [ href: /developers/quickstart , title: Accept a payment , body: Create a session, mount the payment embed, and handle success. , Icon: CreditCard, , href: /developers/embedded-checkout , title: Embed checkout , body: Use PaymentEmbed, CheckoutEmbed, a raw iframe, or a hosted page. , Icon: Code2, , href: /developers/cart-api , title: Manage native carts , body: Create carts, add items, calculate shipping/tax, and checkout. , Icon: Box, , href: /developers/webhooks , title: Listen to webhooks , body: Verify HMAC signatures and process events idempotently. , Icon: Webhook, , href: /developers/packages , title: Choose a package , body: Pick the right SDK or CLI for your integration surface. , Icon: Route, , href: /developers/api-reference , title: Browse endpoints , body: Use the curated reference, then jump to full OpenAPI when needed. , Icon: ShieldCheck, , ]; export default function DevelopersPage() return ( 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. 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":"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":"export const metadata: Metadata = title: Product Feeds , ; const feedRouteSnippet = // app/feeds/google-merchant.xml/route.ts import getProducts from @/lib/cms ; // your catalog source export const revalidate = 3600; // regenerate hourly function esc(value: string): string return value .replace(/&/g, & ) .replace(/ /g, > ); export async function GET() const products = await getProducts(); // CMS query const items = products .map( (p) => \\ \\$ esc(p.sku) \\$ esc(p.name) \\$ esc(p.description) https://shop.example.com/products/\\$ esc(p.slug) \\$ esc(p.imageUrl) \\$ p.inStock ? in_stock : out_of_stock \\$ (p.priceMinor / 100).toFixed(2) \\$ esc(p.currency) \\$ esc(p.brand) new \\ , ) .join( ); const xml = \\ Example Shop https://shop.example.com Product feed \\$ items \\ ; return new Response(xml, headers: Content-Type : application/xml; charset=utf-8 , ); ; const lineItemSnippet = // Adding a cart line item — referenceId MUST equal the feed s await carts.items.add(cart.id, type: product , name: Premium Widget , referenceId: WIDGET-PREM-001 , // WIDGET-PREM-001 in the feed unitPrice: 2599, quantity: 1, ); ; export default function ProductFeedsPage() return ( 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":"export const metadata: Metadata = title: Track Conversions , ; const embedPurchaseSnippet = import CheckoutEmbed from @usethrottle/checkout-react ; const value = (total ?? 0) / 100; // minor units -> major const items = lineItems ?? []; window.gtag?.( event , purchase , transaction_id: orderId, value, currency: currency ?? USD , items: items.map((item) => ( item_id: item.sku ?? item.id ?? item.name, item_name: item.name, quantity: item.quantity, price: item.unitPrice / 100, )), ); window.fbq?.( track , Purchase , value, currency: currency ?? USD , content_type: product , content_ids: items .map((item) => item.sku ?? item.id) .filter((id): id is string => Boolean(id)), , eventID: orderId , ); router.push(\\ /thank-you?order=\\$ orderId \\ ); />; ; const trackingSettingsSnippet = curl -X PUT https://api.usethrottle.dev/api/v1/tracking-settings \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d ga4MeasurementId : G-XXXXXXXXXX , metaPixelId : 1234567890123456 , requireConsent : true ; const serverSideSettingsSnippet = curl -X PUT https://api.usethrottle.dev/api/v1/tracking-settings \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d serverSideEnabled : true, ga4ApiSecret : your-ga4-mp-api-secret , metaCapiAccessToken : your-meta-capi-access-token ; const clientContextSnippet = // Your server, at session create time const session = await checkout.createSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, cartId: cart.id, returnUrl: https://shop.example.com/checkout/success , cancelUrl: https://shop.example.com/cart , clientContext: landingPage: https://shop.example.com/spring-sale?utm_source=google , referrer: https://www.google.com/ , utmSource: google , utmMedium: cpc , utmCampaign: spring-sale , gclid: Cj0KCQjw... , gaClientId: 123456789.1700000000 , gaSessionId: 1700000000 , fbp: fb.1.1700000000000.1234567890 , fbc: fb.1.1700000000000.IwAR2... , consent: analytics: true, marketing: true ); ; const hostedSteps = [ title: Configure tracking settings , body: PUT /api/v1/tracking-settings with your ga4MeasurementId and/or metaPixelId. Settings are per application and per environment, so configure your production environment separately from sandboxes. , , title: Pass the consent signal , body: Append consent=granted (or consent=denied) to the checkout URL when you hand the buyer off. With requireConsent true (the default), no consent signal means no pixels are injected. If you run your own consent platform upstream and only send consenting buyers, you can set requireConsent to false instead. , , title: Add the checkout domain to GA4 cross-domain linking , body: In GA4 Admin, add checkout.usethrottle.dev to your cross-domain linking domain list. Throttle configures gtag.js with linker.accept_incoming, so the _gl parameter your storefront appends stitches the buyer into the same GA4 session instead of starting a new one on the checkout domain. , , ]; export default function TrackConversionsPage() return ( 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. , <> 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 C"},{"title":"API Surface by Integration Task","route":"/developers/api-reference","headings":["API Surface by Integration Task","{group.title}"],"text":"export const metadata: Metadata = title: API Reference , ; export default function ApiReferencePage() return ( 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":"export const metadata: Metadata = title: Cart API , ; const cartSteps = [ title: Configure your application , body: Copy your Application UUID + add at least one allowedOrigins entry from the dashboard. POST /carts and the embed both refuse to render until these are set. , , title: Create the cart , body: Start with applicationId. Currency is optional — it inherits your application currency, and a mismatched value is rejected (currency_mismatch). Keep cart operations on your backend. , , title: Add commerce state , body: Add items, apply discounts, select shipping, and calculate tax before checkout. , , title: Lock totals for checkout , body: Run checkout_final calculation when the buyer commits to shipping and payment. , , title: Convert to a draft order , body: POST /carts/ id /finalize converts the cart to a draft order. (Legacy alias: /checkout — same handler.) To collect payment, mint a checkout session afterwards (see Embedded Checkout). , , ]; const cartFlowChart = sequenceDiagram participant B as Browser participant M as Merchant Backend participant T as Throttle API participant C as Checkout Iframe B->>M: add item / apply code / choose shipping M->>T: POST /api/v1/carts M->>T: POST /api/v1/carts/ id /items M->>T: POST /api/v1/carts/ id /apply-discount M->>T: POST /api/v1/shipping-tax/carts/ id /calculate T-->>M: totals + shipping/tax snapshot M->>T: POST /api/v1/checkout/sessions M-->>B: sessionId + embed URL B->>C: render checkout C->>T: POST /api/v1/checkout-sessions/ id /complete T-->>B: throttle.completed ; const addressSnippet = // One canonical address shape (CartAddress). Required: addressLine1, city, await client.carts.update(cartId, shippingAddress: firstName: Ada , addressLine1: 1 Market St , addressLine2: Suite 400 , city: San Francisco , stateProvince: CA , postalCode: 94105 , countryCode: US , , ); const shippingSelectSnippet = // Selecting a shipping method is ONE call. The response is the full cart with const cart = await client.shipping.select(cartId, methodId: standard , displayName: Standard (3-5 days) , rateAmount: 799, ); cart.selectedShipping; // methodId, displayName, rateAmount, currency, ... cart.shippingTotal; // 799 cart.taxTotal; // recomputed cart.total; // recomputed const fresh = await client.carts.get(cartId); fresh.selectedShipping; // bind your UI directly to this — it never drifts. ; export default function CartApiPage() return ( 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_production_* . 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 pk_ and are scoped by application and allowed origin. They can estimate totals, but they cannot mutate carts or create orders. carts.create and carts.update accept an optional customerEmail — a way to attach a buyer’s email to a cart without minting a full customer record (e.g. an “email me my cart” field). When set, an otherwise-anonymous abandoned cart becomes recoverable: the cart.abandoned webhook carries it as customer.email (with id: null ). A fully linked customer’s email always takes precedence. Pass null on update to clear it. The cart is the source of truth for shipping and totals Selecting a shipping method is a single, atomic call. The response is the full cart with shippingTotal , taxTotal , and total already recomputed — you do not need a calculate-before or a recalculate-after round trip, and you do not need to keep a parallel copy of the selected method or totals in client state. shipping.select and ca"},{"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","Shipping & discounts","Hand off to checkout","One hook for the whole flow: useThrottleCheckout","Endpoints","Update an item","Remove an item","Read the current cart","Apply a discount code"],"text":"export const metadata: Metadata = title: Cart sessions (backend-less carts) , ; export default function CartSessionsPage() return ( 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 , and a cart snapshot. 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 /> 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: application/json \\\\ -d code : SAVE10 /> Hand off to checkout When the buyer is ready, convert the cart session into a checkout session and redirect them to the returned checkoutUrl . This marks the cart session converted (no further edits). /checkout-session \\\\ -H Origin: https://shop.example.com -H Content-Type: application/json \\\\ -d returnUrl : https://shop.example.com/thanks , cancelUrl : https://shop.example.com/cart /> One hook for the whole flow: useThrottleCheckout useThrottleCheckout (in @usethrottle/checkout-react ) wraps all of the above: it binds totals and selectedMethod straight to the cart (no shadow copy to drift), locks shipping in one call via selectMethod , exposes a status state machine, and auto-recovers from a stale cart — if the cart was converted by a completed order or abandoned, it rebuilds a fresh session from the last-known line items and retries ( status === 'recovering' ). createSession returns the checkoutSessionId for <PaymentEmbed> . = useThrottleCheckout( applicationId, environmentId, quoteToken ); const start = async () => const checkoutSessionId = await createSession( returnUrl: https://shop.example.com/thanks , cancelUrl: https://shop.example.com/cart , ); setSessionId(checkoutSessionId); ; return sessionId ? : Pay totals?.total ; /> The hook does not write addresses to the cart — the hosted/full checkout embed collects shipping & billing. The hook focuses on cart state, shipping selection, totals, recovery, and the session handoff. Endpoints POST /api/v1/cart-sessions — create a cart + session. GET /api/v1/cart-sessions/ id — read the session + cart. POST /api/v1/cart-sessions/ id /items — add an item. PATCH /api/v1/cart-sessions/ id /items/ itemId — update an item. DELETE /api/v1/cart-sessions/ id /items/ itemId — remove an item. POST /api/v1/cart-sessions/ id /shipping — select a shipping method. DELETE /api/v1/cart-sessions/ id /shipping — clear the selected method. POST /api/v1/cart-sessions/ id /discount — apply a discount code. DELETE /api/v1/cart-sessions/ id /discount — remove the discount. POST /api/v1/cart-sessions/ id /checkout-session — hand off to checkout. invalid_quote_token (401) — bad token, app/environment, or origin. origin_not_allowed (403) — origin not on the allowlist. cart_session_expired (410) — session is no longer active. item_not_found (404) — the item isn't in this session's cart. rate_limit_exceeded (429) — too many POST /cart-sessions creates from one client; back off and retry. );"},{"title":"Developer Changelog","route":"/developers/changelog","headings":["Developer Changelog","2026-07-19 — Money-correctness: returns, order edits, cancel, currency","Returns & exchanges","Orders","Carts","2026-07-04 — Embedded checkout: webhooks, receipts, prefill & retry fixes","Webhooks","Emails","Checkout","2026-07-04 — Abandoned-cart recovery works end to end","Webhooks & email","2026-07-04 — Subscription checkout, typed request bodies, cart lifecycle","API & SDK","API","SDK","Docs","2026-06-20 — Cancel a checkout session","2026-06-20 — Canonical cart address + typed cart errors","2026-06-20 — Abandoned-carts read APIs","Embed config","2026-05-11 — Team management & per-app roles","Workspace invitations","Legacy compatibility","2026-05-04 — Collect flags, billing, metadata propagation","Embedded checkout","Discounts","Metadata + webhooks"],"text":"export const metadata: Metadata = title: Changelog , ; export default function ChangelogPage() return ( 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-07-19 — Money-correctness: returns, order edits, cancel, currency Returns & exchanges Return refunds now reflect what the buyer actually paid. The refund for a returned line is its subtotal plus tax, minus discounts (including a proportional share of order-level code discounts), prorated exactly across partial-quantity returns. Previously refunds used bare unitPrice × quantity , under-refunding tax and over-refunding discounted items. Orders PATCH /orders/:id/line-items now recalculates tax. When tax is configured for the application, edited orders re-quote tax for the resulting item set (added items no longer land with taxAmount: 0 ) and the captured delta charge/refund includes the tax movement. POST /orders/:id/cancel now settles payments. Open authorizations are always voided; pass refundCapturedPayments: true to also refund captured money. The response reports a paymentActions array. Carts Cart currency is validated against the application. POST /carts rejects a currency that differs from the application's configured per-environment currency (422 currency_mismatch ); omitting currency now inherits the application currency instead of silently defaulting to USD. Legacy deprecated discount types fail validation. Surviving free_shipping / buy_x_get_y rows (retired types) now fail checkout validation loudly instead of applying with a silent $0 effect while consuming a usage slot. 2026-07-04 — Embedded checkout: webhooks, receipts, prefill & retry fixes Webhooks payment.captured and payment.vaulted now deliver for embedded checkout. These events (and all webhooks from proxy/embed sessions, including order.created ) were silently dropped because the emit omitted application context. They now reach every subscribed endpoint with the correct signature. Emails customer.payment_receipt now sends for embedded checkout captures. The synchronous embed capture path previously bypassed the internal event bus, so no receipt email fired for card/one-time or subscription checkouts. Checkout Buyer prefill now reaches the checkout UI. Passing a customer on session create now pre-fills the buyer's name and address in the hosted/embedded form (the prefill was being stripped from the public session response). Failed payments are retryable. If a capture fails, returning to the same checkout session now retries cleanly instead of erroring; a duplicate submit returns the existing order without double-charging. 2026-07-04 — Abandoned-cart recovery works end to end Checkout Recovery links can now complete a purchase. Creating a checkout session against an abandoned cart now reopens it ( status → open ), so a buyer who returns via a cart.abandoned recovery link can finish checkout on that same cart. Previously the cart was frozen and completion dead-ended with cart … is in 'abandoned' status . Webhooks & email Guest carts get recovery emails. The abandoned-cart sweep now sends the customer.cart_abandoned recovery email to carts that captured only a customerEmail (no full customer record), not just carts linked to a customer. Configure the recovery link. The recovery URL in the cart.abandoned webhook payload and the recovery email is built from your per-app cartRecoveryUrlTemplate (must contain cartId ) — set it in the dashboard under Abandoned carts , or via PUT /api/v1/embed-config . Without it, recovery is webhook-only with a null URL. 2026-07-04 — Subscription checkout, typed request bodies, cart lifecycle API & SDK Checkout session request bodies are now documented. POST /api/v1/checkout/sessions and POST /api/v1/checkout/sessions/ id /complete now publish their requestBody in the OpenAPI spec. @usethrottle/api-client@2.6.1 regenerates postApiV1CheckoutSessions / postApiV1CheckoutSessionsComplete with a typed body parameter — no more raw-fetch workaround for creating or completing a session. Request/response validation is unchanged. Checkout Plan-based & free-trial subscription checkouts can use an empty cart. When a checkout session carries a recurring block and its cart has no line items, Throttle now synthesizes a single subscription line item from the plan at completion (amount due today for an immediate charge, or $0 for a free trial), so the order converts cleanly. Previously an empty cart failed with cart_empty (“Cart cannot be converted to an order without line items”). recurring.create: 'auto' governs subscription creation after payment; it does not add cart items itself. Webhooks Checkout-session expiry reaches the cart. A checkout session now stamps its expiry window onto the parent cart, and when a session expires the cart is released — its status moves to abandoned and cart.abandoned fires (with the standard recovery payload). Previously the cart stayed open with no event. 2026-07-04 — Actionable unavailableReason on payment methods API GET /api/v1/checkout-sessions/ id /payment-methods unavailableReason . When methods is empty because a payment provider is connected but can’t currently render (for example, not yet configured for the checkout’s environment), the response now includes an optional unavailableReason: code, message . Surface message to the buyer instead of a blank “no payment methods” state. The field is additive and only present on the empty path. The list itself now also reflects exactly what the embed will render, so the “available” view and the embed no longer diverge. 2026-06-21 — Cart email capture + richer cart.abandoned payload API Cart customerEmail . POST /api/v1/carts and PATCH /api/v1/carts/ id accept an optional customerEmail — lightweight email capture without a full customer record. The cart response now also returns customer"},{"title":"API Conventions","route":"/developers/conventions","headings":["API Conventions","Field naming: camelCase, single shape","Errors","Pagination","Identifier prefixes"],"text":"export const metadata: Metadata = title: Conventions , ; const requestExample = // POST /api/v1/carts request body applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , currency : USD ; const responseExample = // POST /api/v1/carts response data : id : 9f6e... , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , currency : USD , netN : null, lineItems : [] , meta : requestId : ... ; export default function ConventionsPage() return ( 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: 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_production_* and pk_production_* — production environment keys. whsec_* — webhook signing secret. );"},{"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":"export const metadata: Metadata = title: Discounts , ; const steps = [ title: Create a discount , body: Use the dashboard or API to create a percentage or fixed amount discount code. , , title: Preview in your storefront , body: Call /api/v1/discounts/preview from your server when the buyer enters a code. , , title: Apply to a cart , body: When checkout starts, apply the valid code to the Throttle cart before creating the checkout session. , , title: Lock totals , body: Run shipping/tax calculation and checkout_final against the discounted cart before payment. , , ]; const paymentFlowDiagram = sequenceDiagram participant B as Browser participant M as Merchant Backend participant T as Throttle API participant C as Checkout Iframe B->>M: buyer enters SUMMER25 M->>T: POST /api/v1/discounts/preview T-->>M: valid + discountTotal + total M-->>B: show adjusted order summary B->>M: buyer starts checkout M->>T: POST /api/v1/carts/ id /apply-discount M->>T: POST /api/v1/checkout/sessions M-->>B: sessionId B->>C: render checkout C->>T: complete payment with discounted total ; export default function DiscountsPage() return ( 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","Choosing the right surface","Session lifecycle","Sequence: postMessage handshake","Query parameter reference","postMessage event reference","Complete-session payloads","Payment-method filtering"],"text":"export const metadata: Metadata = title: Embedded Checkout , ; const handshakeChart = sequenceDiagram participant S as Storefront participant F as Throttle Iframe participant API as Throttle API S->>F: src=/s/ sessionId ?embed=1&parentOrigin=... F->>API: GET /payment-methods API-->>F: eligible methods F->>S: postMessage throttle.ready Note over F,S: buyer fills cart / address / billing F->>S: throttle.step.changed: address F->>S: throttle.step.changed: billing F->>S: throttle.step.changed: payment S->>F: buyer clicks Pay F->>S: throttle.processing F->>API: POST /checkout-sessions/ id /complete API-->>F: orderId, paymentId F->>S: throttle.completed orderId, paymentId S->>S: redirect to thank-you page ; const lifecycleSteps = [ title: Mint a session server-side , body: Your backend POSTs to /api/v1/checkout-sessions/embed-token with the cart amount and currency. The response includes the session id, a short-lived embed JWT, and both hostedUrl and embedUrl for direct rendering. (This payment-only endpoint does not accept allowedMethods — the payment widget renders the methods configured on your connection.) , , title: Render the iframe , body: Mount , , or a raw iframe pointing at hostedUrl or embedUrl. Throttle re-mints the embed JWT every render via GET /api/v1/checkout-sessions/:id/embed-token, so a buyer can land minutes after the original mint without breaking the flow. , , title: Buyer submits , body: When the buyer clicks Pay, the iframe POSTs /api/v1/checkout-sessions/:id/complete with one of three payload shapes (card, provider proxy, or net30) and surfaces processing/success/failure events to the parent. , , title: Parent reacts to events , body: Listen for throttle.completed (or onSucceeded in the React SDK) and navigate the buyer to your own thank-you page. Subscribe to outbound webhooks (order.created, payment.captured, payment.failed) for durable side-effects. , , ]; export default function EmbeddedCheckoutPage() return ( 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. 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. 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) , PaymentEmbed , ], [ <> /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 , 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. 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 ). Sequence: postMessage handshake Visualises the full conversation between your storefront page, the embedded iframe, and the Throttle API. Use it to sanity-check listener ordering — your parent page must register the message listener before the iframe emits throttle.ready . Query parameter reference Both /c and /s accept the same iframe-mode parameters. The React SDK sets these for you; documented here for raw integrations. embed , 1 , Toggles iframe mode (chromeless layout, postMessage emission, origin allowlist enforcement). Omit to render the standalone hosted page. , ], [ parentOrigin , https://shop.example.com , <> Required when embed=1 . Server-side check against the application's allowedOrigins (set in Embed Config); a missing or non-allowlisted origin renders the EmbedDenied panel instead of the checkout UI. , ], [ mode , payment-only , <> Skips the cart and address steps; jumps directly to the payment step. Use when your storefront already collected th"},{"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":"export const metadata: Metadata = title: Collection Flags , ; const sessionCreateBoth = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : true, billingAddress : true ; const sessionCreateShipOnly = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : true, billingAddress : false ; const sessionCreateBillOnly = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : false, billingAddress : true ; const sessionCreateNeither = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H X-API-Key: sk_production_xxx \\\\ -H Content-Type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : cart_abc , amount : 2999, currency : USD , collect : shippingAddress : false, billingAddress : false ; const completeBothBilling = POST /api/v1/checkout-sessions/sess_xxx/complete paymentMethod : card , shippingAddress : name : Jane Doe , line1 : 1 Market St , city : San Francisco , region : CA , postalCode : 94103 , country : US , billingAddress : name : Jane Doe , line1 : 1 Market St , city : San Francisco , region : CA , postalCode : 94103 , country : US , billingSameAsShipping : false ; const completeSameAsShipping = POST /api/v1/checkout-sessions/sess_xxx/complete paymentMethod : card , shippingAddress : ... : ... , billingSameAsShipping : true ; const collectMachineChart = flowchart LR Start([Session created]) --> S collect.shippingAddress? S -- true --> AddrShip[address step] S -- false --> Bcheck1 collect.billingAddress? AddrShip --> Bcheck2 collect.billingAddress? Bcheck2 -- true --> Bill[billing step] Bcheck2 -- false --> Pay1[payment step] Bcheck1 -- true --> BillOnly[billing step] Bcheck1 -- false --> Pay2[payment step] Bill --> Pay3[payment step] BillOnly --> Pay4[payment step] Pay1 --> Done([throttle.completed]) Pay2 --> Done Pay3 --> Done Pay4 --> Done ; const addressRequired422 = HTTP/1.1 422 Unprocessable Entity Content-Type: application/json error : code : address_required , message : Billing address is required for this session. , field : billingAddress ; export default function CollectionFlagsPage() return ( 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: collect.shippingAddress , boolean , true , <> When true , the iframe renders the shipping address step and /complete requires shippingAddress . , ], [ collect.billingAddress , boolean , false , <> 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 and stamped them on metadata.customer_prefill . 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 : name , line1 , line2? , city , region , postalCode , country . 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 "},{"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":"export const metadata: Metadata = title: Parent Controls , ; const reactSnippet = use client ; export function CheckoutPage( sessionId : sessionId: string ) const register, watch = useForm( mode: onChange ); const email = watch( email ); const name = watch( name ); const formValid = !!email && !!name; return ( <> router.push(\\ /orders/\\$ orderId \\ ) /> ); ; const vanillaJsSnippet = // Direct postMessage when not using @usethrottle/checkout-react. const iframe = document.querySelector( #throttle-checkout ); const throttleOrigin = https://checkout.usethrottle.dev ; function setSubmitDisabled(disabled) iframe.contentWindow.postMessage( source: throttle-parent , version: 1, type: set-submit-disabled , disabled, , throttleOrigin, ); form.addEventListener( input , () => setSubmitDisabled(!form.checkValidity()); ); ; const messageShape = source : throttle-parent , // distinguishes parent commands from version : 1, // protocol version; bumps are additive type : set-submit-disabled , disabled : true | false ; export default function ParentControlsPage() return ( 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":"Environments","route":"/developers/environments","headings":[],"text":"export metadata from ../test-mode/page ; export default from ../test-mode/page ;"},{"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 (show once — store both values immediately):","\"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","\"webhookSigningSecret\": \"whsec_...\" ← only present if webhookUrl was declared; shown once","}","}`;"],"text":"export const metadata: Metadata = title: Build an Extension , ; const steps = [ title: Create the extension in the dashboard , body: Go to Extensions in your workspace sidebar. Give it a name, declare your iframeUrl (for UI extensions) and/or a webhookUrl + eventSubscriptions (for event extensions), and list the scopes you need. , , title: Create and publish a version , body: POST /api/v1/extensions/:id/versions to snapshot the current manifest as an immutable version, then POST /api/v1/extensions/:id/versions/:vid/publish to mark it installable. , , title: Install into an application , body: POST /api/v1/extensions/:id/install with a versionId. The response includes a one-time API key and (if webhookUrl is declared) a webhookSigningSecret. Store both securely — they are never shown again. , , title: Wire up the iframe bridge , body: In your iframe page, call createBridge() from @usethrottle/extension-bridge (npm) or load the hosted script. Wait for bridge.ready, then use bridge.api.* to call Throttle REST endpoints. , , ]; const registerSnippet = # 1. Create the extension catalog entry curl -X POST https://api.usethrottle.dev/api/v1/extensions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d name : Inventory Sidebar , iframeUrl : https://extensions.example.com/inventory , webhookUrl : https://extensions.example.com/webhooks , eventSubscriptions : [ order.created , order.completed ], scopes : [ orders:read , customers:read ] # → data: id: ext_... , name: Inventory Sidebar , lifecycle: draft , ... ; const versionSnippet = # 2. Create a version (snapshots the current manifest) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d version : 1.0.0 , notes : Initial release # → data: id: ver_... , version: 1.0.0 , status: draft , ... # 3. Publish the version (marks it installable) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions/ver_xxx/publish \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d # → data: id: ver_... , status: published , ... ; const installSnippet = # 4. Install into an application (requires X-Throttle-Application-Id header) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/install \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H X-Throttle-Environment-Id: \\\\ -H content-type: application/json \\\\ -d versionId : ver_xxx # Response (show once — store both values immediately): # # 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 # webhookSigningSecret : whsec_... ← only present if webhookUrl was declared; shown once # # ; const bridgeHostedSnippet = const bridge = window.ThrottleBridge.createBridge( targetOrigin: https://app.usethrottle.dev , ); bridge.ready.then((ctx) => console.log( Extension running as , ctx.user.email, in , ctx.environment.environmentSlug); bridge.api.get( /api/v1/orders?limit=10 ).then((res) => console.log( Recent orders: , res.data); ); bridge.resize(480); bridge.toast( Inventory synced , success ); bridge.navigate( /orders ); ); ; const bridgeNpmSnippet = // Option B: npm package (React / bundled apps) const bridge = createBridge( targetOrigin: https://app.usethrottle.dev , ); const ctx = await bridge.ready; console.log( workspace: , ctx.workspace.slug, app: , ctx.application.slug); console.log( environment: , ctx.environment.environmentSlug, role: , ctx.role); console.log( scopes: , ctx.scopes); const orders = await bridge.api.get( /api/v1/orders?limit=5 ); const customer = await bridge.api.get( /api/v1/customers/cust_xxx ); await bridge.api.post( /api/v1/orders/ord_xxx/notes , note: Inventory reserved ); bridge.resize(600); // resize the iframe panel bridge.toast( Done , info ); // show a dashboard toast bridge.navigate( /orders ); // navigate the parent window bridge.destroy(); ; const bridgeContextSnippet = type SessionContext = user: id: string; email: string ; workspace: id: string; slug: string ; application: id: string; slug: string ; environment: environmentId: string; environmentSlug: string; environmentKind: production | non_production ; providerEnvironment: production | sandbox ; ; installationId: string; extensionId: string; version: string; role: string; scopes: string[]; ; const ctx = await bridge.ready; const bridgeReactSnippet = // React hook pattern export function useExtensionBridge() const bridgeRef = useRef (null); const [ctx, setCtx] = useState (null); useEffect(() => const bridge = createBridge( targetOrigin: https://app.usethrottle.dev ); bridgeRef.current = bridge; bridge.ready.then(setCtx); return () => bridge.destroy(); , []); return bridge: bridgeRef.current, ctx ; function InventorySidebar() const bridge, ctx = useExtensionBridge(); useEffect(() => if (!bridge || !ctx) return; bridge.api.get( /api/v1/orders?limit=20 ).then((res: any) => ); , [bridge, ctx]); return ... ; ; export default function ExtensionsBuildPage() return ( 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 au"},{"title":"Extension Events","route":"/developers/extensions/events","headings":["Extension Events","Payload envelope","Verify signatures","Process idempotently","Delivery inspection and replay","Auto-suspend on repeated failures","Event catalog","Replay a dead-lettered delivery","Send a verification ping to the installation's webhook URL"],"text":"export const metadata: Metadata = title: Extension Events , ; const eventsByScope = (() => const groups = new Map (); for (const type of ALL_EVENT_TYPES) const scope = eventReadScope(type); const list = groups.get(scope) ?? []; list.push(type); groups.set(scope, list); return [...groups.entries()] .map(([scope, types]) => ( scope, types: [...types].sort() )) .sort((a, b) => a.scope.localeCompare(b.scope)); )(); const envelopeSnippet = id : evt_01HZX... , type : order.created , version : 1 , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , environmentId : 8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 , createdAt : 2026-05-31T10:00:00.000Z , data : orderId : ord_01HZX... , cartId : cart_01HZX... , status : pending , currency : USD , total : 12900 ; const verifySnippet = // Node.js — import from the subpath entry (uses node:crypto) app.post( /webhooks/throttle , express.raw( type: */* ), (req, res) => const rawBody = req.body.toString( utf8 ); // ← raw string, BEFORE JSON.parse const signature = req.headers[ x-throttle-signature ] as string; const secret = process.env.EXTENSION_WEBHOOK_SECRET!; // from install response const valid = verifyWebhook(rawBody, signature, secret); if (!valid) return res.status(400).json( error: Invalid signature ); const event = JSON.parse(rawBody); console.log( Received event: , event.id, event.type); res.status(200).send(); ); ; const verifyEdgeSnippet = // Cloudflare Workers / Deno / Bun — use verify.ts (Web Crypto API, async) export default async fetch(request: Request): Promise const rawBody = await request.text(); const signature = request.headers.get( x-throttle-signature ) ?? ; const secret = env.EXTENSION_WEBHOOK_SECRET; const valid = await verifyWebhook(rawBody, signature, secret); if (!valid) return new Response( Bad signature , status: 400 ); const event = JSON.parse(rawBody); return new Response( ok ); , ; ; const idempotencySnippet = // Store event IDs before processing side effects to handle at-least-once delivery. const eventId = event.id; const alreadyProcessed = await db.query( SELECT 1 FROM processed_events WHERE event_id = $1 , [eventId], ); if (alreadyProcessed.rowCount > 0) return res.status(200).json( status: duplicate ); await processOrder(event.data); await db.query( INSERT INTO processed_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW()) , [eventId, event.type], ); ; const replaySnippet = # Inspect deliveries for an installation GET /api/v1/installations/:id/deliveries?limit=50&offset=0 # Replay a dead-lettered delivery POST /api/v1/installations/:id/deliveries/:deliveryId/replay # Send a verification ping to the installation s webhook URL POST /api/v1/installations/:id/test ; export default function ExtensionsEventsPage() return ( 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 , createdAt , data . 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. 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. Auto-suspend on repeated failures If an extension's webhook endpoint fails all five delivery attempts repeatedly, Throttle may suspend the installation ( status: "suspended" ). A suspended installation stops receiving new events. Resume via POST /api/v1/extensions/:id/suspend (which actually toggles — use it to re-activate) or reinstall the extension to get a fresh install row with a fresh endpoint. Event catalog Each event type below requires the matching read scope in your extension manifest's scopes list to subscribe to it. Throttle validates this at registration time — if eventSubscriptions references an event whose required scope is not in the manifest's scopes , the create/update call returns 400 insufficient_scopes . Extensions may only request extensionAllowed scopes — workspace events ( worksp"},{"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":"export const metadata: Metadata = title: Get Started with Extensions ; const steps = [ title: Choose an application in Test mode , body: Create or select a Throttle application and switch the dashboard to Test mode. Catalog entries belong to the publisher workspace; each installation belongs to one application and environment. , title: Prepare your application , body: Use the public starter repository when it is available, or host an HTTPS iframe, webhook handler, or both. Your extension never needs access to Throttle\\ s private source repository. , title: Expose local endpoints over HTTPS , body: Use a secure development tunnel for your iframe and webhook endpoints. Keep the tunnel URL stable while configuring and testing the catalog entry. , title: Create a draft catalog entry , body: Open Extensions → Catalog, enter the iframe URL and/or webhook URL, select event subscriptions, and request only the scopes the app actually uses. , title: Publish a private version , body: Create version 0.1.0 from the draft manifest and publish it. Keep the extension private while developing and running acceptance tests. , title: Install it into the Test environment , body: Use the install wizard for the selected application. Capture one-time server credentials in a secret manager; never put them in browser code or source control. , title: Verify both surfaces , body: Confirm the iframe receives verified identity context and send a signed test event to the webhook. Inspect delivery logs and repeat a delivery to confirm idempotency. , ]; export default function ExtensionGetStartedPage() return 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 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":"export const metadata: Metadata = title: Extension Identity JWT , ; const claimsSnippet = iss : throttle , sub : , aud : , iat : 1748000000, exp : 1748000600, // 10-minute TTL installationId : inst_01HZX... , extensionId : ext_01HZX... , version : 1.0.0 , // pinned semver of the installed version workspace : id : ws-uuid , slug : acme , application : id : app-uuid , slug : acme-store , environment : environmentId : env-uuid , environmentSlug : uat , environmentKind : non_production , providerEnvironment : sandbox , user : id : user-clerk-id , email : alice@acme.com , name : alice@acme.com , role : admin , // app-level role: admin | developer | finance | viewer scopes : [ orders:read , customers:read ] // from the pinned version ; const mintSnippet = // The dashboard calls this endpoint automatically during the bridge handshake. const res = await fetch( https://api.usethrottle.dev/api/v1/installations/inst_xxx/launch-token , method: POST , headers: Authorization: Bearer , X-Throttle-Application-Id : , X-Throttle-Environment-Id : , content-type : application/json , , body: JSON.stringify( ), , ); const data = await res.json(); const verifyJwksSnippet = import createRemoteJWKSet, jwtVerify from jose ; const JWKS = createRemoteJWKSet( new URL( https://api.usethrottle.dev/.well-known/extension-jwks.json ), ); export async function verifyExtensionToken(token: string) try const payload = await jwtVerify(token, JWKS, issuer: throttle , algorithms: [ RS256 ], ); return installationId: payload.installationId as string, extensionId: payload.extensionId as string, version: payload.version as string, workspace: payload.workspace as id: string; slug: string , application: payload.application as id: string; slug: string , environment: payload.environment as environmentId: string; environmentSlug: string; environmentKind: production | non_production ; providerEnvironment: production | sandbox ; , user: payload.user as id: string; email: string; name: string , role: payload.role as string, scopes: payload.scopes as string[], ; catch return null; // expired, bad signature, wrong issuer — always fail closed ; const bearerSnippet = // The iframe bridge automatically attaches the token as a Bearer on every const token = bridge.getToken(); const res = await fetch( https://api.usethrottle.dev/api/v1/orders?limit=10 , headers: Authorization: \\ Bearer \\$ token \\ , content-type : application/json , , ); ; const autoRefreshSnippet = // bridge.api.* handles refresh automatically. const currentToken = bridge.getToken(); export default function ExtensionsIdentityPage() return ( 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. 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 "},{"title":"Installing Extensions","route":"/developers/extensions/install","headings":["Installing Extensions","Install an extension","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 (show once — store both values immediately) ────────────────────","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":"export const metadata: Metadata = title: Installing Extensions , ; const installSnippet = # Install a published version of an extension into an application. # Both the dashboard session token (Clerk JWT) and the Application ID header # are required — installs are application-scoped. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/install \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H X-Throttle-Environment-Id: \\\\ -H content-type: application/json \\\\ -d versionId : ver_xxx , config : syncInterval : 60, notifyOnNew : true # ─── Response (show once — store both values immediately) ──────────────────── data : id : inst_01HZX... , extensionId : ext_xxx , versionId : ver_xxx , status : active , environmentId : 8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 , environmentSlug : uat , environmentKind : non_production , providerEnvironment : sandbox , config : syncInterval : 60, notifyOnNew : true , createdAt : 2026-05-31T10:00:00.000Z , apiKey : sk_uat_... , // ← never shown again webhookSigningSecret : whsec_... // ← never shown again (only if webhookUrl declared) ; const uninstallSnippet = # Uninstall removes the active install for this application + environment. # The extension catalog entry and other applications installs are unaffected. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/uninstall \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: # → 204 No Content ; const configSnippet = # Update the installation configuration after install. curl -X PATCH https://api.usethrottle.dev/api/v1/installations/inst_xxx/config \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d config : syncInterval : 120, notifyOnNew : false # → data: id: inst_xxx , config: ... , ... ; const bridgeConfigSnippet = import createBridge from @usethrottle/extension-bridge ; const bridge = createBridge( targetOrigin: https://app.usethrottle.dev ); const ctx = await bridge.ready; const installs = await bridge.api.get( /api/v1/installations ) as data: Array >; ; const install = installs.data.find((item) => item.id === ctx.installationId); const config = install?.config ?? ; ; const configSchemaSnippet = type : object , properties : apiKey : type : string , title : Provider API key , description : Issued by the upstream provider , syncInterval : type : number , title : Sync interval , notifyOnNew : type : boolean , title : Notify on new records , required : [ apiKey ] ; const listSnippet = # List all installations for the current application + environment. GET /api/v1/installations -H Authorization: Bearer -H X-Throttle-Application-Id: -H X-Throttle-Environment-Id: # Response shape: paginated list # # data : [ id : inst_... , extensionId : ext_... , status : active , ... ], # meta : requestId : ... , pagination : cursor : null, hasMore : false # ; export default function ExtensionsInstallPage() return ( 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 and webhookSigningSecret are shown exactly once in the install response. If you lose either value, the only recovery is to uninstall and reinstall — a new key and signing secret will be generated. 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 co"},{"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":"export const metadata: Metadata = title: Operating Your Extension ; export default function ExtensionOperationsPage() return 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":"export const metadata: Metadata = title: Extensions Overview , ; const lifecycleChart = stateDiagram-v2 [*] --> draft: POST /extensions draft --> private: POST /extensions/:id/lifecycle (lifecycle=private) private --> draft: lifecycle=draft private --> public: lifecycle=public (admin only) public --> private: lifecycle=private draft --> [*]: DELETE /extensions/:id ; export default function ExtensionsOverviewPage() return ( 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 shown once at install 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"},{"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\"","}","}`;","→ { data: { id: \"ext_xxx\", lifecycle: \"public\", ... } }`;"],"text":"export const metadata: Metadata = title: Publishing to the Marketplace , ; const reviewLifecycleChart = stateDiagram-v2 [*] --> not_submitted: Private extension with a published version not_submitted --> pending: Developer submits for review pending --> in_review: Staff claims the review in_review --> approved: Staff approves in_review --> changes_requested: Staff requests changes in_review --> rejected: Staff rejects changes_requested --> pending: Developer resubmits approved --> public: Developer clicks Publish to marketplace ; const submitSnippet = # Submit the latest published version of your extension for review. # Requires: an accepted Publisher Agreement + a completed Listing. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/reviews \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d # Response: # # data : # id : rev_01HZX... , # extensionId : ext_xxx , # status : pending , # versionId : ver_xxx , # createdAt : 2026-06-06T10:00:00.000Z # # ; const lifecycleSnippet = # Once the review is approved, the developer publishes the extension. curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/lifecycle \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d lifecycle : public # → data: id: ext_xxx , lifecycle: public , ... ; export default function ExtensionsPublishingPage() return ( 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. The public marketplace is controlled by the EXTENSIONS_PUBLIC_MARKETPLACE environment flag. The entire pipeline ships dark; the flag is flipped once the Publisher Agreement text is finalized. All API endpoints and review tooling are available regardless of the flag. 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 or SVG uploaded via the icon-upload flow. Stored as a public S3 object and referenced by its public URL. At least one screenshot — a minimum of one screenshot or promotional image (PNG/JPEG, 1280 × 800 recommended). 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. 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/extensi"},{"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":"export const metadata: Metadata = title: Extension Scopes , ; const extensionScopeGroups = (() => return Object.entries(SCOPE_GROUPS) .map(([group, scopeIds]) => ( group, scopes: scopeIds .map((id) => SCOPE_REGISTRY[id]) .filter((def) => def && def.extensionAllowed), )) .filter((g) => g.scopes.length > 0) .sort((a, b) => a.group.localeCompare(b.group)); )(); const manifestScopeSnippet = // Extension manifest with scopes name : Order Inspector , iframeUrl : https://extensions.example.com/orders , webhookUrl : https://extensions.example.com/webhooks , eventSubscriptions : [ order.created , order.updated , payment.captured ], scopes : [ orders:read , // required for order.* events + GET /api/v1/orders payments:read , // required for payment.* events + GET /api/v1/payments customers:read // optional — allows reading customer records ] ; const ceilingSnippet = // The extension s JWT token carries the scopes from the pinned version. const orders = await bridge.api.get( /api/v1/orders?limit=10 ); export default function ExtensionsScopesPage() return ( 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":"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":"export const metadata: Metadata = title: Extension Security Model , ; const jwksVerifySnippet = import createRemoteJWKSet, jwtVerify from jose ; const JWKS = createRemoteJWKSet( new URL( https://api.usethrottle.dev/.well-known/extension-jwks.json ), ); async function verifyToken(token: string) try const payload = await jwtVerify(token, JWKS, issuer: throttle , // always throttle algorithms: [ RS256 ], // only RS256 — no HS256 ); return payload; catch return null; // expired, bad sig, wrong issuer → reject ; const sandboxSnippet = ; const scopeCeilingSnippet = // The extension token s scopes come from the pinned version s scopes list. const res = await bridge.api.get( /api/v1/team-members ); // → 403 insufficient_scopes ; const signatureVerifySnippet = import verifyWebhook from @usethrottle/extension-bridge/webhook ; function handleWebhook(rawBody: string, signatureHeader: string): boolean return verifyWebhook( rawBody, signatureHeader, process.env.EXTENSION_WEBHOOK_SECRET!, toleranceSeconds: 300 , // default — reject deliveries > 5 min old ); export default function ExtensionsSecurityPage() return ( 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 — rotating it requires uninstalling and reinstalling. 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. Uninstalling and reinstalling rotates the secret. See the Events reference for the full verification workflow and event catalog. Summary RS256 + "},{"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":"const cloneSnippet = git clone https://github.com/Epic-Design-Labs/throttle-extension-starter.git cd throttle-extension-starter corepack enable # picks up the pinned pnpm version pnpm install pnpm dev # embedded UI at http://localhost:5173 (mocked bridge) pnpm check # format, lint, typecheck, tests, build — must pass ; export const metadata: Metadata = title: Extension Starter Repository ; export default function ExtensionStarterRepositoryPage() return 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. 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":"export const metadata: Metadata = title: Testing Extensions ; export default function ExtensionTestingPage() return 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":"export const metadata: Metadata = title: Extension Versioning , ; const versionLifecycleChart = stateDiagram-v2 [*] --> draft: POST /extensions/:id/versions draft --> published: POST .../publish published --> [*]: (immutable — never changed) ; const createVersionSnippet = # Create a new version (snapshots the current manifest) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d version : 1.1.0 , notes : Added customers:read scope for contact lookup feature # Response: # # data : # id : ver_new... , # version : 1.1.0 , # status : draft , # scopes : [ orders:read , customers:read ], # notes : ... , # createdAt : ... # # ; const publishVersionSnippet = # Publish a draft version (makes it installable) curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions/ver_new/publish \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d ; const listVersionsSnippet = # List all versions for an extension GET /api/v1/extensions/ext_xxx/versions # Response: data: versions: [ id, version, status, scopes, ... , ... ] ; const upgradeSnippet = # Attempt to upgrade to a new version curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_new... # 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: curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_new... , acknowledgedNewScopes : true # → data: id: inst_xxx , versionId: ver_new... , ... ; const rollbackSnippet = # Roll back to an older published version by upgrading to it. # 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. curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_xxx/upgrade \\\\ -H Authorization: Bearer \\\\ -H X-Throttle-Application-Id: \\\\ -H content-type: application/json \\\\ -d targetVersionId : ver_old... ; export default function ExtensionsVersioningPage() return ( 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":"export const metadata: Metadata = title: Idempotency , ; const usageSnippet = // Generate a UUID v4 per logical operation; reuse it on retry. const idempotencyKey = randomUUID(); await fetch( https://api.usethrottle.dev/api/v1/carts , method: POST , headers: Authorization : \\ Bearer \\$ process.env.THROTTLE_API_KEY \\ , Content-Type : application/json , Idempotency-Key : idempotencyKey, , body: JSON.stringify( applicationId: process.env.THROTTLE_APPLICATION_ID, currency: USD , ), ); ; const replayHeaderSnippet = # Second request with the same key + same body returns the # first response byte-for-byte. The header below tells you it s a replay. HTTP/1.1 201 Created content-type: application/json idempotency-replay: true id : 9f6e... , ... ; const reusedSnippet = // 409 idempotency_key_reused — same key, DIFFERENT body. error : code : idempotency_key_reused , message : Idempotency-Key was already used with a different request body. Generate a new key for new operations. ; export default function IdempotencyPage() return ( 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":"Customer Invoices","route":"/developers/invoices","headings":["Customer Invoices","One list, three payment types","Auth and trust model","Pagination","Per-application branding","Refund behavior","REST routes quick reference","Pages in this section","Merchant API: list invoices for a specific customer","Redirect directly to PDF (default)"],"text":"export const metadata: Metadata = title: Customer Invoices , ; const proxySnippet = // app/api/throttle/invoices/[...path]/route.ts const handler = createInvoiceProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getCustomerId(request) const user = await auth(); return user?.id ?? null; , ); export handler as GET ; ; const paginationSnippet = // Cursor-paginated response envelope data : [ id : inv_abc123 , number : INV-sprinter-001 , status : paid , sourceType : order , currency : USD , subtotal : 4999, taxTotal : 400, shippingTotal : 699, discountTotal : 0, total : 6098, amountRefunded : 0, issuedAt : 2026-06-01T10:00:00Z , dueAt : null, periodStart : null, periodEnd : null, createdAt : 2026-06-01T10:00:00Z ], meta : pagination : cursor : eyJpZCI6Imlu... , hasMore : true ; const curlListSnippet = # Merchant API: list all invoices (all source types) for the application curl -G https://api.usethrottle.dev/api/v1/invoices \\\\ -H x-api-key: $THROTTLE_SECRET_KEY \\\\ --data-urlencode limit=25 \\\\ --data-urlencode sort=desc # Merchant API: list invoices for a specific customer curl https://api.usethrottle.dev/api/v1/customers/cus_xyz/invoices \\\\ -H x-api-key: $THROTTLE_SECRET_KEY ; const curlDownloadSnippet = # Get a signed PDF download URL (JSON mode) curl https://api.usethrottle.dev/api/v1/invoices/inv_abc123/download?format=json \\\\ -H x-api-key: $THROTTLE_SECRET_KEY # Redirect directly to PDF (default) curl -L https://api.usethrottle.dev/api/v1/invoices/inv_abc123/download \\\\ -H x-api-key: $THROTTLE_SECRET_KEY \\\\ --output invoice.pdf ; const curlDownloadResponseSnippet = data : url : https://s3.us-east-1.amazonaws.com/…signed-url ; export default function CustomerInvoicesPage() return ( 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. 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":"export const metadata: Metadata = title: React Package · @usethrottle/invoices , ; const installSnippet = pnpm add @usethrottle/invoices ; const proxySnippet = // app/api/throttle/invoices/[...path]/route.ts const handler = createInvoiceProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getCustomerId(request) const user = await auth(); return user?.id ?? null; , ); export handler as GET ; ; const providerSnippet = use client ; export function Providers( children : children: React.ReactNode ) return ( fetch(\\ /api/throttle/invoices\\$ path \\ , init) > children ); ; const useInvoicesSnippet = import useInvoices from @usethrottle/invoices ; function InvoiceList() const data, isLoading, error = useInvoices( limit: 20, sort: desc , ); if (isLoading) return ; if (error) return ; if (!data) return null; return ( <> data.data.map((invoice) => ( )) data.pagination?.hasMore && ( ) ); ; const useInvoiceSnippet = import useInvoice from @usethrottle/invoices ; function InvoiceDetail( id : id: string ) const data: invoice, isLoading = useInvoice(id); if (isLoading || !invoice) return ; return ( invoice.number Status: invoice.status Total: invoice.total / 100 invoice.currency invoice.amountRefunded ? ( Refunded: invoice.amountRefunded / 100 invoice.currency ) : null ); ; const useDownloadSnippet = import useDownloadInvoice from @usethrottle/invoices ; function DownloadButton( invoiceId : invoiceId: string ) const download = useDownloadInvoice(); return ( download(invoiceId) > Download PDF ); ; const useDownloadUrlSnippet = import useDownloadInvoice from @usethrottle/invoices ; function InvoiceLink( invoice : invoice: Invoice ) const download = useDownloadInvoice(); return ( e.preventDefault(); await download(invoice.id); // Opens PDF in new tab > View PDF ); ; const manualPaginationSnippet = import useState from react ; function PaginatedList() const [cursor, setCursor] = useState (null); const data, isLoading = useInvoices( cursor: cursor ?? undefined, limit: 20 ); return ( <> data?.data.map((inv) => ) data?.pagination?.hasMore && ( setCursor(data.pagination!.cursor) > Load more ) ); ; const invalidateSnippet = import useInvoiceInvalidation from @usethrottle/invoices ; function RefundButton( paymentId : paymentId: string ) const invalidate = useInvoiceInvalidation(); async function handleRefund() await fetch(\\ /api/payments/\\$ paymentId /refund\\ , method: POST ); invalidate(); return Refund ; ; export default function InvoicesReactPackagePage() return ( 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 — hu"},{"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":"export const metadata: Metadata = title: MCP server , ; const claudeCodeSnippet = # Claude Code claude mcp add throttle -e THROTTLE_API_KEY=sk_uat_... -- npx -y @usethrottle/mcp ; const configSnippet = mcpServers : throttle : command : npx , args : [ -y , @usethrottle/mcp ], env : THROTTLE_API_KEY : sk_uat_... ; const remoteCurlSnippet = curl https://mcp.usethrottle.dev/mcp \\\\ -X POST \\\\ -H Authorization: Bearer sk_uat_... \\\\ -H Content-Type: application/json \\\\ -H Accept: application/json, text/event-stream \\\\ -d jsonrpc : 2.0 , id :1, method : tools/list ; const validateSnippet = // The assistant checks a body before writing integration code. validate_payload( method: POST , path: /api/v1/carts/ id /items , body: name: Widget , unit_price: 2500, storeId: store_123 ) ok : false, matched : POST /api/v1/carts/ id /items , errors : [ field : unit_price , problem : retired public field name — the API rejects it , suggestion : use \\\\ unitPrice\\\\ , field : storeId , problem : retired public field name — the API rejects it , suggestion : use \\\\ applicationId\\\\ , field : unitPrice , problem : required field is missing ] ; export default function McpPage() return ( 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, scopes, and read-only behavior are identical to the local server; the only difference is transport. 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. Both are tracked for a future OAuth 2.1 phase. 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. 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. explain_checkout_failure — session state, cart, emitted events and recent webhook deliveries for one failing checkout, in a single call. get_integration_status — connectors, webhook endpoints and delivery health: “am I ready to go live?” search_discounts , get_discount — discount codes: value, conditions, usage limits and redemption count. search_payments , get_payment — payments as a standalone search, with full transaction history on the by-id read. get_invoice — a single issued invoice by id. list_abandoned_carts — carts started but never finalized, with recovery status and a summary. list_extension_installations — extensions installed on this application, with status and configuration. get_connector_routing — payment-routing cascade, active routing connectors, and card/other decline rules. get_workspace_settings , get_audit_log — workspace identity/branding/net30 defaults, and recent admin actions on this workspace. get_business_summary — orders/revenue, subscription MRR, receivables and quotes pipeline for a week, month or quarter, compared to the prior period of equal length. get_attention_digest — open disputes, subscriptions in dunning, overdue Net-N invoices, and webhooks with degraded delivery health, all in one call. search_events , list_webhook_deliveries , get_webhook_delivery — the event log and outbound delivery records, including the payload we sent and the response we got. seed_test_data — a customer, cart, line items and a finalized draft order so a fresh integration has something to read. Refuses to run against production. Catching field-name mistakes before they 400 Throttle's public API is strict camelCase and rejects retired aliases. Those aliases are exactly what a model tends to guess, so the server names the replacement instead of letting you find out from a 400. Writes Five write tools are available behind an explicit --allow-writes flag (a second --allow-live-writes gate is required for production keys), each with an idempotency key on every call. Holding a write scope means the key may write; the flag is how you say you intended it to. seed_test_data — sandbox only, refused"},{"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"],"text":"export const metadata: Metadata = title: Metadata , ; const customerSnippet = // Customers — use the REST API until a focused customer SDK is published. const create = await fetch( https://api.usethrottle.dev/api/v1/customers , method: POST , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( email: jane@example.com , externalId: user_42 , metadata: plan: enterprise , cohort: q2-2026 , referredBy: partner_x , , ), ); const data: customer = await create.json(); const data: fetched = await fetch(\\ https://api.usethrottle.dev/api/v1/customers/\\$ customer.id \\ , headers: x-api-key : process.env.THROTTLE_API_KEY! , ).then((res) => res.json()); console.log(fetched.metadata.plan); // enterprise ; const subscriptionSnippet = // Subscriptions const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_API_KEY!, ); const now = new Date(); const end = new Date(now); end.setMonth(end.getMonth() + 1); const sub = await subscriptions.create( externalCustomerId: user_42 , planReference: pro_monthly , interval: monthly , amount: 2999, currentPeriodStart: now.toISOString(), currentPeriodEnd: end.toISOString(), metadata: salesRepId: rep_99 , contractId: con_2026_xyz , region: EMEA , , ); await subscriptions.update(sub.id, metadata: ...sub.metadata, contractRenewedAt: 2026-05-03 , ); ; const cartSnippet = // Carts AND cart line items const carts = new CartClient( apiKey: process.env.THROTTLE_API_KEY!, ); const cart = await carts.carts.create( applicationId, metadata: source: mobile_app , utmCampaign: summer_sale , ); await carts.items.add(cart.id, type: subscription , name: Pro Plan , unitPrice: 2999, quantity: 1, metadata: sku: PRO-001 , catalogVersion: 2026-04 , fulfillmentType: access_grant , resourceType: plan , resourceId: pro_monthly , permissions: [ read ], requiresShipping: false, , ); ; const fulfillmentMetadataSnippet = // Digital product: auto-fulfilled after payment. await carts.items.add(cart.id, type: product , name: Design System Kit , unitPrice: 4900, quantity: 1, metadata: sku: DS-KIT-2026 , fulfillmentType: digital , downloadUrl: https://cdn.example.com/design-system-kit.zip , downloadLimit: 3, downloadExpiry: 2026-12-31T23:59:59.000Z , requiresShipping: false, , ); await carts.items.add(cart.id, type: subscription , name: Pro Plan , unitPrice: 2999, quantity: 1, metadata: fulfillmentType: access_grant , resourceType: plan , resourceId: pro_monthly , permissions: [ read ], , ); ; const orderPaymentSnippet = // Orders + payments await fetch(\\ https://api.usethrottle.dev/api/v1/orders/\\$ orderId \\ , method: PATCH , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( metadata: fulfillmentVendor: shipstation , merchantNotes: priority , , ), ); await fetch(\\ https://api.usethrottle.dev/api/v1/orders/\\$ orderId /payments\\ , method: POST , headers: x-api-key : process.env.THROTTLE_API_KEY!, content-type : application/json , , body: JSON.stringify( amount: 2999, currency: USD , method: embedded , metadata: processedBy: rep_99 , urgentRefund: false , ), ); ; const checkoutSnippet = // Checkout sessions const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_API_KEY!, ); await subscriptions.createCheckoutSession( applicationId, externalCartId, customer: externalCustomerId: user_42 , email: jane@example.com , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , metadata: // sibling to recurring landingPage: /pricing/pro , referralCode: FRIEND10 , , returnUrl: https://shop.example/success , cancelUrl: https://shop.example/pricing , ); ; export default function MetadataPage() return ( 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. 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 "},{"title":"Pick the Smallest Package That Does the Job","route":"/developers/packages","headings":["Pick the Smallest Package That Does the Job","Package Boundary"],"text":"export const metadata: Metadata = title: Packages , ; export default function PackagesPage() return ( 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":"Payment Methods","route":"/developers/payment-methods","headings":["Payment Methods","Two-step auth flow","Endpoint reference","The SavedCard shape","Error reference","Security notes","React package","Step 1 — backend: mint a client token","Step 2 — frontend: render the component"],"text":"export const metadata: Metadata = title: Payment Methods , ; const flowDiagram = sequenceDiagram participant B as Buyer Browser participant M as Your Backend participant T as Throttle API M->>T: POST /api/v1/payment-methods/client-token customerId note over M,T: Authenticated with sk_* secret key T-->>M: token, expiresAt M-->>B: token B->>T: GET /api/v1/me/payment-methods note over B,T: X-Throttle-PM-Token: T-->>B: data: SavedCard[] B->>T: PATCH /api/v1/me/payment-methods/:id isDefault: true T-->>B: data: SavedCard B->>T: POST /api/v1/me/payment-methods/embed-token T-->>B: embedToken, checkoutSessionId, ... note over B,T: Buyer adds new card via the add-only vault embed ; const mintTokenSnippet = // Your backend — Node.js / Next.js App Router route handler export async function POST(request: Request) const user = await auth(); // your auth layer if (!user) return new Response(null, status: 401 ); const customerId = await resolveThrottleCustomerId(user.id); // cus_* const token, expiresAt = await mintClientToken( apiKey: process.env.THROTTLE_SECRET_KEY!, customerId, ); return Response.json( token, expiresAt ); ; const mintTokenResponseSnippet = data : token : pm_tkn_eyJhbGci... , expiresAt : 2026-06-24T11:00:00.000Z ; const listSnippet = // Browser — fetch the current customer s saved cards const res = await fetch( https://api.usethrottle.dev/api/v1/me/payment-methods , headers: X-Throttle-PM-Token : token , ); const data: cards = await res.json(); ; const listResponseSnippet = data : [ id : cpm_abc123 , methodType : card , cardBrand : visa , cardLastFour : 4242 , cardExpMonth : 12, cardExpYear : 2027, isDefault : true , id : cpm_def456 , methodType : card , cardBrand : mastercard , cardLastFour : 0001 , cardExpMonth : 8, cardExpYear : 2026, isDefault : false ] ; const setDefaultSnippet = // Browser — set a card as the default const res = await fetch( \\ https://api.usethrottle.dev/api/v1/me/payment-methods/\\$ cardId \\ , method: PATCH , headers: X-Throttle-PM-Token : token, Content-Type : application/json , , body: JSON.stringify( isDefault: true ), , ); const data: updatedCard = await res.json(); ; const removeSnippet = // Browser — remove a saved card const res = await fetch( \\ https://api.usethrottle.dev/api/v1/me/payment-methods/\\$ cardId \\ , method: DELETE , headers: X-Throttle-PM-Token : token , , ); const embedTokenSnippet = // Browser — get an add-only vault embed token so the buyer can add a new card const res = await fetch( https://api.usethrottle.dev/api/v1/me/payment-methods/embed-token , method: POST , headers: X-Throttle-PM-Token : token , ); const data: embedPayload = await res.json(); throttleEmbed.mount( #add-card-container , embedToken: embedPayload.embedToken, checkoutSessionId: embedPayload.checkoutSessionId, processorInstanceId: embedPayload.processorInstanceId, environment: embedPayload.environment, amount: embedPayload.amount, currency: embedPayload.currency, ); ; const embedTokenResponseSnippet = data : embedToken : eyJhbGci... , checkoutSessionId : cs_xyz , amount : 0, currency : USD , processorInstanceId : spider-store , merchantAccountId : acct_abc , environment : sandbox , hostedUrl : https://checkout.usethrottle.dev/... , embedUrl : https://embed.gr4vy.com/... ; const cannotRemoveDefaultSnippet = // 409 response when removing the default card while an active subscription exists error : code : cannot_remove_default , message : Add another card and make it the default before removing this one. , status : 409 ; const reactPackageBackendSnippet = // app/api/pm-token/route.ts (Next.js App Router) export async function POST(request: Request) const user = await auth(); if (!user) return new Response(null, status: 401 ); const customerId = await resolveThrottleCustomerId(user.id); // cus_* const token, expiresAt = await mintClientToken( apiKey: process.env.THROTTLE_SECRET_KEY!, customerId, ); return Response.json( token, expiresAt ); ; const reactPackageComponentSnippet = // components/Wallet.tsx use client ; const PaymentMethods = dynamic( () => import( @usethrottle/payment-methods ).then((m) => m.PaymentMethods), ssr: false , ); export function Wallet( clientToken : clientToken: string ) return ( console.log( cards updated , cards) /> ); ; export default function PaymentMethodsPage() return ( 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 hea"},{"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":"export const metadata: Metadata = title: Permissions , ; export default function PermissionsPage() return ( 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: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":"const apiClientSnippet = import OpenAPI, MerchantsService, ApiKeysService from @usethrottle/api-client ; OpenAPI.BASE = https://api.usethrottle.dev ; OpenAPI.HEADERS = X-API-Key : process.env.THROTTLE_API_KEY! ; const me = await MerchantsService.getApiV1MerchantsMe(); const keys = await ApiKeysService.getApiV1ApiKeys(); ; export const metadata: Metadata = title: Quickstart , ; const steps = [ title: Configure your application , body: In the dashboard, copy your Application ID from App Settings → General and add your storefront origin (e.g. http://localhost:3000) to Allowed origins. The embed refuses to render until both are set. , , title: Create an API key , body: On the API keys page, create a secret (sk_) key. The raw key is shown once — copy it and keep it on your backend only. , , title: Create a checkout session , body: Your backend uses @usethrottle/checkout-sdk/server to mint a session with amount, currency, and an optional cart reference. , , title: Render the React embed , body: Your storefront mounts PaymentEmbed from @usethrottle/checkout-react and handles the UI callbacks. , , title: Subscribe to webhooks , body: Your backend treats signed webhook events as the durable source of truth for fulfillment and order state. , , ]; export default function QuickstartPage() return ( 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, no wildcards. http://localhost and http://127.0.0.1 are accepted in every environment for local development. 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 it — e.g. sk_test_* or sk_live_* . 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","Hosted request forms","The buyer link","Accept a quote","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.","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).`;"],"text":"export const metadata: Metadata = title: Quotes , ; const createQuoteSnippet = curl -X POST https://api.usethrottle.dev/api/v1/quotes \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d customerEmail : buyer@example.com , buyerReference : PO-1042 , items : [ name : Widget (bulk) , unitPrice : 1500, quantity : 200 , name : Install service , unitPrice : 50000, quantity : 1, type : service , optional : true ], paymentTerms : mode : net_terms , netN : 30 , expiresAt : 2026-08-15T00:00:00Z ; const issueQuoteSnippet = # Freeze the working revision and propose it to the buyer. curl -X POST https://api.usethrottle.dev/api/v1/quotes/ quoteId /issue \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json -d # The response includes quote.shareToken — the buyer link is # https://checkout.usethrottle.dev/q/ shareToken (hosted page ships with the # public-link release). ; const requestFromCartSnippet = # Turn an existing cart into a quote request without re-keying anything. # The cart itself is unchanged. curl -X POST https://api.usethrottle.dev/api/v1/carts/ cartId /request-quote \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d buyerReference : PO-1042 , note : Need delivery by end of month ; const acceptBuyerSnippet = # The buyer accepts straight from the hosted link — no account, no API key. curl -X POST https://api.usethrottle.dev/api/v1/quote-links/ shareToken /accept \\\\ -H Content-Type: application/json \\\\ -d revisionId : the currently issued revision id , acceptedByName : Bea Buyer , acceptedByEmail : bea@example.com , itemSelections : [ itemId : optional line id , selected : false ] # Response: data : quote : ... , checkoutUrl : https://checkout.usethrottle.dev/s/... # Redirect the buyer to checkoutUrl to complete payment (card or Net-N, # depending on the quote s payment terms). ; const acceptRepSnippet = # A rep records a phone/email acceptance on the merchant s behalf. curl -X POST https://api.usethrottle.dev/api/v1/quotes/ quoteId /accept \\\\ -H X-API-Key: sk_test_... \\\\ -H Content-Type: application/json \\\\ -d revisionId : the currently issued revision id , acceptedByName : Bea Buyer , acceptedByEmail : bea@example.com , reason : Verbal agreement over the phone ; export default function QuotesPage() return ( 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. 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 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. 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/{"},{"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":"export const metadata: Metadata = title: React Package · @usethrottle/quotes , ; const installSnippet = pnpm add @usethrottle/quotes ; const quotePageSnippet = // app/q/[token]/page.tsx export default function QuotePage( params : params: token: string ) return ; ; const hookSnippet = import useQuote, formatMoney from @usethrottle/quotes ; function MyQuote( token : token: string ) const quote, total, selections, setQuantity, setSelected, accept, error = useQuote(token); if (!quote) return null; return ( <> quote.currentRevision?.items.map((item) => ( setQuantity(item.id, q) onToggle= (on) => setSelected(item.id, on) /> )) formatMoney(total, quote.currency) const checkoutUrl = await accept( acceptedByName: Ada Lovelace , acceptedByEmail: ada@example.com , ); window.location.href = checkoutUrl; > Accept & checkout ); ; const errorSnippet = import ThrottleQuotesError from @usethrottle/quotes ; try await accept( acceptedByName, acceptedByEmail ); catch (e) if (e instanceof ThrottleQuotesError) if (e.isExpired) return showExpiredNotice(); if (e.isSuperseded) return showRefreshedPricing(); console.error(e.code, e.statusCode, e.message); ; const rfqSnippet = // app/q/request/[formToken]/page.tsx export default function RequestPage( params : params: formToken: string ) return ; ; const proxySnippet = // app/api/throttle/[...path]/route.ts const handler = createQuoteProxyHandler(); export handler as GET, handler as POST ; ; const proxyUseSnippet = ; export default function QuotesReactPackagePage() return ( 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"],"text":"export const metadata: Metadata = title: Cart states , ; const chart = stateDiagram-v2 [*] --> open: POST /carts open --> open: mutations (items / shipping / discount / tax) open --> checkout: POST /carts/ id /checkout (creates draft order) checkout --> converted: order is paid (payment.captured) open --> abandoned: 24h idle (background sweep) checkout --> abandoned: 24h idle OR order cancelled before payment abandoned --> [*] converted --> [*] ; export default function CartStatesPage() return ( 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 (when configured): Throttle sends the customer.cart_abandoned email automatically when the cart has a known customer email and the application has a cartRecoveryUrlTemplate set. The template is a storefront URL containing a cartId placeholder, e.g. https://shop.example.com/cart?c= cartId . The email respects customer email opt-out / unsubscribe; in non-production environments it carries a [TEST] subject banner. 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":"export const metadata: Metadata = title: Error codes , ; const envelope = // Every Throttle 4xx / 5xx response follows this shape. error : code : cart_already_checked_out , // stable contract — use this message : Cart has already been converted to a draft order. , details : [ // optional, field-level field : cartId , code : conflict , message : Already checked out ] ; const rows: Array = [ [ cart_not_found , 404 , Cart id is invalid or belongs to another workspace. ], [ cart_not_open , 409 , A mutating cart operation targeted a cart no longer in status=open (converted by a completed order, or abandoned). In the cart SDK this surfaces as CartNotOpenError. , ], [ cart_already_checked_out , 409 , POST /carts/ id /checkout was called twice on the same cart. , ], [ cart_already_converted , 409 , Cart is in status=converted (linked order is paid). Read-only. , ], [ customer_not_found , 404 , Customer id is invalid or belongs to another workspace. ], [ application_not_found , 404 , Application UUID is invalid or belongs to another workspace. ], [ application_required , 400 , Endpoint requires X-Throttle-Application-Id header or applicationId in the body. , ], [ application_mismatch , 403 , API key is scoped to a different application than the request target. , ], [ application_key_workspace_route , 403 , API keys cannot access workspace-level routes (Clerk JWT required). , ], [ missing_application_id , 400 , POST /carts called without applicationId in the request body. , ], [ idempotency_key_reused , 409 , Same Idempotency-Key sent with a different request body within 24h. , ], [ invalid_origin , 400 , Origin in allowedOrigins isn t https:// or http://localhost: . , ], [ invalid_quote_token , 400 , pk_ token expired, scope-mismatched, or signed with a stale secret. , ], [ invalid_signature , 401 , Webhook signature failed verification (HMAC mismatch or stale timestamp). , ], [ embed_token_invalid , 401 , Provider embed JWT expired or was tampered with. Re-mint via the proxy endpoint. , ], [ embed_token_failed , 502 , The payment provider refused to mint an embed token (auth/permissions issue upstream). , ], [ permission_denied , 403 , Caller authenticated but lacks the required role / scope. ], [ workspace_access_denied , 403 , Caller is not a member of the workspace in :workspaceId. ], [ workspace_not_entitled_for_live , 402 , A production-environment credential was used without active subscription or partner status. , ], [ environment_mismatch , 403 , A credential or dashboard request targeted a resource from a different workspace environment. Use a key for the correct environment, or send the matching X-Throttle-Environment-Id. , ], [ environment_not_found , 404 , Requested workspace environment does not exist, is archived, or is not accessible to the workspace. , ], [ member_env_forbidden , 403 , Clerk-authenticated member tried to use an environment outside their environment grants. , ], [ environment_slug_taken , 409 , A workspace environment with that slug already exists. ], [ system_environment_locked , 400 , Production or another system environment cannot be archived. , ], [ validation_error , 400 , Request body failed zod validation. See details[] for field-level errors. , ], [ rate_limit_exceeded , 429 , Per-workspace rate limit hit. Retry-After header indicates the cooldown. , ], [ clerk_required , 403 , Endpoint requires a Clerk JWT (API key auth rejected). ], [ workspace_mismatch , 403 , Clerk JWT workspace does not match the workspace in the URL. ], [ no_file , 400 , Multipart upload route received a request without a file part. ], [ mime_not_allowed , 415 , Upload file type is outside the route’s ALLOWED_MIME set. ], [ logo_too_big , 413 , Logo upload exceeded the per-route byte cap. ], [ asset_too_big , 413 , Email asset upload exceeded the per-route byte cap. ], [ file_too_big , 413 , Digital-fulfillment upload exceeded the per-route byte cap. ], [ fulfillment_not_found , 404 , Digital-upload route’s :id is not a fulfillment in this application. , ], [ fulfillment_not_digital , 404 , Upload route targeted a non-digital fulfillment type. ], [ missing_token , 400 , Buyer download URL missing ?token= query param. ], [ invalid_token , 401 , Buyer download token failed JWT verification. ], [ token_mismatch , 401 , Buyer download token was minted for a different fulfillment. ], [ download_expired , 410 , Buyer download window has elapsed. ], [ download_limit_reached , 410 , Buyer download count has reached the configured limit for this delivery. , ], [ invalid_logo_url , 400 , logoUrl on /embed-config is not a valid https URL. ], [ invalid_primary_color , 400 , primaryColor on /embed-config is not a #rrggbb hex string. ], [ invalid_storefront_base_url , 400 , storefrontBaseUrl on /embed-config is not an absolute https URL. , ], [ image_url_unresolvable , 400 , A relative line-item imageUrl was sent but the application has no storefrontBaseUrl (or allowedOrigin) to resolve it against. , ], ]; export default function ErrorsPage() return ( 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 , 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 T"},{"title":"Fulfillment states","route":"/developers/reference/fulfillment-states","headings":["Fulfillment states","State machine","States","When the order is considered fulfilled"],"text":"export const metadata: Metadata = title: Fulfillment states , ; const chart = stateDiagram-v2 [*] --> pending: POST /fulfillments pending --> in_progress: shipment label created / digital prepared / access pending in_progress --> fulfilled: shipped+delivered / file delivered / access granted / service complete / in-person handoff pending --> cancelled: merchant cancels before in_progress in_progress --> cancelled: merchant cancels mid-fulfillment cancelled --> [*] fulfilled --> [*] ; export default function FulfillmentStatesPage() return ( 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.shipped for shipment type. 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. When the order is considered fulfilled The parent order transitions to fulfilled only when every attached fulfillment row reaches fulfilled . A mix of fulfilled + cancelled rows means the order moves to fulfilled as long as at least one row was fulfilled and no rows remain in non-terminal states. );"},{"title":"Order states","route":"/developers/reference/order-states","headings":["Order states","State machine","States","Outbound events"],"text":"export const metadata: Metadata = title: Order states , ; const chart = stateDiagram-v2 [*] --> draft: cart.checkout draft --> pending: checkout-session created pending --> paid: payment.captured pending --> cancelled: buyer abandons or merchant cancels paid --> fulfilled: all fulfillments delivered paid --> partially_refunded: payment.refunded (partial) paid --> refunded: payment.refunded (full) fulfilled --> [*] refunded --> [*] partially_refunded --> [*] cancelled --> [*] ; export default function OrderStatesPage() return ( State reference Order states An order s status drives whether you can capture payment, fulfill items, or refund. Each transition emits an outbound webhook. State machine States draft — created via POST /carts/ id /checkout (or directly via the orders API). No payment attached. pending — a checkout session has been minted against the order. Payment is in flight (PaymentEmbed mounted on the buyer s storefront). paid — payment was captured. Fulfillments can now be created. fulfilled — every fulfillment row attached to this order reached its terminal state (shipped/delivered for shipment, granted for access, delivered for digital, etc.). partially_refunded / refunded — a refund event was processed against the linked payment. Partial refunds remain mutable for further refunds up to the original captured amount. cancelled — terminal. Reached from pending before payment. Outbound events order.created on entering draft . order.updated on every status transition. order.cancelled on entering cancelled . order.fulfilled on entering fulfilled . order.refunded on entering refunded / partially_refunded . );"},{"title":"Payment states","route":"/developers/reference/payment-states","headings":["Payment states","State machine","States","Net30 special cases","Disputes & chargebacks"],"text":"export const metadata: Metadata = title: Payment states , ; const chart = stateDiagram-v2 [*] --> pending: POST /payments or buyer click pending --> authorized: Provider/Net30 authorizes pending --> failed: provider declines authorized --> captured: capture call authorized --> voided: void before capture captured --> refunded: full refund captured --> partially_refunded: partial refund partially_refunded --> refunded: subsequent refunds total to full refunded --> [*] voided --> [*] failed --> [*] ; export default function PaymentStatesPage() return ( 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. 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. voided — authorisation released before capture. Funds never moved. 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. 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","Scope catalog","{group}","Migration from coarse scopes","Machine-readable catalog"],"text":"export const metadata: Metadata = title: API key scopes , ; function flagBadges(def: ScopeDef) const badges: label: string; tone: string; title: string [] = []; if (def.sensitive) badges.push( label: ⚠ sensitive , tone: warning , title: Financial, destructive, or PII access — shown with a warning in the picker. , ); if (def.staffOnly) badges.push( label: staff only , tone: danger , title: Reachable only by Throttle Staff via Clerk; not grantable to workspace keys. , ); if (def.publishableAllowed) badges.push( label: pk_ ok , tone: ok , title: May be held by a publishable (pk_) browser-facing key. , ); return badges; export default function ScopesPage() const groupNames = Object.keys(SCOPE_GROUPS); return ( 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 stateless compute scopes only — shipping_quotes:write and tax_calculations:write — and can never read carts, orders, customers, payment methods, or PII. (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): the two stateless compute scopes shipping_quotes:write and tax_calculations:write . 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 . Scope catalog Every scope, grouped as it appears in the dashboard picker. A scope marked ⚠ sensitive triggers a picker warning (financial, destructive, or PII); pk_ ok means a publishable key may hold it; staff only means it is unreachable by any workspace key. Unless a row lists both, the resource exposes only the action shown. groupNames.map((group) => ( group Scope Label Flags SCOPE_GROUPS[group].map((id) => const def = SCOPE_REGISTRY[id]; const badges = flagBadges(def); return ( id def.label badges.length === 0 ? ( — ) : ( badges.map((b) => ( b.label )) ) ); ) )) Migration from coarse scopes Throttle previously used ~27 coarse scopes (for example a single payments:write ). Those were replaced with the granular catalog above. Existing keys keep working unchanged: Each legacy scope expands to the superset of granular scopes it used to cover — so a key with the old payments:write now holds payment_authorizations:write , payment_captures:write , payment_refunds:write , and the rest of the payment family. No power is lost. stores:read and stores:write are deprecated aliases for applications:read / applications:write , still accepted on input and expanded at evaluation time. New keys should request applications:read / applications:write directly. There is no endpoint to edit the scopes on an existing API key. To change a key's scopes, revoke it and create a new one with the scopes you want. (Extension manifest scopes, by contrast, are editable via PATCH /api/v1/extensions/ id .) Machine-readable catalog GET /api/v1/scopes returns the full catalog as JSON — every scope definition ( id , resource , action , group , label , sensitive , staffOnly , publishableAllowed , extensionAllowed , implies ) plus the group ordering. Build a scope picker or validate requested scopes against it rather than hard-coding the list. data : scopes : [ id : payment_refunds:write , resource : payment_refunds , action : write , group : Payments , label : Refund payments , sensitive : true, staffOnly : false, publishableAllowed : false, extensionAllowed : true, implies : [ payment_refunds:read ] ], groups : Payments : [ payments:read , payments:write , ...], ... );"},{"title":"Subscriptions","route":"/developers/subscriptions","headings":["Subscriptions","What ships in v1","What is intentionally out of v1","How the pieces fit","Sequence: auto-subscribe on card capture","Pages in this section"],"text":"export const metadata: Metadata = title: Subscriptions , ; const autoSubscribeChart = sequenceDiagram participant B as Buyer participant F as Throttle Iframe participant P as Payment Provider participant T as Throttle Backend participant H as Webhook Endpoint B->>F: fills card details F->>P: authorize + tokenize P-->>F: paymentMethodId F->>T: POST /complete (recurring + create:auto) T->>P: capture + vault payment method P-->>T: vaulted token T->>T: checkTrialEligibility(card fingerprint) alt eligible for trial T->>T: set trialEnd = now + trialDays else fingerprint already used T->>H: subscription.trial_blocked end T->>T: subscriptionService.create(...) T-->>F: orderId, paymentId, subscriptionId T->>H: subscription.created F->>B: throttle.completed ; export default function SubscriptionsOverviewPage() return ( 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. React management package. @usethrottle/subscriptions exposes hooks and primitive components so your buyer-facing portal does not have to re-derive the API plumbing. Built-in dunning. Hardcoded retry curve of [1, 3, 7] days; 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 . 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. Mid-period proration. PATCH /api/v1/subscriptions/:id swaps the plan/amount; the new value applies on the next renewal. No proration credit is issued for the current period. Recurring discounts. Promotion codes apply to cart-backed checkout totals today. Ongoing subscription-cycle discounts are deferred. Configurable dunning curve. The retry schedule is fixed at [1, 3, 7] days. 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 [1, 3, 7] 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 . 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 section Quickstart — five-minute end-to-end. Creating Subscriptions — auto, manual, and webhook patterns. Customer Identity — externalCustomerId pattern + alternates. Lifecycle and States — state machine, trials, dunning. Trial Fraud Protection — card-fingerprint check + eligibility-check API. Testing — sandbox smoke tests and webhook assertions. Managing Subscriptions — pause, resume, cancel, change plan. Buyer Portal — the proxy pattern with templates. React Package — @usethrottle/subscriptions reference. Subscription Webhooks — event types and payloads. Usage Reporting — metered usage via POST/GET .../usage ; billed at renewal. );"},{"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":"export const metadata: Metadata = title: Buyer Portal , ; const nextRouteHandlerSnippet = // app/api/throttle/[...path]/route.ts const handler = createSubscriptionProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getExternalCustomerId() const user = await auth(); return user?.id ?? null; , ); export handler as GET, handler as POST, handler as PATCH ; ; const expressSnippet = // MERCHANT BACKEND — Express variant. const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); app.get( /api/throttle/api/v1/subscriptions , requireAuth, async (req, res) => const result = await subscriptions.list( externalCustomerId: req.user.id, status: req.query.status, ); res.json(result); ); app.post( /api/throttle/api/v1/subscriptions/:id/cancel , requireAuth, async (req, res) => if (!(await assertUserOwnsSub(req.user.id, req.params.id))) return res.sendStatus(403); const sub = await subscriptions.cancel(req.params.id, atPeriodEnd: req.body?.atPeriodEnd ?? true, ); res.json(sub); ); const ownershipSnippet = // Verify the buyer owns the subscription before mutating. async function assertUserOwnsSub(userId: string, subId: string) const sub = await subscriptions.get(subId); if (!sub) return false; const customer = await subscriptions.getCustomerByExternalId(userId); return customer?.id === sub.customerId; ; const providerWiringSnippet = // Mount the React provider with a fetcher that hits your proxy. use client ; export function ThrottleProvider( children : children: React.ReactNode ) return ( return fetch(\\ /api/throttle\\$ path \\ , init); > children ); ; export default function BuyerPortalPage() return ( 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. 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)","Next"],"text":"export const metadata: Metadata = title: Creating Subscriptions , ; const autoSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const session = await subscriptions.createCheckoutSession( applicationId, externalCartId, returnUrl, cancelUrl, customer: externalCustomerId: user.id, email: user.email , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , ); router.push(\\ /account/\\$ subscriptionId \\ ) />; ; const manualEmbedSnippet = // Set create: manual to opt out of auto-create. const session = await subscriptions.createCheckoutSession( recurring: plan: pro_monthly , interval: monthly , amount: 2999, create: manual , ); await fetch( /api/subscribe , method: POST , body: JSON.stringify( orderId, paymentId, plan: pro_monthly ), ); />; ; const manualBackendSnippet = // MERCHANT BACKEND — call subscriptions.create yourself const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const userId, plan = await req.json(); const now = new Date(); const periodEnd = new Date(now); periodEnd.setMonth(periodEnd.getMonth() + 1); const sub = await subscriptions.create( externalCustomerId: userId, planReference: plan, interval: monthly , amount: 2999, currentPeriodStart: now.toISOString(), currentPeriodEnd: periodEnd.toISOString(), metadata: source: manual_post_checkout , ); await db.userSubscriptions.create( userId, subId: sub.id ); return Response.json( subscriptionId: sub.id ); ; const webhookSnippet = // MERCHANT BACKEND — webhook-driven creation. const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (event.type !== payment.vaulted ) return Response.json( ok: true ); const customerId, paymentMethodId, recurring = event.data; if (!recurring) return Response.json( ok: true ); // not a subscription intent const existing = await subscriptions.list( customerId, status: active ); if (existing.data.some((s) => s.metadata?.checkoutSessionId === event.data.sessionId)) return Response.json( ok: true ); await subscriptions.create( customerId, planReference: recurring.plan, interval: recurring.interval, amount: recurring.amount, currentPeriodStart: new Date().toISOString(), currentPeriodEnd: addInterval(new Date(), recurring.interval).toISOString(), metadata: checkoutSessionId: event.data.sessionId , ); return Response.json( ok: true ); ; export default function CreatingSubscriptionsPage() return ( 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 Direct 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. 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":"export const metadata: Metadata = title: Customer Identity , ; const patternASnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const session = await subscriptions.createCheckoutSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, externalCartId: \\ sub-\\$ user.id -\\$ Date.now() \\ , returnUrl: https://shop.example.com/account , cancelUrl: https://shop.example.com/pricing , customer: externalCustomerId: user.id, email: user.email , recurring: plan: pro_monthly , interval: monthly , amount: 2999 , ); const subs = await subscriptions.list( externalCustomerId: user.id ); const customer = await subscriptions.getCustomerByExternalId(user.id); const patternBSnippet = // Persist Throttle s customerId in your user table. const customer = await subscriptions.getCustomerByExternalId(user.id); if (!customer) throw new Error( Throttle customer was not created yet ); await db.users.update( where: id: user.id , data: throttleCustomerId: customer.id , ); const subs = await subscriptions.list( customerId: user.throttleCustomerId ); ; const webhookSyncSnippet = // MERCHANT WEBHOOK — keep your DB in sync via customer.created const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (event.type === customer.created ) const id, externalId, email = event.data; if (externalId) await db.users.updateMany( where: id: externalId , data: throttleCustomerId: id , ); return Response.json( ok: true ); ; export default function CustomerIdentityPage() return ( 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","States","State diagram","Trial behavior","Renewal","Dunning","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","Next"],"text":"export const metadata: Metadata = title: Lifecycle and States , ; const stateMachineDiagram = stateDiagram-v2 [*] --> trialing: create with trialEnd [*] --> active: create without trial trialing --> active: trialEnd cron tick trialing --> cancelled: cancel() active --> paused: pause() paused --> active: resume() paused --> cancelled: cancel() active --> active: renewal succeeds / period advances active --> active: renewal fails 1-2 times active --> past_due: third renewal failure active --> cancelled: cancel() past_due --> active: payment recovers past_due --> cancelled: next failure / dunning exhausted past_due --> cancelled: cancel() cancelled --> [*] ; const renewalSnippet = // Renewal job shape. for (const sub of dueSubscriptions) if (sub.status === trialing && sub.trialEnd = sub.currentPeriodStart) continue; // idempotency guard const result = await paymentProvider.chargeStored( paymentMethodId: vault.processorToken, buyerId: vault.providerBuyerId, amount: sub.amount, idempotencyKey: \\ \\$ sub.id :\\$ sub.currentPeriodStart.toISOString() \\ , ); if (result.success) await subscriptionService.renew(sub, newPeriodStart, newPeriodEnd); else await subscriptionService.recordPaymentFailure(sub.id); ; export default function LifecycleAndStatesPage() return ( 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 supports four intervals out of the box: weekly , monthly , quarterly , and yearly . The interval is set on the subscription at create time (via the recurring block on the checkout session, or directly on POST /api/v1/subscriptions ) and drives the period math the renewal cron uses. Period advancement uses calendar arithmetic with day-of-month clamping: a Jan 31 subscription rolls to Feb 28 (or 29 in leap years) on a monthly interval, then back to Mar 31 the next cycle. No drift over multi-year subscriptions. 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 interval on an existing subscription (e.g., monthly → yearly) via PATCH /api/v1/subscriptions/:id . The new interval applies on the next renewal — no proration credit is issued for the unused portion of the current period. 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 — Three or more renewal attempts have failed. Still being retried, but no further attempts will succeed without a new card. cancelled — Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop. State diagram trialing , trialEnd cron tick , active , Activates the subscription and emits subscription.activated without charging. , ], [ active , renewal succeeds , active , Advances the current period and emits subscription.renewed. , ], [ active , first or second renewal failure , active , Records the failure, schedules the next retry, and emits subscription.payment_failed. , ], [ active , third renewal failure , past_due , Marks the subscription past_due and emits subscription.past_due. , ], [ past_due , next renewal failure , cancelled , Cancels with reason dunning_exhausted and emits subscription.cancelled. , ], [ Any non-terminal state , cancel() , cancelled , Stops future renewals and emits subscription.cancelled. , ], ] /> 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 against prior subscriptions; on a match the new subscription is created without a trial and a subscription.trial_blocked webhook fires. There's also a pre-flight POST /api/v1/subscriptions/eligibility-check endpoint for explicit gating. See Trial Fraud Protection for the full guide, including what this does NOT cover (different cards, disposable emails, IP velocity). Renewal A cron runs every 5 minutes selecting subscriptions whose currentPeriodEnd has passed. For each one it calls the provider's chargeStored with an idempotency key derived from the subscription ID and the period start, then advances the period on success. Two guards prevent double-billing: a DB check ( lastPaymentAt >= currentPeriodStart ) and the provider-level idempotency key. Either alone would not be enough. Dunning When a renewal charge fails, the cron records the failure and schedules the next attempt. The schedule is fixed: past_due , Send the final-warning notice and surface update-card UI. , ], [ 4th+ , No further schedule , cancelled , De-provision or downgrade according to your offboarding policy. , ], ] /> On every failed attempt, Throttle emits subscription.payment_failed . The first time a subscription transitions to past_due , it additionally emits subscription.past_due . When dunning is exhausted, it transitions to cancelled with reason: dunning_exhausted . Throttle does not send emails to buyers. Subscribe to subscription.payment_failed and use y"},{"title":"Managing Subscriptions","route":"/developers/subscriptions/managing-subscriptions","headings":["Managing Subscriptions","List and filter","Cancel","Immediate cancellation","Cancel at period end","Pause and resume","Change plan","Immediate upgrade","Scheduled downgrade","Cancelling a pending change","Seats and quantity","From the React package","Audit log","Next"],"text":"export const metadata: Metadata = title: Managing Subscriptions , ; const cancelImmediateSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); await subscriptions.cancel( sub_xyz , atPeriodEnd: false ); const cancelAtPeriodEndSnippet = // Cancel at the end of the current period. await subscriptions.cancel( sub_xyz , atPeriodEnd: true ); const pauseResumeSnippet = // Pause an active subscription. Renewal cron skips it. await subscriptions.pause( sub_xyz ); await subscriptions.resume( sub_xyz ); ; const upgradeNowSnippet = // Immediate upgrade: prorated charge now, resets the billing period. const result = await fetch( https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/change-plan , method: POST , headers: x-api-key : process.env.THROTTLE_SECRET_KEY!, content-type : application/json , body: JSON.stringify( planReference: pro_yearly , planName: Pro Yearly , interval: yearly , amount: 29900, effective: now , // sub.status === active && ( pause.mutate( id: sub.id ) >Pause ) sub.status === paused && ( resume.mutate( id: sub.id ) >Resume ) cancel.mutate( id: sub.id, atPeriodEnd: true ) > Cancel at period end change.mutate( id: sub.id, planReference: pro_yearly , interval: yearly , amount: 29900 ) > Switch to yearly ); ; export default function ManagingSubscriptionsPage() return ( 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. You may want to combine this with a prorated refund (handled outside Throttle s subscription engine). 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. 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 ; the period continues from where it was paused. Guards: pause() requires status active . Throws otherwise. resume() requires status paused . Throws otherwise. Change plan Use POST /api/v1/subscriptions/:id/change-plan for all mid-cycle plan changes. The behavior depends on the effective field. 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. No charge is made now. The four pending* fields ( pendingPlanReference , pendingPlanName , pendingInterval , pendingAmount ) are written, and the renewal cron applies the change on the next period end. Cancelling a pending change If the buyer changes their mind about a scheduled downgrade, use DELETE /api/v1/subscriptions/:id/pending-change to clear the pending fields and keep the current plan. Calling this when no change is pending is a safe no-op. If the subscription already has cancelAtPeriodEnd: true , the cancellation takes precedence and the pending plan change will never apply. Clear the cancel flag first ( PATCH with cancelAtPeriodEnd: false ), then schedule the plan change. Seats and quantity A subscription carries a quantity (default 1). The amount is the per-seat price, so the amount billed each period is amount × quantity (the renewal invoice line item carries the seat count). Set quantity when you create the subscription, and change it mid-cycle with POST /api/v1/subscriptions/:id/change-quantity . Like a plan change, the seat change is either effective: "now" or effective: "period_end" : Immediate — prorated exactly like an immediate plan change: it credits the unused time on the old total ( amount × oldQuantity ) and charges the net of the new total ( amount × newQuantity ), then resets the billing period. Removing seats where the credit exceeds the new total issues no charge. The response carries the same proration object. Period end — writes pendingQuantity ; the renewal cron applies it at the next period boundary and bills the new seat count. No charge now. From the React package The hooks invalidate the right cache entries on success — your useSubscription(id) and useSubscriptions() data update without a manual refetch. Audit log Every state change is recorded in the audit log with the actor (API key, user, or system) and the change set. View it in the dashboard under Customers → Subscriptions → Activity . Next Buyer Portal — let buyers do this themselves. Subscript"},{"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":"export const metadata: Metadata = title: Subscriptions Quickstart , ; const steps = [ title: Create a checkout session with a recurring block , body: Your backend POSTs to /api/v1/checkout/sessions. The recurring block tells Throttle to vault the card and seed a subscription on payment success. Pass externalCustomerId so you can reconcile the subscription to your customer record. , , title: Render the embed with the recurring prop , body: handles the buyer-facing card form. Card collection, 3DS, and vault all happen inside the iframe — your storefront does not see card data. , , title: Read the success payload , body: onSucceeded fires with subscriptionId, customerId, paymentMethodId . Persist customerId to your user table if you want lookups by Throttle ID, or skip it and continue using your own ID via externalCustomerId. , , title: Subscribe to the webhook stream , body: Listen for subscription.created, subscription.renewed, subscription.past_due, and subscription.payment_failed. Your DB stays in sync without polling. , , ]; const sessionSnippet = // MERCHANT BACKEND (e.g. /api/checkout/start) const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); export async function POST(req: Request) const user, planId = await req.json(); const session = await subscriptions.createCheckoutSession( applicationId: process.env.THROTTLE_APPLICATION_ID!, cartId: undefined, // skip cart for plan-based subs externalCartId: \\ sub-\\$ user.id -\\$ Date.now() \\ , customer: externalCustomerId: user.id, // your own user ID email: user.email, firstName: user.firstName, lastName: user.lastName, , returnUrl: https://yourstore.com/account/subscriptions , cancelUrl: https://yourstore.com/pricing , recurring: plan: planId, // your own plan reference interval: monthly , amount: 2999, // cents; defaults to cart total if omitted trialDays: 14, // optional create: auto , // default — Throttle creates the sub , ); return Response.json( sessionId: session.sessionId ); ; const embedSnippet = // MERCHANT FRONTEND use client ; export function SubscribeForm( sessionId : sessionId: string ) const router = useRouter(); return ( router.push(\\ /account/subscriptions/\\$ result.subscriptionId \\ ); onFailed= (err) => console.error( Subscribe failed: , err) /> ); ; const webhookSnippet = // MERCHANT BACKEND (/api/webhooks/throttle) export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); switch (event.type) case subscription.created : break; case subscription.payment_failed : break; case subscription.cancelled : break; case subscription.renewed : break; return Response.json( ok: true ); ; export default function SubscriptionsQuickstartPage() return ( 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. 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()","Primitive components","Boundaries"],"text":"export const metadata: Metadata = title: React Package , ; const installSnippet = npm install @usethrottle/subscriptions @usethrottle/checkout-react ; const nextProxySnippet = // app/api/throttle/[...path]/route.ts const handler = createSubscriptionProxyHandler( apiKey: process.env.THROTTLE_SECRET_KEY!, async getExternalCustomerId() const user = await auth(); return user?.id ?? null; , ); export handler as GET, handler as POST, handler as PATCH ; ; const providerSnippet = use client ; export function Providers( children : children: React.ReactNode ) return ( fetch(\\ /api/throttle\\$ path \\ , init) > children ); ; const serverClientSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); await subscriptions.cancel( sub_123 , atPeriodEnd: true ); ; const serverPaymentMethodsSnippet = import createSubscriptionsClient from @usethrottle/subscriptions/server ; const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const paymentMethods = await subscriptions.listCustomerPaymentMethods( cus_123 ); ; const useSubscriptionsSnippet = import useSubscriptions from @usethrottle/subscriptions ; function MySubs() const data, isLoading, error = useSubscriptions( externalCustomerId: user_42 , // or customerId: cus_xyz status: active , // optional ); if (isLoading) return ; if (error) return ; return data.data.map((sub) => ); ; const useSubscriptionSnippet = import useSubscription from @usethrottle/subscriptions ; function SubDetail( id : id: string ) const data: sub, isLoading = useSubscription(id); if (isLoading || !sub) return ; return ; ; const usePaymentMethodsSnippet = import useCustomerPaymentMethods from @usethrottle/subscriptions ; function SavedCards( customerId : customerId: string ) const data: methods, isLoading = useCustomerPaymentMethods(customerId); if (isLoading) return ; return methods?.map((method) => ( method.cardBrand ending in method.cardLastFour )); ; const useCancelSnippet = import useCancelSubscription from @usethrottle/subscriptions ; function CancelButton( subId : subId: string ) const cancel = useCancelSubscription(); return ( cancel.mutate( id: subId, atPeriodEnd: true ) > cancel.isPending ? Cancelling… : Cancel at period end ); ; const usePauseResumeSnippet = import usePauseSubscription, useResumeSubscription from @usethrottle/subscriptions ; function PauseToggle( sub ) const pause = usePauseSubscription(); const resume = useResumeSubscription(); return sub.status === paused ? resume.mutate( id: sub.id ) >Resume : pause.mutate( id: sub.id ) >Pause ; ; const useChangePlanSnippet = import useChangePlan from @usethrottle/subscriptions ; function PlanSwitcher( subId : subId: string ) const change = useChangePlan(); return ( change.mutate( id: subId, planReference: pro_yearly , interval: yearly , amount: 29900, ) > Switch to yearly ); ; const useCreateCheckoutSessionSnippet = import useCreateCheckoutSession from @usethrottle/subscriptions ; function SubscribeFlow( user, plan ) const create = useCreateCheckoutSession(); if (!create.data) return ( create.mutate( applicationId: 00000000-0000-0000-0000-000000000000 , externalCartId: \\ sub-\\$ user.id -\\$ plan \\ , customer: externalCustomerId: user.id, email: user.email , returnUrl: https://shop.example.com/account , cancelUrl: https://shop.example.com/pricing , recurring: plan, interval: monthly , amount: 2999 , ) > Subscribe ); return ( router.push(\\ /account/\\$ subscriptionId \\ ) /> ); ; const trialEligibilitySnippet = import useTrialEligibility from @usethrottle/subscriptions ; function TrialGate( paymentMethodId : paymentMethodId: string ) const check = useTrialEligibility(); return ( check.mutate( paymentMethodId ) > Check trial eligibility ); ; const componentsSnippet = import SubscriptionStatusBadge, TrialCountdown, DunningBanner, from @usethrottle/subscriptions ; function SubCard( subscription: sub ) return ( sub.planName sub.status === trialing && sub.trialEnd && ( ) sub.status === past_due && ( openUpdateCardFlow(sub) /> ) Next charge: sub.currentPeriodEnd ); ; export default function ReactPackagePage() return ( 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 , 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 authentic"},{"title":"Testing","route":"/developers/subscriptions/testing","headings":["Testing","Sandbox checklist","Manual smoke test","Trial-abuse replay","Webhook assertions","Automated test boundaries","Common gotchas","Next"],"text":"export const metadata: Metadata = title: Testing Subscriptions , ; const sandboxConfig = THROTTLE_API_URL=https://api.usethrottle.dev THROTTLE_CHECKOUT_ORIGIN=https://checkout.usethrottle.dev THROTTLE_SECRET_KEY=sk_uat_... THROTTLE_APPLICATION_ID=e7efb0a6-892e-46b2-97ab-296bb04c5b29 THROTTLE_WEBHOOK_SECRET=whsec_... ; const subscriptionIntentSnippet = curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \\\\ -H x-api-key: sk_uat_... \\\\ -H content-type: application/json \\\\ -d applicationId : e7efb0a6-892e-46b2-97ab-296bb04c5b29 , externalCartId : sub-test-001 , returnUrl : https://staging.shop.example.com/account , cancelUrl : https://staging.shop.example.com/pricing , customer : externalCustomerId : user_test_001 , email : buyer@example.com , recurring : plan : pro_monthly , planName : Pro Monthly , interval : monthly , amount : 2999, trialDays : 14 , metadata : testRunId : sub-flow-2026-05-05 ; const webhookHandlerSnippet = import verifyThrottleWebhook from @/lib/throttle/webhooks ; export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); if (await db.webhookEvents.exists(event.id)) return Response.json( ok: true ); await db.webhookEvents.insert( id: event.id, type: event.type, receivedAt: new Date(), ); if (event.type === subscription.created ) await provisionAccess(event.data.subscription); if (event.type === subscription.cancelled ) await revokeAccess(event.data.subscription); return Response.json( ok: true ); ; export default function TestingSubscriptionsPage() return ( 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. Receive 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 , subscription.activated , ], [ Renewal succeeds , subscription.renewed , ], [ Renewal fails , <> subscription.payment_failed , then subscription.past_due when the threshold is crossed. , ], [ Cancellation , <> 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. 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":"export const metadata: Metadata = title: Trial Fraud Protection , ; const eligibilityCheckSnippet = // MERCHANT BACKEND const subscriptions = createSubscriptionsClient( apiKey: process.env.THROTTLE_SECRET_KEY!, ); const result = await subscriptions.checkTrialEligibility( paymentMethodId: pmRowId, ); if (!result.eligible) return showNoTrialOption( reason: result.reason ); return showTrialOption(); ; const responseShape = data : eligible : true | false, reason : card_already_used_for_trial | payment_method_not_found | no_fingerprint_available | undefined // when eligible:true with no caveat ; const trialBlockedWebhookSnippet = // MERCHANT WEBHOOK case subscription.trial_blocked : await notifyTrialDeclined(event.data); break; ; export default function TrialFraudProtectionPage() return ( 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":"export const metadata: Metadata = title: Usage Reporting , ; const reportUsageSnippet = // Report a metered usage item for the current billing period. const res = await fetch( \\ https://api.usethrottle.dev/api/v1/subscriptions/\\$ subscriptionId /usage\\ , method: POST , headers: X-API-Key : process.env.THROTTLE_SECRET_KEY, Content-Type : application/json , , body: JSON.stringify( kind: api_calls , unitCount: 1500, unitPriceCents: 2, // $0.02 per call ), , ); const data = await res.json(); const listUsageSnippet = // Retrieve all usage items for the current billing period. const res = await fetch( \\ https://api.usethrottle.dev/api/v1/subscriptions/\\$ subscriptionId /usage\\ , headers: X-API-Key : process.env.THROTTLE_SECRET_KEY, , , ); const items = await res.json(); const multiKindSnippet = // Report multiple usage kinds in the same period. await reportUsage(subscriptionId, kind: api_calls , unitCount: 1500, unitPriceCents: 2 ); await reportUsage(subscriptionId, kind: storage_gb , unitCount: 12, unitPriceCents: 50 ); await reportUsage(subscriptionId, kind: api_calls , unitCount: 1800, unitPriceCents: 2 ); export default function UsageReportingPage() return ( 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. 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)","Reference handler","Delivery semantics","Common pitfalls"],"text":"export const metadata: Metadata = title: Subscription Webhooks , ; const handlerSnippet = // app/api/webhooks/throttle/route.ts export async function POST(req: Request) const rawBody = await req.text(); const signature = req.headers.get( x-throttle-signature ); if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) return new Response( Invalid signature , status: 400 ); const event = JSON.parse(rawBody); switch (event.type) case subscription.created : await provisionAccess(event.data.subscription); break; case subscription.activated : break; case subscription.renewed : break; case subscription.payment_failed : await sendDunningEmail(event.data.subscription, event.data.attempt); break; case subscription.past_due : break; case subscription.paused : case subscription.resumed : case subscription.updated : await invalidateLocalCache(event.data.subscription.id); break; case subscription.plan_change_scheduled : await notifyBuyerOfScheduledChange(event.data.pending, event.data.effectiveAt); break; case subscription.plan_changed : await updateBuyerPlanAccess(event.data.subscription, event.data.previous); break; case subscription.cancelled : await deprovisionAccess(event.data.subscription, event.data.reason); break; return Response.json( ok: true ); ; const payloadSnippet = id : evt_01HF8... , type : subscription.renewed , workspaceId : merch_xyz , createdAt : 2026-05-03T12:00:00Z , data : subscription : id : sub_abc , customerId : cus_xyz , status : active , planReference : pro_monthly , planName : Pro Monthly , interval : monthly , amount : 2999, currency : USD , currentPeriodStart : 2026-05-03T12:00:00Z , currentPeriodEnd : 2026-06-03T12:00:00Z , trialEnd : null, failureCount : 0, cancelAtPeriodEnd : false, pendingPlanReference : null, pendingPlanName : null, pendingInterval : null, pendingAmount : null, metadata : , customer : id : cus_xyz , email : buyer@example.com , firstName : Ada , lastName : Lovelace , phone : null, externalId : your-user-42 , externalCustomerId : null ; const planChangeScheduledPayloadSnippet = // subscription.plan_change_scheduled — payload when a downgrade is deferred id : evt_02AB9... , type : subscription.plan_change_scheduled , workspaceId : merch_xyz , createdAt : 2026-06-01T10:00:00Z , data : subscription : id : sub_abc , status : active , planReference : pro_monthly , interval : monthly , amount : 2999, currentPeriodEnd : 2026-06-03T12:00:00Z , pending : planReference : starter_monthly , planName : Starter (Monthly) , interval : monthly , amount : 999 , effectiveAt : 2026-06-03T12:00:00Z ; const planChangedPayloadSnippet = // subscription.plan_changed — payload after upgrade or deferred downgrade applies id : evt_03CD0... , type : subscription.plan_changed , workspaceId : merch_xyz , createdAt : 2026-06-03T12:00:00Z , data : subscription : id : sub_abc , status : active , planReference : starter_monthly , planName : Starter (Monthly) , interval : monthly , amount : 999, currentPeriodStart : 2026-06-03T12:00:00Z , currentPeriodEnd : 2026-07-03T12:00:00Z , previous : planReference : pro_monthly , amount : 2999 ; export default function SubscriptionWebhooksPage() return ( 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 subscription.created , A new subscription row is inserted (auto from checkout, or via API). , Provision access. Send welcome email. , ], [ subscription.activated , <> A trialing subscription flips to active at trialEnd . , Optional your trial ended, you are now subscribed email. , ], [ subscription.renewed , A renewal charge succeeded and the period advanced. , Ledger entry. Receipt email (optional). , ], [ subscription.payment_failed , <> Every failed renewal attempt. Includes attempt , nextRetryAt , and lastError in the payload. , Payment-failed email. First failure is the most important. , ], [ subscription.past_due , <> The first time a subscription transitions to past_due (after 3 failures). , Final-warning email with update-card link. , ], [ subscription.updated , <> Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change . , Confirmation email if buyer-initiated. , ], [ subscription.plan_change_scheduled , <> 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. , <> 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. , ], [ subscription.plan_changed , <> 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. , ], [ subscription.paused , <> Subscription transitioned to paused . , Suspend feature access (or leave on, depending on your model). , ], [ subscription.resumed , <> Subscription transitioned back to active . , Re-enable access. , ], [ subscription.cancelled , <> Terminal cancellation. data.reason is one of merchant_action , dunning_exhausted , or period_end . , De-provision. Send s"},{"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":"export const metadata: Metadata = title: Team Management , ; export default function TeamManagementPage() return ( 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 rol"},{"title":"Workspace Environments","route":"/developers/test-mode","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":"export const metadata: Metadata = title: Workspace Environments , ; const keyExample = # API keys include the workspace environment slug. # Production is immutable; non-production environments can be named for your flow. THROTTLE_API_KEY=sk_uat_3e2e04df... # non-production secret key THROTTLE_PUBLISHABLE_KEY=pk_uat_290b29b1... # non-production publishable key # Production: THROTTLE_API_KEY=sk_production_fbc6cab6... THROTTLE_PUBLISHABLE_KEY=pk_production_06646787... ; const environmentHeaderExample = // Dashboard and Clerk-authenticated setup calls select an environment explicitly. await fetch( https://api.usethrottle.dev/api/v1/api-keys , method: POST , headers: Authorization: Bearer , X-Throttle-Application-Id : , X-Throttle-Environment-Id : , content-type : application/json , , body: JSON.stringify( name: UAT backend , scopes: [ orders:read , payments:write ], ), ); ; const environmentManagementExample = # List active environments curl https://api.usethrottle.dev/api/v1/workspaces/ /environments \\\\ -H Authorization: Bearer # Include archived environments for settings/audit screens curl https://api.usethrottle.dev/api/v1/workspaces/ /environments?includeArchived=true \\\\ -H Authorization: Bearer # Create a non-production sandbox-provider environment curl -X POST https://api.usethrottle.dev/api/v1/workspaces/ /environments \\\\ -H Authorization: Bearer \\\\ -H content-type: application/json \\\\ -d slug : uat , name : UAT # Archive a custom non-production environment curl -X DELETE https://api.usethrottle.dev/api/v1/workspaces/ /environments/8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0 \\\\ -H Authorization: Bearer ; const providerEnvExample = // Provider routing comes from the selected workspace environment: export default function TestModePage() return ( 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":"Use Webhooks as Your Source of Truth","route":"/developers/webhooks","headings":["Use Webhooks as Your Source of Truth","Create an endpoint","Verify signatures","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":"export const metadata: Metadata = title: Webhooks , ; const cartAbandonedSnippet = id : evt_2b8f... , type : cart.abandoned , workspaceId : 9c1e... , createdAt : 2026-07-04T18:22:05.000Z , data : cartId : cart_9f2a... , sequence : 7, currency : USD , total : 8900, itemCount : 2, customer : id : cus_51... , email : buyer@example.com , firstName : Bea , lineItems : [ id : li_1 , name : Premium Widget , quantity : 2, unitPrice : 2999, total : 5998 ], recoveryUrl : https://checkout.usethrottle.dev/c/cart_9f2a... ; const eventsByScope = (() => const groups = new Map (); for (const type of ALL_EVENT_TYPES) const scope = eventReadScope(type); const list = groups.get(scope) ?? []; list.push(type); groups.set(scope, list); return [...groups.entries()] .map(([scope, types]) => ( scope, types: [...types].sort() )) .sort((a, b) => a.scope.localeCompare(b.scope)); )(); const retryChart = sequenceDiagram participant T as Throttle participant Q as Delivery Queue participant E as Your Endpoint participant Op as Operator T->>Q: emit event (e.g. payment.captured) Q->>E: attempt 1 E-->>Q: 5xx / timeout Note over Q: wait 5m Q->>E: attempt 2 E-->>Q: 5xx / timeout Note over Q: wait 15m Q->>E: attempt 3 E-->>Q: 5xx / timeout Note over Q: wait 1h Q->>E: attempt 4 E-->>Q: 5xx / timeout Note over Q: wait 6h Q->>E: attempt 5 E-->>Q: 5xx / timeout Note over Q: wait 24h, max attempts reached Q->>Q: mark delivery dead_letter Op->>T: POST /api/v1/webhook-deliveries/ deliveryId /replay T->>Q: requeue Q->>E: manual retry attempt E-->>Q: 2xx Q->>Q: mark delivered ; const envelopeSnippet = id : evt_01HZX... , type : payment.captured , version : 1 , workspaceId : 6659b411-9cd7-40ac-9a73-1e7801d89f55 , environmentId : b7e5a40e-8c0f-4d85-a726-2ff2967f4b52 , createdAt : 2026-05-06T10:00:00.000Z , data : paymentId : pay_01HZX... , orderId : ord_01HZX... , amount : 12900, capturedAmount : 12900, authorizedAmount : 12900, currency : USD , customerId : cus_01HZX... , subscriptionId : sub_01HZX... , customer : id : cus_01HZX... , email : buyer@example.com , firstName : Ada , lastName : Lovelace , phone : null, externalId : your-user-42 , externalCustomerId : null ; const coverageSnippet = data : windowDays : 30, unsubscribed : [ type : subscription.paused , count : 12, lastEmittedAt : 2026-07-27T09:24:11.000Z ] ; const retiredEventsSnippet = data : id : wep_01HZX... , enabledEvents : [ payment.captured , subscription.updated ], retiredEventsIgnored : [ cart.expired ] ; export default function WebhooksPage() return ( 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. 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. 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.completed in addition to payment events when you need to mirror delivery state. 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 run your own follow-up. cart.converted — fires when a cart becomes an "},{"title":"Internal Support Module","route":"/internal/support","headings":["Internal Support Module","Routes","Auth","Workspace identity model","Webhook payload contract","See also"],"text":"export const metadata: Metadata = title: Internal Support Module , description: Internal-only documentation for the dispatchtickets.com-backed support module that powers the Throttle dashboard. , robots: index: false, follow: false , ; export default function InternalSupportPage() return ( 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 );"}]}
|