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);
}}
/>
);
}
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.
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" 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.commatcheshttps://pr-42.preview.example.comand any deeper subdomain. It does not matchhttps://preview.example.comitself; 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 with400 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.
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 (embedded card, embedded 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.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.
checkout.cancelSession(sessionId) (
DELETE /api/v1/checkout/sessions/{id}). It is
idempotent, marks the session cancelled, and re-opens an
associated cart still sitting in checkout. A completed
session cannot be cancelled (422 already_completed).
GET /api/v1/checkout-sessions/{id} — unauthenticated,
what the iframe itself reads to render — returns recurringTerms
when the session carries a recurring intent, otherwise null.
Card-network rules require disclosing what a trial converts into and what an installment
plan totals before the buyer converts, so the iframe builds its disclosure copy from this
object rather than hardcoding it: interval, intervalUnit, intervalCount, totalPayments (installment plans only), firstChargeAmountMinor, currency, trialDays + trialEndsAt, planName, taxApplies — true when renewals of this app are taxed, in which case the
disclosure appends "plus applicable tax" to its amounts and total — and planChargedToday (installment plans only): whether the
plan itself is on today's charge. It is false on a
hybrid/quote/deposit-split cart, where the 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.
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 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?, 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.
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? } 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 }. 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 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.
/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.
| Status | Code | When | What to do |
|---|---|---|---|
| 409 | checkout_total_changed | The 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.) |
| 409 | payment_unverified | The 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. |
| 409 | transaction_not_for_session | The 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. |
| 409 | transaction_already_used | The transaction already paid another order, was refunded, or this checkout already voided or refunded it. | Pay again with a fresh card form. |
| 409 | trial_not_available | The 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. |
| 409 | tax_calculation_failed | On 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. |
| 409 | payment_processing | A 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. |
| 409 | checkout_in_progress | Another /complete for this session is still running. | The clients retry once after 2 s. |
// 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 } // 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'scapturedAmountis 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 answeredcheckout_total_changed; a transaction another payment already holds answerstransaction_already_usedunless 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 answers409 tax_calculation_failedand 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
/completeanswers{ status: "card_saved", orderId: null, paymentId: null, transactionId, paymentMethodId, paymentMethod }. If the card cannot be saved, the $1 is still released and the answer is422 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.
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.