Skip to content

Embedded Stripe Checkout

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.

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 json

Install Stripe 14.0.0 or newer:

npm install 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

This guide begins when the payer opens the handoff. Proxy sends them to your configured checkout URL with the same proxy_session_id.

Call openCheckout with the Proxy session ID and a server-only Stripe client configured for Embedded Checkout.

server/route.ts
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-checkout
export 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,
});
}

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.

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

  1. Follow Fulfillment and entitlements to configure your endpoint and subscribe to the required Proxy events.
  2. Pass each request to proxy.webhooks.handle(...). The handler verifies the signature and resolves the current Proxy state.
  3. 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.

  1. Create one handoff and confirm the payer page loads the expected cart.
  2. Open the handoff in two browser sessions. Confirm both can use the same Checkout Session.
  3. Complete a Stripe test payment in one browser. Confirm the other browser receives already_paid without creating another Session.
  4. Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.
  5. 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.