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.
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.
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.
<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.
npm install -g @usethrottle/cli
throttle embed set \
--origins https://shop.example.com,https://staging.shop.example.com \
--primary "#1D56E8" \
--logo https://shop.example.com/logo.png \
--merchant-name "Example Shop" 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.
| Surface | When to use | Methods supported | React 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 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.
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 payment widget renders the methods configured on your connection.)
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.
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.
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.
// 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 payment widget
// renders whatever your payment 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. 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.
open for the whole life of a
session. The cart only becomes converted (terminal)
when the session completes and the order is created — not when the session
is created. So a buyer can return to an in-flight session and keep mutating the cart. If a
later cart operation returns 409 cart_not_open, the cart
was converted by a previously completed order; start a new cart for the new
purchase.
checkout.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 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.
| Parameter | Type | Behaviour |
|---|---|---|
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 those details and stamped them onto metadata.customer_prefill at session create time. |
primary (/c only) | #1D56E8 | Per-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.
// 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"
} | Event | Payload | Fired |
|---|---|---|
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, paymentStatus?, subscriptionId?, total?, currency?, lineItems? }' } | Terminal success. Navigate the buyer; the order is durable. Protocol v1.4 (additive) carries total (minor units), currency, and lineItems[] ( { id?, name, sku?, quantity, unitPrice, total } ) so the parent can fire analytics purchase events without an extra fetch — see Track conversions . Absent when unavailable (payment-only mode with no cart snapshot). |
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? }.
POST /api/v1/checkout-sessions/sess_xxx/complete
{
"paymentMethod": "card",
"paymentToken": "<provider paymentMethodId from the embed>",
"email": "[email protected]",
"shippingAddress": {
"firstName": "Ada",
"lastName": "Lovelace",
"addressLine1": "1 Infinite Loop",
"city": "Cupertino",
"stateProvince": "CA",
"postalCode": "95014",
"countryCode": "US",
"phone": "+14155551234"
}
}
// Response: { orderId, paymentId, redirectUrl? } POST /api/v1/checkout-sessions/sess_xxx/complete
{
"paymentMethod": "gr4vy",
"processorTransactionId": "<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 }. POST /api/v1/checkout-sessions/sess_xxx/complete
{
"paymentMethod": "net30",
"email": "[email protected]",
"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": "[email protected]"
}
}
// 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.
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.
//
// When methods is empty because a provider IS connected but can't render yet
// (e.g. not configured for the current environment), the response also carries
// an actionable reason — show reason.message instead of a generic empty state:
//
// {
// "data": {
// "methods": [],
// "unavailableReason": {
// "code": "payment_provider_not_renderable",
// "message": "Card payments aren't available for this checkout yet ..."
// }
// }
// } allowedMethods: ['card']
suppresses Net 30 even when the merchant has it configured. It does not restrict the payment methods the payment card widget renders in a
payment-only <PaymentEmbed> — those come from
your payment 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.
paymentTerms.netN to set a cart-level Invoice
Terms override. The final invoice uses customer.netN ?? cart.netN ?? DEFAULT_NET_N.