One-time Stripe Checkout
Use this guide after choosing a one-time Checkout path. In every presentation,
your backend calls openCheckout with its merchant-owned Stripe client. The
difference is what that helper returns to the payer page:
- Hosted: a redirect URL. This is the recommended default.
- Embedded: a client secret for Stripe Embedded Checkout.
- Custom: a client secret for a Checkout Session rendered with Elements.
If your server creates a PaymentIntent without a Checkout Session, use Direct PaymentIntent instead.
Hosted
Section titled “Hosted”Hosted Checkout redirects the payer to Stripe’s payment page. Its technical ID
is OT-CO-H; use that ID only for the canonical facts check:
proxy stripe doctor --path OT-CO-H --format jsonInstall the Stripe server package before copying this path's backend example. The exact version comes from the same canonical capability manifest as stripe doctor.
npm install --save-exact stripe@12.18.0
pnpm add --save-exact stripe@12.18.0
yarn add --exact stripe@12.18.0
Credential ownership
Section titled “Credential ownership”Use two separate Stripe credentials:
- Merchant backend: your existing create/edit credential, kept only in your server and used by the injected Stripe client.
- Proxy: one dedicated read-only restricted key with the exact combined permissions shown in Portal for every implemented path.
Proxy retrieves complete paginated Checkout lines for final-cart reconciliation and fee basis. It never uses merchant cart input as provider-authored final evidence.
Backend redirect route
Section titled “Backend redirect route”import Stripe from "stripe";import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";import { openCheckout } from "@proxy-checkout/stripe-server-js";
const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!,});
const hostedStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2022-11-15",});
export async function POST(request: Request) { const { proxySessionId } = (await request.json()) as { proxySessionId: string };
const checkout = await openCheckout({ commercialMode: "one_time", compatibility: { apiVersion: "2022-11-15", serverSdkVersion: "12.18.0", }, pricingMode: "fixed", proxy, proxySessionId, stripe: hostedStripe, uiMode: "hosted", validateCart: ({ cart }) => validateCurrentOffer(cart), buildCheckoutSessionParams: ({ offer }) => ({ line_items: offer.lineItems, mode: "payment", success_url: `${process.env.APP_ORIGIN}/pay/complete?proxy_session_id=${encodeURIComponent(proxySessionId)}`, cancel_url: `${process.env.APP_ORIGIN}/pay?proxy_session_id=${encodeURIComponent(proxySessionId)}`, }), });
if (checkout.outcome !== "ready") { return Response.json({ cart: checkout.cart, outcome: checkout.outcome, sessionStatus: checkout.sessionStatus, }); }
if (checkout.presentation?.kind !== "redirect") { throw new Error("Expected hosted Checkout redirect"); }
return Response.json({ outcome: checkout.outcome, redirectUrl: checkout.presentation.url, });}hostedStripe is only for OT-CO-H. Do not reuse it for embedded or custom Checkout.
Callers select Proxy uiMode: "hosted" but must omit provider ui_mode from buildCheckoutSessionParams. Stripe 12.18.0 does not type it; current Stripe reports hosted_page. Both default hosted on create, and the helper normalizes the current response without accepting embedded/custom.
Hosted uses success_url and optional cancel_url. Do not set return_url and do not expect or render a Checkout client_secret.
Proxy metadata is injected after pure metadata-conflict preflight and after reservation supplies the acquisition ID. Do not add proxy_session_id, proxy_acquisition_attempt_id, proxy_contract_version, or proxy_integration_path yourself. Conflicts fail before reservation/provider creation.
Redirect and terminal UI
Section titled “Redirect and terminal UI”const checkout = await response.json();
if (checkout.outcome === "ready") { location.assign(checkout.redirectUrl);} else if (checkout.outcome === "already_paid") { renderCompletedOrder(checkout.cart, checkout.sessionStatus);} else { renderUnavailableOrder(checkout.cart, checkout.sessionStatus);}Every authorized viewer retains the cart. The first opener never owns the Session. Identical concurrent calls reserve/join and converge on the same hosted URL. If completion races reservation, the helper re-reads Proxy and returns already_paid with zero Stripe create/retrieve calls.
Fixed and provider-finalized pricing
Section titled “Fixed and provider-finalized pricing”For pricingMode: "fixed", created/retrieved Checkout amount and currency must match the exact Proxy cart version. The normalized complete line identity includes Price identity, quantity, amount/currency, and recurring semantics, so same-total carts with different line composition cannot join one root.
For pricingMode: "provider_finalized", Proxy may start with a quote and accept a different provider-authored final amount. Completion retrieves every Checkout line page, validates contract-v2 acquisition/path metadata, stores the bounded normalized final cart snapshot plus final amount/currency, and then evaluates provisioning. The line-evidence hash is a tamper/change detector, not the stored final-cart snapshot or fee basis by itself.
Cart edits
Section titled “Cart edits”Hosted Checkout Sessions cannot update line items. Replace rather than mutate a bound Session:
await stripe.checkout.sessions.expire(checkoutSessionId);// Wait for Proxy to expose provider-confirmed acquisition status `expired`.// Then write the next expected cart version, supersede with `checkout_expired`,// and call openCheckout again with the canonical next provider-options fingerprint.See Cart updates for the exact order and code. syncCheckoutCart fails before mutation with the stable hosted_replacement_required code for an explicit hosted or embedded acquisition and remains only a mutable/custom compatibility helper. A stale positive success is retained as a financial and fee fact but cannot silently provision.
Lifecycle
Section titled “Lifecycle”| Stripe evidence | Proxy behavior |
|---|---|
checkout.session.completed unpaid/async |
Records progress; does not grant. |
checkout.session.async_payment_succeeded plus coherent paid evidence |
Evaluates completion once final cart and PaymentIntent evidence are coherent. |
checkout.session.async_payment_failed |
Records failure and preserves immutable facts; no grant. |
checkout.session.expired |
Provider-confirmed terminal evidence may release/supersede the acquisition; the cart remains editable. |
| Positive succeeded PaymentIntent + paid Checkout | One payment/completion winner; one immutable earned fee. |
| Duplicate/reordered event or API recovery | Replays through the same idempotent lifecycle owner. |
No-cost Checkout
Section titled “No-cost Checkout”A fixed order with amount zero is supported only when the declared Stripe request API is 2023-08-16 or newer. The classic 2022-11-15 lane supports positive hosted Checkout but not no-cost orders. The helper fails before Stripe creation with exact upgrade guidance when the API version is absent/older.
A verified no-cost Checkout has no PaymentIntent, earns no fee, and can become provisionable only from coherent provider-authored Checkout evidence. Never synthesize a PaymentIntent or positive placeholder.
Fulfillment and optional observations
Section titled “Fulfillment and optional observations”Grant only from a verified Proxy event/current-state resolution after your idempotent merchant-owned entitlement write. Browser return URLs and Checkout completion alone are not authority. Refund/dispute observation applies only when selected for this Stripe configuration; Proxy does not create refunds, manage disputes, revoke access, or reverse the 3% + 30-minor-unit earned fee.
See compatibility, fees, events and permissions, and order identity.
Embedded
Section titled “Embedded”Embedded Checkout keeps Stripe’s Checkout UI on your page. Its technical ID is
OT-CO-E:
proxy stripe doctor --path OT-CO-E --format jsonInstall the Stripe server package before copying this path's backend example. The exact version comes from the same canonical capability manifest as stripe doctor.
npm install --save-exact stripe@14.0.0
pnpm add --save-exact stripe@14.0.0
yarn add --exact stripe@14.0.0
Install the Stripe browser packages before copying the client example. These exact versions come from the same canonical capability manifest as stripe doctor.
npm install --save-exact @stripe/stripe-js@2.1.8 @stripe/react-stripe-js@2.3.2
pnpm add --save-exact @stripe/stripe-js@2.1.8 @stripe/react-stripe-js@2.3.2
yarn add --exact @stripe/stripe-js@2.1.8 @stripe/react-stripe-js@2.3.2
The merchant backend creates the Session after the shared Proxy reservation. The minimum supported API version sends provider ui_mode: "embedded"; newer supported versions may report embedded_page, which the helper normalizes only for the semantic embedded path.
import Stripe from "stripe";
const embeddedStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2023-10-16",});
function requireOrderEmail(proxySession: { beneficiaryContact: { email: string | null } | null;}) { const email = proxySession.beneficiaryContact?.email; if (!email) throw new Error("Shared Checkout requires an immutable order email"); return email;}
const checkout = await openCheckout({ commercialMode: "one_time", compatibility: { apiVersion: "2023-10-16", browserSdkVersion: "2.1.8", reactSdkVersion: "2.3.2", serverSdkVersion: "14.0.0", }, pricingMode: "fixed", proxy, proxySessionId, stripe: embeddedStripe, uiMode: "embedded", validateCart: ({ cart }) => validateCurrentOffer(cart), buildCheckoutSessionParams: ({ offer, proxySession }) => ({ customer_email: requireOrderEmail(proxySession), line_items: offer.lineItems, mode: "payment", ui_mode: "embedded", return_url: `${process.env.APP_ORIGIN}/pay/return?proxy_session_id=${encodeURIComponent(proxySessionId)}`, }),});
if (checkout.outcome !== "ready") return Response.json(checkout);if ( checkout.presentation?.kind !== "client_secret" || checkout.presentation.uiMode !== "embedded") { throw new Error("Expected embedded Checkout client secret");}return Response.json({ outcome: "ready", presentation: checkout.presentation });requireOrderEmail must return the immutable beneficiary or shared billing email already stored on
the Proxy session, and fail before Stripe creation when that order identity has no email. Never use
the current viewer’s email to populate a shared Checkout Session.
Validate the discriminator before mounting Stripe. A URL, missing secret, custom discriminator, success_url, or hosted response is an error; never redirect as a fallback. Treat the client secret as an opaque credential: Stripe can return literal percent escapes such as %2F, and the browser integration must pass the exact returned string to Stripe.js without URL-decoding it.
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";import { loadStripe } from "@stripe/stripe-js";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
function isCheckoutSessionClientSecret(value: unknown): value is string { if (typeof value !== "string" || /\s/u.test(value)) return false; return ( (value.startsWith("cs_test_") && value.length > "cs_test_".length) || (value.startsWith("cs_live_") && value.length > "cs_live_".length) );}
export function EmbeddedCheckoutForm({ presentation }: { presentation: unknown }) { if ( !presentation || typeof presentation !== "object" || !("kind" in presentation) || presentation.kind !== "client_secret" || !("uiMode" in presentation) || presentation.uiMode !== "embedded" || !("clientSecret" in presentation) || !isCheckoutSessionClientSecret(presentation.clientSecret) || "url" in presentation ) throw new Error("Embedded Checkout presentation is malformed");
return ( <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret: presentation.clientSecret }} > <EmbeddedCheckout /> </EmbeddedCheckoutProvider> );}Custom
Section titled “Custom”Custom Checkout lets your page compose an Elements-based payment UI while a
Checkout Session still owns the payment flow. Its technical ID is OT-CO-C:
proxy stripe doctor --path OT-CO-C --format jsonInstall the Stripe server package before copying this path's backend example. The exact version comes from the same canonical capability manifest as stripe doctor.
npm install --save-exact stripe@18.0.0
pnpm add --save-exact stripe@18.0.0
yarn add --exact stripe@18.0.0
Install the Stripe browser packages before copying the client example. These exact versions come from the same canonical capability manifest as stripe doctor.
npm install --save-exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.0
pnpm add --save-exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.0
yarn add --exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.0
Custom uses the same reservation and cart-validation sequence as embedded, but it must use its own Basil Stripe client and compatibility declaration. Do not reuse hostedStripe or embeddedStripe.
import Stripe from "stripe";
const customStripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-03-31.basil",});
const checkout = await openCheckout({ commercialMode: "one_time", compatibility: { apiVersion: "2025-03-31.basil", browserSdkVersion: "7.0.0", reactSdkVersion: "3.6.0", serverSdkVersion: "18.0.0", }, pricingMode: "fixed", proxy, proxySessionId, stripe: customStripe, uiMode: "custom", validateCart: ({ cart }) => validateCurrentOffer(cart), buildCheckoutSessionParams: ({ offer, proxySession }) => ({ customer_email: requireOrderEmail(proxySession), line_items: offer.lineItems, mode: "payment", ui_mode: "custom", return_url: `${process.env.APP_ORIGIN}/pay/return?proxy_session_id=${encodeURIComponent(proxySessionId)}`, }),});
if (checkout.outcome !== "ready") return Response.json(checkout);if ( checkout.presentation?.kind !== "client_secret" || checkout.presentation.uiMode !== "custom") { throw new Error("Expected custom Checkout client secret");}return Response.json({ outcome: "ready", presentation: checkout.presentation });Newer supported Stripe versions may report provider elements; the helper normalizes it only for custom. Return the discriminated client-secret presentation and reject any redirect or mismatched result before rendering.
With the minimum versions listed above, render and submit with the root-package Checkout contract:
import { CheckoutProvider, PaymentElement, useCheckout } from "@stripe/react-stripe-js";import { loadStripe } from "@stripe/stripe-js";import { type SubmitEvent, useMemo, useState } from "react";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
function isCheckoutSessionClientSecret(value: unknown): value is string { if (typeof value !== "string" || /\s/u.test(value)) return false; return ( (value.startsWith("cs_test_") && value.length > "cs_test_".length) || (value.startsWith("cs_live_") && value.length > "cs_live_".length) );}
function CheckoutForm() { const checkout = useCheckout(); const [error, setError] = useState<string>(); const [submitting, setSubmitting] = useState(false); async function submit(event: SubmitEvent<HTMLFormElement>) { event.preventDefault(); setError(undefined); setSubmitting(true); const confirmation = await checkout.confirm({ redirect: "if_required" }); if (confirmation.type === "error") { setError(confirmation.error.message); setSubmitting(false); } } return ( <form onSubmit={submit}> <p>Total: <output>{checkout.total.total.amount}</output></p> <PaymentElement /> <button disabled={submitting || !checkout.canConfirm} type="submit">{submitting ? "Submitting…" : "Pay"}</button> {error ? <p role="alert">{error}</p> : null} </form> );}
export function CustomCheckoutPresentation({ presentation }: { presentation: unknown }) { if ( !presentation || typeof presentation !== "object" || !("kind" in presentation) || presentation.kind !== "client_secret" || !("uiMode" in presentation) || presentation.uiMode !== "custom" || !("clientSecret" in presentation) || !isCheckoutSessionClientSecret(presentation.clientSecret) || "url" in presentation ) throw new Error("Custom Checkout presentation is malformed"); const clientSecret = presentation.clientSecret; const options = useMemo(() => ({ fetchClientSecret: async () => clientSecret }), [clientSecret]); return <CheckoutProvider stripe={stripePromise} options={options}><CheckoutForm /></CheckoutProvider>;}Set return_url once when your server creates the custom Checkout Session. Do not also pass
returnUrl to checkout.confirm(). Set customer_email during merchant-server Session creation
from the immutable beneficiary or shared billing email stored on the Proxy session. Because every
authorized payer receives the same client secret, never call checkout.updateEmail() with a
viewer-local value. Once that server-configured Session is ready, canConfirm safely gates submit.
With React Stripe.js 6.7.0, use CheckoutElementsProvider, PaymentElement, and useCheckoutElements from @stripe/react-stripe-js/checkout; do not mix those imports with the minimum-version example above.
Shared client-secret lifecycle
Section titled “Shared client-secret lifecycle”Embedded and custom use the hosted path’s fixed/provider-finalized pricing, async success/failure, expiry, no-cost, immutable fee, Customer diagnostic, optional refund/dispute observation, known-object recovery, and merchant event behavior. Their minimum request APIs satisfy the 2023-08-16 no-cost requirement; a verified zero Session has no PaymentIntent and earns no fee.
Two clean browser contexts must load the same Proxy session concurrently, receive the same Checkout Session/client secret, and both be able to interact. Account for exactly one Stripe create. Opening or mounting never gives either viewer ownership or freezes the cart. After a qualifying success, both contexts render explicit terminal state and request no new client secret.
An asynchronous payment method can end the provider presentation with
payment_status: "unpaid" while Proxy remains non-terminal until
checkout.session.async_payment_succeeded or checkout.session.async_payment_failed.
openCheckout returns outcome: "processing" for that interval; render pending state and reopen
Proxy later rather than requesting another client secret or treating the delay as an error.
Proxy merchant events for these paths are the exact capability-manifest union:
provider_acquisition.expired, provider_acquisition.failed, provider_acquisition.processing,
provider_acquisition.reconciliation_required, provider_acquisition.requires_action,
payment_attempt.cancelled, payment_attempt.failed, payment_attempt.succeeded,
proxy_session.expired, proxy_session.paid, and proxy_session.provisionable.
The non-granting provider-acquisition events cover asynchronous failure, expiry,
and in-progress/action-required states; Proxy does not advertise
payment_attempt.processing or payment_attempt.requires_action because those
states do not have merchant-event writers. Verify/dedupe the signed event,
resolve current state, and then perform merchant-owned fulfillment or
revocation; browser completion is never authority.
Customer and PaymentMethod references are diagnostic provider evidence only. Never put them, payer data, raw Events, or client secrets in logs or retained test records. Selected refund/dispute observation can emit linked thin merchant facts, but Proxy never creates a refund, manages a dispute, reverses an earned fee, or decides access.
Subscriptions
Section titled “Subscriptions”Hosted subscription Checkout is generally available through the standard Stripe
setup. It uses the same openCheckout helper with
commercialMode: "subscription", while complete provider-authored Invoice
lines—not the Checkout total or payment ordinal—determine payment and fee
authority. See Stripe-hosted subscriptions
for the server integration, trial, mixed-cart, lifecycle, and fee contract.
Embedded and custom subscription Checkout are generally available through the same Stripe
configuration. Use SUB-CO-E and SUB-CO-C with commercialMode: "subscription"; their exact
embedded and custom compatibility floors and presentation rules are the same as the corresponding
one-time paths above. Run proxy stripe doctor --path SUB-CO-E --format json and
proxy stripe doctor --path SUB-CO-C --format json against the single combined setup before
rendering either path.
Validate your selected presentation
Section titled “Validate your selected presentation”- Run
proxy stripe doctor --path <selected-id> --format jsonand satisfy the reported package/API versions, read permissions, and webhook events. - Start two payer views for the same Proxy handoff and confirm both converge on the same Checkout Session and cart.
- Complete a Stripe test payment in one view. Confirm the other view shows the completed/already-paid state without creating another Session.
- Retry signed events and confirm your merchant fulfillment runs once.
- Exercise the selected presentation’s cancellation, action-required, and unavailable state without falling back to a different presentation.
Next, review order identity and duplicate prevention and add entitlements when payment grants access.