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.

Payment-only embed
import { PaymentEmbed } from '@usethrottle/checkout-react';

export function PaymentStep({ sessionId }: { sessionId: string }) {
  return (
    <PaymentEmbed
      sessionId={sessionId}
      parentOrigin="https://shop.example.com"
      baseUrl="https://checkout.usethrottle.dev"
      primary="#1D56E8"
      onReady={() => console.log('ready')}
      onProcessing={() => console.log('processing')}
      onSucceeded={({ orderId, paymentId }) => {
        window.location.href = `/thank-you?order=${orderId}&payment=${paymentId}`;
      }}
      onFailed={({ code, message }) => {
        console.error(code, message);
      }}
    />
  );
}

CheckoutEmbed

Use CheckoutEmbed when you want a fuller hosted checkout flow inside your page. It supports the same terminal events plus checkout step changes.

Full checkout embed
import { CheckoutEmbed } from '@usethrottle/checkout-react';

<CheckoutEmbed
  sessionId={sessionId}
  parentOrigin="https://shop.example.com"
  baseUrl="https://checkout.usethrottle.dev"
  logo="https://shop.example.com/logo.png"
  onStepChanged={({ step }) => analytics.track('checkout_step', { step })}
  onSucceeded={({ orderId }) => router.push(`/orders/${orderId}`)}
/>;

Raw iframe

If you are not using React, render the hosted checkout iframe directly and validate the postMessage envelope before trusting events.

Plain HTML
<iframe
  src="https://checkout.usethrottle.dev/c/SESSION_ID?embed=1&mode=payment-only&parentOrigin=https%3A%2F%2Fshop.example.com"
  style="width:100%;height:520px;border:0"
  allow="payment *"
></iframe>

<script>
  window.addEventListener('message', (event) => {
    if (event.origin !== 'https://checkout.usethrottle.dev') return;
    if (event.data?.source !== 'throttle' || event.data?.version !== 1) return;

    if (event.data.type === 'throttle.completed') {
      window.location.href = '/thank-you?order=' + event.data.orderId;
    }
  });
</script>

Origin allowlist

Embedded checkout refuses to render unless the parent origin is allowlisted. Configure production and staging origins before mounting the iframe.

CLI
npm install -g @usethrottle/cli

throttle embed-config set \
  --origins https://shop.example.com,https://staging.shop.example.com \
  --primary "#1D56E8" \
  --logo https://shop.example.com/logo.png \
  --merchant-name "Example Shop"
Strict browser event routing
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.

SurfaceWhen to useMethods supportedReact SDK component
/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 30CheckoutEmbed

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.

1

Mint a session server-side

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 Gr4vy widget renders the methods configured on your connection.)

2

Render the iframe

Mount <CheckoutEmbed/>, <PaymentEmbed/>, 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.

3

Buyer submits

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.

4

Parent reacts to events

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.

End-to-end pseudocode
// 1. Server-side: merchant mints an embed-mode session.
import { createCheckoutClient } from '@usethrottle/checkout-sdk/server';

const checkout = createCheckoutClient({
  apiKey: process.env.THROTTLE_API_KEY!,
});

const session = await checkout.createEmbedToken({
  amount: 12000,
  currency: 'USD',
  country: 'US',
  externalCartId: 'cart_abc',
  // NOTE: the payment-only embed cannot restrict methods — the Gr4vy widget
  // renders whatever your Gr4vy connection exposes. Passing allowedMethods here
  // is rejected (400 allowed_methods_unsupported). To limit methods, configure
  // the connection, or use the full hosted checkout (createSession).
});
// session.checkoutSessionId, session.embedToken (~30min TTL),
// session.hostedUrl (full /c URL), session.embedUrl (/c?embed=1).

// 2. Client-side: parent renders the iframe (or React embed).
//    Throttle's iframe re-mints the JWT on every render via
//    GET /api/v1/checkout-sessions/:id/embed-token, so the buyer can land
//    minutes after creation without breaking the embed.

// 3. Buyer submits → iframe POSTs /api/v1/checkout-sessions/:id/complete
//    with one of the three payload shapes (see the Complete reference below).

// 4. Iframe posts `throttle.completed` with { orderId, paymentId } to the parent.
//    Parent navigates to its own thank-you page.
Embed JWTs are not the session
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.
Creating a session does not consume the cart
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.
Cancel an abandoned session
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.

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
Embedded checkout (/s) handshake — load through redirect.

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.

ParameterTypeBehaviour
embed1Toggles iframe mode (chromeless layout, postMessage emission, origin allowlist enforcement). Omit to render the standalone hosted page.
parentOriginhttps://shop.example.comRequired 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.
modepayment-onlySkips the cart and address steps; jumps directly to the payment step. Use when your storefront already collected those details and stamped them onto metadata.customer_prefill at session create time.
primary (/c only)#1D56E8Per-render brand colour override. Falls back to the merchant's saved branding.primaryColor. Validated as a hex literal; invalid values are silently dropped.
logo (/c only)https://...Per-render logo URL override. Validated with new URL(); non-https/http URLs are ignored so a malicious parent cannot smuggle in a data URI.

postMessage event reference

Every iframe-to-parent message wraps an event in a versioned envelope. Verify the envelope before trusting the payload — the same window may host multiple third-party iframes.

Envelope shape
// Every event Throttle posts to the parent uses this envelope.
// Verify event.origin === <checkout host>, source === 'throttle', version === 1.
{
  "source": "throttle",
  "version": 1,
  "type": "throttle.completed",
  "orderId": "ord_xxx",
  "paymentId": "pay_xxx"
}
EventPayloadFired
throttle.ready{}iframe mounted; safe to attach the event listener.
throttle.step.changed{ step: 'cart' | 'address' | 'shipping' | 'billing' | 'payment' }Step transition (deduped — the same step never fires twice in a row). Only emitted from the unified /s flow.
throttle.processing{}Pay clicked; treat as a non-cancellable spinner cue.
throttle.completed{ orderId, paymentId }Terminal success. Navigate the buyer; the order is durable.
throttle.error{ code, message }Failure reported by the embed (declined card, expired session, etc.).
throttle.cancelled{}Buyer abandoned the flow (closed modal, navigated away).
throttle.resize{ height }Document height changed. The React SDK auto-applies this; raw integrators should set the iframe height to the reported value.

Complete-session payloads

The buyer-side POST /api/v1/checkout-sessions/:id/complete accepts three payload shapes, discriminated by paymentMethod. All three return the same shape: { orderId, paymentId, redirectUrl? }.

Card (unified /s flow)
POST /api/v1/checkout-sessions/sess_xxx/complete

{
  "paymentMethod": "card",
  "paymentToken": "<provider paymentMethodId from the embed>",
  "email": "buyer@example.com",
  "shippingAddress": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "addressLine1": "1 Infinite Loop",
    "city": "Cupertino",
    "stateProvince": "CA",
    "postalCode": "95014",
    "countryCode": "US",
    "phone": "+14155551234"
  }
}

// Response: { orderId, paymentId, redirectUrl? }
Provider proxy (legacy /c flow)
POST /api/v1/checkout-sessions/sess_xxx/complete

{
  "paymentMethod": "gr4vy",
  "gr4vyTransactionId": "<txn id emitted by the provider embed onTransactionCreated>"
}

// Used by the legacy /c proxy mode where the provider collects the email + address
// inside its own iframe. Response: { orderId, paymentId }.
Net 30 invoice
POST /api/v1/checkout-sessions/sess_xxx/complete

{
  "paymentMethod": "net30",
  "email": "billing@acme.com",
  "shippingAddress": { /* same shape as card */ },
  "net30Acceptance": {
    "termsHash": "<sha256 of the terms snapshot the buyer accepted>",
    "acceptedAt": "2026-05-02T18:30:00Z",
    "companyName": "Acme Corp",
    "billingEmail": "billing@acme.com"
  }
}

// Response: { orderId, paymentId, redirectUrl? }
// The order ships in 'deferred' payment status; an invoice is issued and the
// AR cron handles dunning + aging until it is marked paid.

Payment-method filtering

allowedMethods on a checkout session filters Throttle's method selection: the GET /api/v1/checkout-sessions/{id}/payment-methods catalog and, in the full hosted checkout, which payment tiles the buyer sees.

Eligible methods response
GET /api/v1/checkout-sessions/sess_xxx/payment-methods

{
  "data": {
    "methods": [
      {
        "method": "card",
        "displayName": "Card",
        "connectorId": "gr4vy",
        "acceptedCurrencies": ["USD"]
      },
      {
        "method": "net30",
        "displayName": "Net 30 Invoice",
        "connectorId": "net30",
        "acceptedCurrencies": ["USD"]
      }
    ]
  }
}

// The server runs each connector's listEligible() against the merchant + amount
// + currency, then intersects with the session's `allowedMethods`. Empty
// methods array → iframe renders the "no payment methods available" tile.
// The client never re-filters; trust the response.
allowedMethods filters the full checkout — not the payment-only embed
In the full hosted checkout, allowedMethods: ['card'] suppresses Net 30 even when the merchant has it configured. It does not restrict the payment methods the Gr4vy card widget renders in a payment-only <PaymentEmbed> — those come from your Gr4vy connection configuration (so to hide, say, PayPal there, disable it on the connection). The payment-only embed-token endpoint (POST /api/v1/checkout-sessions/embed-token) now rejects allowedMethods with 400 allowed_methods_unsupported rather than silently ignoring it.
Net-N day counts live in paymentTerms
Use paymentTerms.netN to set a cart-level Invoice Terms override. The final invoice uses customer.netN ?? cart.netN ?? DEFAULT_NET_N.