Skip to content

Embedded Checkout subscriptions

Use this path when Stripe Checkout should create the Subscription and your page should mount Stripe Embedded Checkout. This path’s technical ID is SUB-CO-E.

For a Stripe-hosted redirect, use Stripe-hosted Checkout subscriptions. For an Elements-based payment form, use Stripe Elements subscriptions.

Complete Set up Stripe to configure the shared Stripe connection, Proxy credentials, event destination, and payer destination.

Then create a handoff with the Stripe Checkout subscription cart snapshot. That guide stores the recurring Price ID, optional initial one-time Price ID, and quantity used below.

Run this command to print the Stripe versions, permissions, events, and unsupported options for this path:

proxy stripe doctor --path SUB-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. Pass a schema to validate and type the stored cart snapshot.

server/route.ts
import { openCheckout } from "@proxy-checkout/stripe-server-js";
import Stripe from "stripe";
import { proxy } from "./proxy";
import { subscriptionCartSchema, validateCurrentSubscriptionOffer } from "./types";
const stripeApiVersion = "2023-10-16";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: stripeApiVersion,
});
// POST /api/proxy/sessions/:proxySessionId/embedded-subscription
export async function POST(
_request: Request,
{ params }: { params: { proxySessionId: string } },
) {
const checkout = await openCheckout({
cartSchema: subscriptionCartSchema,
commercialMode: "subscription",
compatibility: {
apiVersion: stripeApiVersion,
browserSdkVersion: "2.1.8",
reactSdkVersion: "2.3.2",
serverSdkVersion: "14.0.0",
},
pricingMode: "fixed",
proxy,
proxySessionId: params.proxySessionId,
stripe,
uiMode: "embedded",
validateCart: ({ cart }) => validateCurrentSubscriptionOffer(cart),
buildCheckoutSessionParams: ({ offer }) => ({
mode: "subscription",
line_items: offer.lineItems,
return_url: `https://example.com/checkout/return?proxy_session_id=${encodeURIComponent(params.proxySessionId)}`,
ui_mode: "embedded",
}),
});
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,
});
}

cartSchema rejects a malformed snapshot. validateCurrentSubscriptionOffer checks the current server-owned catalog before Stripe Checkout is created. Replace the sample offer map in server/types.ts with your catalog. See Validate a cart snapshot for supported validator shapes and failure behavior.

If your application already has a Stripe Customer for the beneficiary or shared billing account, resolve it from your trusted account data and pass its ID as merchantCustomerId. Do not accept a Customer ID or email from the current payer.

For a free trial, add your trusted subscription_data.trial_period_days and set payment_method_collection: "if_required". If Stripe finalizes tax, promotions, shipping, or another amount, use pricingMode: "provider_finalized" and review Stripe fees.

Parse the Proxy session ID and request the Embedded Checkout state from your backend.

import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type EmbeddedSubscriptionOpening =
| { 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 openEmbeddedSubscription(): Promise<EmbeddedSubscriptionOpening | null> {
const proxySessionId = parseProxySessionIdFromUrl(window.location.href);
if (!proxySessionId) return null;
const response = await fetch(
`/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/embedded-subscription`,
{ method: "POST" },
);
if (!response.ok) return null;
return (await response.json()) as EmbeddedSubscriptionOpening;
}

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 EmbeddedSubscriptionForm({ clientSecret }: { clientSecret: string }) {
return (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
);
}

Handle every response from openEmbeddedSubscription explicitly. Keep the cart visible, and 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.
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.
  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. Apply subscription and entitlement changes idempotently. Enforce one transition for each resolved event and purchase state.

Checkout completion links the Checkout Session to the Subscription. Use the resolved Proxy event—not the client secret or raw Checkout event—to decide when to grant access. Keep your Stripe subscription management flow for payment failures, authentication, renewals, plan changes, and cancellation.

Review Stripe events and permissions for the events required by this path.

  1. Create one handoff and confirm Embedded Checkout shows the expected recurring plan and initial amount.
  2. Open the handoff in two browser sessions. Confirm both mount the same Checkout Session.
  3. Complete a Stripe test subscription in one browser. Confirm the other browser receives already_paid without creating another Subscription.
  4. Confirm the signed Proxy event causes one entitlement change. Redeliver the event and confirm the change does not repeat.
  5. Test the return, unavailable, and trial or zero-initial-Invoice outcomes your product supports.

If payers can change the plan, quantity, discount, or another order detail after Checkout opens, follow Cart updates.