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);
      }}
    />
  );
}

Both components build their own iframe URL and load the /c page by default. Pass route="s" to load the unified /s page instead — for example to match the route you give buildCheckoutEmbedUrl on your server. The component does not see a URL you built yourself. Card-vault (add-a-card) sessions are only served by /c.

CheckoutEmbed

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

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

Preview origins

Preview deployments get a new hostname per branch, so they cannot be listed one by one. In a non-production environment, an entry may use one wildcard as its leftmost label:

  • https://*.preview.example.com matches https://pr-42.preview.example.com and any deeper subdomain. It does not match https://preview.example.com itself; list that separately if you need it.
  • The wildcard must sit under a domain you own. One over a shared or public suffix ( https://*.vercel.app, https://*.pages.dev, https://*.co.uk) is refused with 400 invalid_origin, because anyone can obtain a hostname there and could then host your checkout. Point a subdomain of your own at the preview host instead.
  • Production takes exact origins only. A wildcard sent to the production environment is refused, and creating an application never accepts one, since that seeds every environment at once. For a domain cutover, list the old and the new origin side by side and remove the old one afterwards.

A wildcard entry applies to embedded checkout, cart sessions, storefront sign-in and the script manifest. One exception: the sandbox that runs extension scripts only ever lets exact origins frame it, so sandboxed scripts do not run on a preview origin matched by a wildcard. A wildcard is also never used to build a link. If your allowlist holds only wildcards, set storefrontBaseUrl so buyer emails and relative image paths have a real host to point at.

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 payment 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 (embedded card, embedded 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 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.
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.
Read a session back
checkout.getSession(sessionId) ( GET /api/v1/checkout/sessions/{id}) returns the session with its current status: open or processing while the buyer is in it, then completed, cancelled or expired. Use it to decide whether a session can be resumed or needs cancelling, instead of tracking that on your side.
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).
Recurring/trial disclosure fields on the public session
GET /api/v1/checkout-sessions/{id} — unauthenticated, what the iframe itself reads to render — returns recurringTerms when the session carries a recurring intent, otherwise null. Card-network rules require disclosing what a trial converts into and what an installment plan totals before the buyer converts, so the iframe builds its disclosure copy from this object rather than hardcoding it: interval, intervalUnit, intervalCount, totalPayments (installment plans only), firstChargeAmountMinor, currency, trialDays + trialEndsAt, planName, taxApplies — true when renewals of this app are taxed, in which case the disclosure appends "plus applicable tax" to its amounts and total — and planChargedToday (installment plans only): whether the plan itself is on today's charge. It is false on a hybrid/quote/deposit-split cart, where the engine never counts the signup as one of the totalPayments — every payment comes from a renewal, so the disclosure must state all N payments and must not say anything is due today. Missing (older sessions) behaves as true, the pre-existing assumption.
Subscription lines on the public session
hasSubscriptions is true when the session has any subscription, from either source, and subscriptions[] lists each subscription line's terms: lineItemId, name, amount (the renewal price per seat), quantity, the cadence fields, totalPayments, trialDays, trialEndsAt, planChargedToday, taxApplies and firstChargeAmount: what the line charges today, after its share of any discount on the cart (split across lines in proportion to their price) and before tax. It is 0 on a free-trial line. A discount applies to the first charge only: amount stays the undiscounted renewal price. recurringTerms keeps its shape for a single-plan session intent, whose subscriptions[] is empty.

The session is pinned to the subscription lines it was created with. If they change afterwards (seats, price, terms, or a subscription line added or removed), this GET, every embed-token mint and a server-side completion answer 409 subscription_lines_changed. The error's details carry the session's cartId and, when the session has one, its cancelUrl. The iframe then shows "Your cart changed" instead of the payment form and posts throttle.error with that code; on the hosted page its "Back to cart" button goes to the cancelUrl. Create a new session from the current cart. The check runs before capture, so money that was already captured is never refused. Completion with a processorTransactionId the embed only authorised (intent: authorize) runs it too: the authorisation is voided and completion answers 409 subscription_lines_changed, with no order and no subscription. A transaction the embed already captured completes as before. If the transaction's status cannot be confirmed yet, completion answers the retryable 409 payment_unverified and voids nothing; retry it.

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, paymentStatus?, subscriptionId?, subscriptionIds?, paymentMethodId?, total?, currency?, lineItems? }' } Terminal success. Navigate the buyer; the order is durable. Protocol v1.6 (additive): an add-card session (the add-only card embed) creates no order and charges nothing — its $1 verification is voided — so orderId is empty, paymentMethodId is the saved card, and paymentId is the processor transaction, which POST /api/v1/me/payment-methods/confirm accepts. Protocol v1.5 (additive): subscriptionIds lists every subscription the checkout created, in line order; subscriptionId is set only when there is exactly one. 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.). A refusal from /complete is posted under its own code (checkout_total_changed, payment_unverified, transaction_not_for_session, transaction_already_used, trial_not_available): these are recoverable, the iframe shows the buyer a notice and a fresh card form, so do not unmount it. See Completion errors. subscription_lines_changed means the cart's subscription lines changed after the session was created: create a new session. recurring_amount_required means a session recurring intent has no amount and the cart now holds other lines: create a new session with recurring.amount.
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, subscriptionId, subscriptionIds, redirectUrl? }. The two subscription keys appear only on a checkout with subscriptions: subscriptionIds lists every subscription it created, in line order (empty if creation failed), and subscriptionId is set only when there is exactly one, null otherwise. A one-off checkout carries neither. Key order can differ between a first response and an idempotent replay, so compare fields, not bytes.

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

{
  "paymentMethod": "embedded",
  "processorTransactionId": "<transaction id the payment embed emits when the buyer pays>",
  "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? }
Provider proxy (legacy /c flow)
POST /api/v1/checkout-sessions/sess_xxx/complete

{
  "paymentMethod": "embedded",
  "processorTransactionId": "<transaction id the payment embed emits when the buyer pays>"
}

// Used by the legacy /c proxy mode where the embed 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": "[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.
card is not a completion method
card names the card category in allowedMethods, and the embedded rail satisfies it. It is not a value /complete can charge: a completion sent with paymentMethod: "card" is rejected with 422 payment_method_not_supported before any order is created. Card payments complete with paymentMethod: "embedded" and the processorTransactionId from the payment embed.
A declined card answers 402 payment_declined
When the processor declined the embed's authorisation, /complete answers 402 payment_declined, the same code the server-authorize and saved-card rails use. message carries the processor's reason, e.g. authorization_declined (<decline code>). Nothing is charged, the attempt leaves no pending payment behind, and the session reopens: the buyer can retry with another card on the same session. 502 payment_capture_failed is kept for a capture the processor refused after a successful authorisation.

Completion errors

/complete checks the money before it creates an order. The embed authorises the card (intent: authorize, recurring checkouts included) and Throttle captures only after it has confirmed that the transaction belongs to this checkout and that the authorised amount is the amount the cart charges now. Except for payment_processing, a refusal leaves the session open and creates no paid order and no subscription. The hosted page and the iframe handle each one for the buyer; a server-side caller should follow the last column.

StatusCodeWhenWhat to do
409checkout_total_changedThe cart total changed after the card was authorised (a new address re-priced the tax, a code was applied, the cart was edited). The authorisation is voided (voided). If the embed had already captured a different amount, it is refunded: capturedAmount and refunded say what happened to the money; refunded: false means the refund did not go through yet and will be retried. With orderTotal, an order from an earlier attempt on this cart already has a payment at a different total.Show the new total, create or refresh the embed token so the buyer confirms it, and pay again. Never retry the same transaction. With orderTotal paying again cannot succeed: start a new checkout, or ask the buyer to contact the merchant. (An earlier order with no money on it is not refused: it is re-totalled to the current cart before the charge.)
409payment_unverifiedThe payment provider has not confirmed the transaction yet: still in flight, or it could not be read. Nothing was captured by this request.Retry /complete with the same transaction. The clients retry after 1 s, 3 s and 6 s; if it is still unverified, they show "We could not confirm your payment. If a charge or hold appears, it will be released or refunded." with the transaction id and a fresh card form.
409transaction_not_for_sessionThe transaction (or, for Secure Fields, the card session) was not created for this checkout: another checkout's payment, another merchant account, or a stale payment form replaced by a newer one. details.retryable is false. The transaction is never used, voided or refunded by this checkout.Do not retry it. Remount the card form and pay again.
409transaction_already_usedThe transaction already paid another order, was refunded, or this checkout already voided or refunded it.Pay again with a fresh card form.
409trial_not_availableThe checkout grants a free trial and the card already had one on this merchant. See Trial Fraud Protection.Offer another card or a plan without a trial.
409tax_calculation_failedOn the server-authorize (Secure Fields), saved-card and Net-N rails Throttle recalculates calculated tax at completion, and that calculation failed. Nothing is charged.Retry, or check the address.
409payment_processingA capture is still settling: a pending order and a processing payment exist and will settle through the provider's webhook.Do not pay again. Wait for payment.captured or the order to update.
409checkout_in_progressAnother /complete for this session is still running.The clients retry once after 2 s.
checkout_total_changed
// 409 — the total changed after the card was authorised (nothing was captured)
{
  "error": {
    "code": "checkout_total_changed",
    "message": "The total changed after your card was authorised. Review the new total and pay again.",
    "details": {
      "authorizedAmount": 1100,
      "currentAmount": 1000,
      "currency": "USD",
      "cartId": "…",
      "voided": true
    }
  }
}

// 409 — the embed had already captured another amount: it is refunded
"details": { "authorizedAmount": 1100, "capturedAmount": 1100, "currentAmount": 1000,
             "currency": "USD", "cartId": "…", "voided": false, "refunded": true }

// 409 — an order from an earlier attempt already has a payment at another total
"details": { "orderTotal": 3248, "currentAmount": 3500, "currency": "USD",
             "cartId": "…", "voided": true }
The other refusals
// 409 — retry /complete with the same transaction
{ "error": { "code": "payment_unverified",
  "message": "We could not confirm your payment yet. Please try again.",
  "details": { "transactionId": "…", "retryable": true } } }

// 409 — never retry this transaction; pay again
{ "error": { "code": "transaction_not_for_session",
  "message": "This payment does not belong to this checkout. Please pay again.",
  "details": { "transactionId": "…", "retryable": false } } }

// 409 — pay again
{ "error": { "code": "transaction_already_used",
  "message": "This payment has already been used for this checkout. Please pay again.",
  "details": { "transactionId": "…" } } }
  • Captures never exceed the authorisation. The payment provider refuses a capture above the authorised amount (400 bad_request, "Cannot capture more than the authorized amount"), and Throttle never completes a checkout at an amount other than the cart's. The payment's capturedAmount is the amount the provider reports as captured.
  • $0 checkouts. A one-off checkout whose total is $0 (for example a 100% code) completes without a charge: the order is paid at $0 with a captured $0 payment. The card form is still shown. On the server-authorize and saved-card rails no processor call is made at all, so no card is saved and the invoice shows no card. A recurring $0 checkout (a trial) still authorises $0 to verify and save the card.
  • Payment-only and proxy completions follow the same rules: the transaction must belong to the Gr4vy checkout session the embed token minted for this session (otherwise transaction_not_for_session); an amount other than the session's is voided or refunded and answered checkout_total_changed; a transaction another payment already holds answers transaction_already_used unless it is the same request replayed for this session. A $0 recurring proxy session (a trial) completes at $0 with no charge.
  • Native tax follows the cart to the payment form. With native tax (taxMode: "calculated"), each embed-token mint ( GET /api/v1/checkout-sessions/:id/embed-token) first checks the cart's tax against its address, lines, shipping and customer. If any of them changed since the tax was last calculated, the tax is recalculated and the token is minted for the new total; if nothing changed, nothing is recalculated. When the cart has no address yet, the address the session was created with (the one completion puts on the order) is used. The completion charges what the card form authorised, so after you change a cart that a payment form is already showing, re-render the form (or mint a new token) to show the buyer the new total. A recalculation that fails answers 409 tax_calculation_failed and mints no token. A quote's checkout keeps its accepted total; provider (Avalara) tax and provider carts are unchanged.
  • Add-card sessions (the add-only card embed) never capture and create no order or payment. The $1 verification is voided once the card is saved, and /complete answers { status: "card_saved", orderId: null, paymentId: null, transactionId, paymentMethodId, paymentMethod }. If the card cannot be saved, the $1 is still released and the answer is 422 card_not_vaulted; the session stays open, so the buyer can try again.

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.
//
// 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 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 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.
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.