Stripe Elements subscriptions
Use this path when Stripe Checkout should create the Subscription and your page should render an Elements-based Custom Checkout form. This path’s technical ID is SUB-CO-C.
Custom Checkout still uses a Checkout Session. For a Stripe-hosted redirect, use Stripe-hosted Checkout subscriptions. To mount Stripe’s complete Checkout UI in your page, use Embedded Checkout subscriptions.
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 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-C --format jsonInstall Stripe 18.0.0 or newer:
npm install stripe@">=18.0.0"pnpm add stripe@">=18.0.0"yarn add 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.0pnpm add --save-exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.0yarn add --exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.0This 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. Pass a schema to validate and type the stored cart snapshot.
import { openCheckout } from "@proxy-checkout/stripe-server-js";import Stripe from "stripe";import { proxy } from "./proxy";import { subscriptionCartSchema, validateCurrentSubscriptionOffer } from "./types";
const stripeApiVersion = "2025-03-31.basil";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: stripeApiVersion,});
// POST /api/proxy/sessions/:proxySessionId/custom-subscriptionexport async function POST( _request: Request, { params }: { params: { proxySessionId: string } },) { const checkout = await openCheckout({ cartSchema: subscriptionCartSchema, commercialMode: "subscription", compatibility: { apiVersion: stripeApiVersion, browserSdkVersion: "7.0.0", reactSdkVersion: "3.6.0", serverSdkVersion: "18.0.0", }, pricingMode: "fixed", proxy, proxySessionId: params.proxySessionId, stripe, uiMode: "custom", 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: "custom", }), });
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 !== "custom" ) { throw new Error("Expected a Custom 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 SubscriptionCart = { currency: string; initialOneTimePriceId?: string; quantity: number; recurringPriceId: string;};
type CurrentSubscriptionOffer = { available: boolean; currency: string; eligible: boolean; initialOneTimePriceId?: string; recurringPriceId: string;};
type ValidatedSubscriptionOffer = { lineItems: Array<{ price: string; quantity: number; }>;};
const configuredRecurringPriceId = process.env.STRIPE_RECURRING_PRICE_ID;if (!configuredRecurringPriceId) { throw new Error("STRIPE_RECURRING_PRICE_ID is required.");}const configuredInitialOneTimePriceId = process.env.STRIPE_INITIAL_ONE_TIME_PRICE_ID;
// Replace this example with plans loaded from your server-owned catalog.const currentSubscriptionOffers = new Map<string, CurrentSubscriptionOffer>([ [ configuredRecurringPriceId, { available: true, currency: "usd", eligible: true, ...(configuredInitialOneTimePriceId ? { initialOneTimePriceId: configuredInitialOneTimePriceId } : {}), recurringPriceId: configuredRecurringPriceId, }, ],]);
export const subscriptionCartSchema = { parse(value: unknown): SubscriptionCart { if ( typeof value !== "object" || value === null || !("currency" in value) || typeof value.currency !== "string" || !("quantity" in value) || typeof value.quantity !== "number" || !Number.isInteger(value.quantity) || value.quantity < 1 || !("recurringPriceId" in value) || typeof value.recurringPriceId !== "string" ) { throw new Error("Cart does not match the subscription cart shape."); }
const initialOneTimePriceId = "initialOneTimePriceId" in value ? value.initialOneTimePriceId : undefined; if ( initialOneTimePriceId !== undefined && typeof initialOneTimePriceId !== "string" ) { throw new Error("Cart does not match the subscription cart shape."); }
return { currency: value.currency, ...(initialOneTimePriceId === undefined ? {} : { initialOneTimePriceId }), quantity: value.quantity, recurringPriceId: value.recurringPriceId, }; },};
export function validateCurrentSubscriptionOffer( cart: SubscriptionCart,): ValidatedSubscriptionOffer { const offer = currentSubscriptionOffers.get(cart.recurringPriceId); if (!offer || !offer.available || !offer.eligible) { throw new Error("This subscription offer is no longer available."); } if (offer.currency !== cart.currency) { throw new Error("This subscription offer changed. Refresh the cart before continuing."); } if (offer.initialOneTimePriceId !== cart.initialOneTimePriceId) { throw new Error("This subscription offer changed. Refresh the cart before continuing."); }
return { lineItems: [ { price: offer.recurringPriceId, quantity: cart.quantity }, ...(offer.initialOneTimePriceId ? [{ price: offer.initialOneTimePriceId, quantity: 1 }] : []), ], };}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.
Frontend
Section titled “Frontend”Parse the Proxy session ID and request the Custom Checkout state from your backend.
import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type CustomSubscriptionOpening = | { 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 openCustomSubscription(): Promise<CustomSubscriptionOpening | null> { const proxySessionId = parseProxySessionIdFromUrl(window.location.href); if (!proxySessionId) return null;
const response = await fetch( `/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/custom-subscription`, { method: "POST" }, ); if (!response.ok) return null;
return (await response.json()) as CustomSubscriptionOpening;}Mount Custom Checkout only for the ready outcome. Set return_url when your backend creates the Checkout Session. Do not also pass returnUrl to checkout.confirm().
import { CheckoutProvider, PaymentElement, useCheckout } from "@stripe/react-stripe-js";import type { StripeCheckoutSession } from "@stripe/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 CheckoutForm({ onComplete,}: { onComplete: (session: StripeCheckoutSession) => void;}) { 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; }
onComplete(confirmation.session); }
return ( <form onSubmit={submit}> <p>Due now: <output>{checkout.total.total.amount}</output></p> <PaymentElement /> <button disabled={submitting || !checkout.canConfirm} type="submit"> {submitting ? "Submitting…" : "Subscribe"} </button> {error ? <p role="alert">{error}</p> : null} </form> );}
export function CustomSubscriptionForm({ clientSecret, onComplete,}: { clientSecret: string; onComplete: (session: StripeCheckoutSession) => void;}) { const options = useMemo( () => ({ fetchClientSecret: async () => clientSecret }), [clientSecret], );
return ( <CheckoutProvider stripe={stripePromise} options={options}> <CheckoutForm onComplete={onComplete} /> </CheckoutProvider> );}Use onComplete to update the payer UI after a successful non-redirect confirmation. The returned session does not authorize an entitlement change.
Handle outcomes
Section titled “Handle outcomes”Handle every response from openCustomSubscription 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 Custom 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. |
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. - 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.
Test the integration
Section titled “Test the integration”- Create one handoff and confirm Custom Checkout shows the expected recurring plan and initial amount.
- Open the handoff in two browser sessions. Confirm both mount the same Checkout Session.
- Complete a Stripe test subscription in one browser. Confirm the other browser receives
already_paidwithout creating another Subscription. - Confirm the signed Proxy event causes one entitlement change. Redeliver the event and confirm the change does not repeat.
- 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.