Embedded Stripe Checkout
Summary
Section titled “Summary”Use this path when a Checkout Session creates the PaymentIntent and your page mounts Stripe Embedded Checkout. This path’s technical ID is OT-CO-E.
For an Elements-based Checkout form, use Stripe Elements. If your server creates the PaymentIntent without a Checkout Session, use Direct PaymentIntent.
Prerequisites
Section titled “Prerequisites”Complete Set up Stripe to configure the shared Stripe connection, Proxy credentials, event destination, and payer destination.
Then create a handoff with the one-time cart snapshot. That guide adds the buyer action and POST /api/proxy/handoffs, which returns the handoffUrl the buyer shares.
Run this command to print the Stripe versions, permissions, events, and unsupported options for this path:
proxy stripe doctor --path OT-CO-E --format jsonInstall Stripe 14.0.0 or newer:
npm install stripe@">=14.0.0"pnpm add stripe@">=14.0.0"yarn add 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.2pnpm add --save-exact @stripe/stripe-js@2.1.8 @stripe/react-stripe-js@2.3.2yarn add --exact @stripe/stripe-js@2.1.8 @stripe/react-stripe-js@2.3.2This guide begins when the payer opens the handoff. Proxy sends them to your configured checkout URL with the same proxy_session_id.
Backend
Section titled “Backend”Create or retrieve the Checkout Session
Section titled “Create or retrieve the Checkout Session”Call openCheckout with the Proxy session ID and a server-only Stripe client configured for Embedded Checkout.
import { openCheckout } from "@proxy-checkout/stripe-server-js";import Stripe from "stripe";import { proxy } from "./proxy";import { validateCurrentOffer } from "./types";
const stripe = 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("Embedded Checkout requires an order email."); return email;}
// POST /api/proxy/sessions/:proxySessionId/embedded-checkoutexport async function POST( _request: Request, { params }: { params: { proxySessionId: string } },) { 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: params.proxySessionId, stripe, uiMode: "embedded", validateCart: ({ cart }) => validateCurrentOffer(cart), buildCheckoutSessionParams: ({ offer, proxySession }) => ({ customer_email: requireOrderEmail(proxySession), line_items: offer.lineItems, mode: "payment", ui_mode: "embedded", return_url: `https://example.com/pay/return?proxy_session_id=${encodeURIComponent(params.proxySessionId)}`, }), });
if (checkout.outcome !== "ready") { return Response.json({ cart: checkout.cart, outcome: checkout.outcome, sessionStatus: checkout.sessionStatus, }); }
if ( checkout.presentation?.kind !== "client_secret" || checkout.presentation.uiMode !== "embedded" ) { throw new Error("Expected an Embedded Checkout client secret."); }
return Response.json({ cart: checkout.cart, clientSecret: checkout.presentation.clientSecret, outcome: checkout.outcome, });}import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";
export const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!, publishableKey: process.env.PROXY_PUBLISHABLE_KEY!,});export type CartSnapshot = { currency: string; lineItems: Array<{ amountMinor: number; name: string; offerId: string; quantity: number; }>;};
type CurrentOffer = { amountMinor: number; available: boolean; currency: string; eligible: boolean; name: string;};
type ValidatedOffer = { lineItems: Array<{ price_data: { currency: string; product_data: { name: string }; unit_amount: number; }; quantity: number; }>;};
// Replace this example with offers loaded from your server-owned catalog.const currentOffers = new Map<string, CurrentOffer>([ [ "annual-membership", { amountMinor: 5000, available: true, currency: "usd", eligible: true, name: "Annual membership", }, ],]);
function parseCartSnapshot(value: unknown): CartSnapshot { if ( typeof value !== "object" || value === null || !("currency" in value) || typeof value.currency !== "string" || !("lineItems" in value) || !Array.isArray(value.lineItems) ) { throw new Error("Cart does not match the one-time checkout shape."); }
const lineItems = value.lineItems.map((item) => { if ( typeof item !== "object" || item === null || !("amountMinor" in item) || typeof item.amountMinor !== "number" || !Number.isInteger(item.amountMinor) || item.amountMinor < 0 || !("name" in item) || typeof item.name !== "string" || !("offerId" in item) || typeof item.offerId !== "string" || !("quantity" in item) || typeof item.quantity !== "number" || !Number.isInteger(item.quantity) || item.quantity < 1 ) { throw new Error("Cart does not match the one-time checkout shape."); }
return { amountMinor: item.amountMinor, name: item.name, offerId: item.offerId, quantity: item.quantity, }; });
return { currency: value.currency, lineItems };}
export function validateCurrentOffer(value: unknown): ValidatedOffer { const cart = parseCartSnapshot(value);
const lineItems = cart.lineItems.map((item) => { const offer = currentOffers.get(item.offerId); if (!offer || !offer.available || !offer.eligible) { throw new Error("This offer is no longer available."); } if ( offer.amountMinor !== item.amountMinor || offer.currency !== cart.currency || offer.name !== item.name ) { throw new Error("This offer changed. Refresh the cart before continuing."); }
return { price_data: { currency: offer.currency, product_data: { name: offer.name }, unit_amount: offer.amountMinor, }, quantity: item.quantity, }; });
return { lineItems };}Replace the sample offer map in server/types.ts with your server-owned catalog. Keep the same checks for the stored price, availability, and eligibility before creating Checkout.
Use the immutable beneficiary or shared billing email stored on the Proxy session. Do not use the current viewer’s email for a shared Checkout Session.
Keep STRIPE_SECRET_KEY in your backend. Pass the returned client secret to Stripe.js without decoding or modifying it.
Frontend
Section titled “Frontend”Parse the Proxy session ID and request the Embedded Checkout state from your backend.
import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type EmbeddedCheckoutOpening = | { cart: unknown; clientSecret: string; outcome: "ready" } | { cart: unknown; outcome: "already_paid"; sessionStatus: "paid" | "provisionable" | "provisioned" | "provisioning_failed"; } | { cart: unknown; outcome: "processing"; sessionStatus: "payer_handoff_pending" | "payer_opened" | "payment_pending"; } | { cart: unknown; outcome: "action_required"; sessionStatus: "merchant_action_required"; } | { cart: unknown; outcome: "unavailable"; sessionStatus: "cancelled" | "created" | "expired" | "failed"; };
async function openEmbeddedCheckout(): Promise<EmbeddedCheckoutOpening | null> { const proxySessionId = parseProxySessionIdFromUrl(window.location.href); if (!proxySessionId) return null;
const response = await fetch( `/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/embedded-checkout`, { method: "POST" }, ); if (!response.ok) return null;
return (await response.json()) as EmbeddedCheckoutOpening;}Mount Embedded Checkout only for the ready outcome.
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";import { loadStripe } from "@stripe/stripe-js";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export function EmbeddedCheckoutForm({ clientSecret }: { clientSecret: string }) { return ( <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}> <EmbeddedCheckout /> </EmbeddedCheckoutProvider> );}Handle outcomes
Section titled “Handle outcomes”Handle every response from openEmbeddedCheckout explicitly.
Keep the cart visible for every outcome. Do not request another client secret after Proxy returns a terminal state.
| Outcome | Session status | Payer experience |
|---|---|---|
ready |
— | Mount Embedded Checkout with clientSecret. |
already_paid |
paid, provisionable, provisioned, or provisioning_failed |
Show that payment is complete. Do not create another Checkout Session. |
processing |
payer_handoff_pending, payer_opened, or payment_pending |
Show a pending state and refresh the checkout state. Do not request another client secret. |
action_required |
merchant_action_required |
Tell the payer that checkout is unavailable until the merchant resolves the issue. Do not mount Checkout. |
unavailable |
cancelled, created, expired, or failed |
Show that checkout is unavailable. Do not mount Checkout. |
An asynchronous payment can return processing after the Checkout presentation ends. Keep the pending state until Proxy receives a successful or failed Stripe event.
A fixed no-cost order requires Stripe request API 2023-08-16 or newer. This path’s minimum version meets that requirement. A verified no-cost Checkout Session has no PaymentIntent and earns no Proxy fee. Do not create a PaymentIntent or placeholder for a no-cost order.
Receive Proxy events in your backend
Section titled “Receive Proxy events in your backend”- Follow Fulfillment and entitlements to configure your endpoint and subscribe to the required Proxy events.
- Pass each request to
proxy.webhooks.handle(...). The handler verifies the signature and resolves the current Proxy state. - Make fulfillment idempotent. Enforce one fulfillment record for each
resolved.session.id.
The example uses fixed pricing. The Checkout amount and currency must match the Proxy cart snapshot. If Stripe finalizes tax, promotions, shipping, or another amount, use pricingMode: "provider_finalized" and review Stripe fees.
Embedded Checkout Sessions cannot update line items. Follow Cart updates to replace a Session after a payer changes the order.
Test the integration
Section titled “Test the integration”- Create one handoff and confirm the payer page loads the expected cart.
- Open the handoff in two browser sessions. Confirm both can use the same Checkout Session.
- Complete a Stripe test payment in one browser. Confirm the other browser receives
already_paidwithout creating another Session. - Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.
- Exercise asynchronous payment, cancellation, action-required, and unavailable states without falling back to another presentation.
Review Stripe events and permissions for the exact events required by this path.
Before launch, review order identity and duplicate prevention.