Stripe-hosted Checkout subscriptions
Use this path when Stripe Checkout should create the Subscription and host the payment form. This path’s technical ID is SUB-CO-H.
For Checkout embedded in your page, use Embedded Checkout subscriptions. For an Elements-based payment form, use Stripe Elements 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-H --format jsonInstall Stripe 12.18.0 or newer:
npm install stripe@">=12.18.0"pnpm add stripe@">=12.18.0"yarn add stripe@">=12.18.0"This 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 = "2022-11-15";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: stripeApiVersion,});
// POST /api/proxy/sessions/:proxySessionId/hosted-subscriptionexport async function POST( _request: Request, { params }: { params: { proxySessionId: string } },) { const checkout = await openCheckout({ cartSchema: subscriptionCartSchema, commercialMode: "subscription", compatibility: { apiVersion: stripeApiVersion, }, pricingMode: "fixed", proxy, proxySessionId: params.proxySessionId, stripe, uiMode: "hosted", validateCart: ({ cart }) => validateCurrentSubscriptionOffer(cart), buildCheckoutSessionParams: ({ offer }) => ({ mode: "subscription", line_items: offer.lineItems, success_url: `https://example.com/checkout/complete?proxy_session_id=${encodeURIComponent(params.proxySessionId)}`, cancel_url: `https://example.com/checkout?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 !== "redirect") { throw new Error("Expected a hosted Checkout redirect."); }
return Response.json({ cart: checkout.cart, outcome: checkout.outcome, redirectUrl: checkout.presentation.url, });}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 ask your backend to open Stripe Checkout.
import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type HostedSubscriptionOpening = | { cart: unknown; outcome: "ready"; redirectUrl: string } | { 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 openHostedSubscription(): Promise<HostedSubscriptionOpening | null> { const proxySessionId = parseProxySessionIdFromUrl(window.location.href); if (!proxySessionId) return null;
const response = await fetch( `/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/hosted-subscription`, { method: "POST" }, ); if (!response.ok) return null;
const checkout = (await response.json()) as HostedSubscriptionOpening; if (checkout.outcome === "ready") { location.assign(checkout.redirectUrl); } return checkout;}Handle outcomes
Section titled “Handle outcomes”Handle every backend response explicitly. Keep the cart visible, and do not request another Checkout Session after Proxy returns a terminal state.
| Outcome | Session status | Payer experience |
|---|---|---|
ready |
— | Redirect to redirectUrl. |
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. |
unavailable |
cancelled, created, expired, or failed |
Show that checkout is unavailable. |
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 redirect 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 Stripe Checkout shows the expected recurring plan and initial amount.
- Open the handoff in two browser sessions. Confirm both receive 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 cancellation and the trial or zero-initial-Invoice outcome your product supports.
If payers can change the plan, quantity, discount, or another order detail after Checkout opens, follow Cart updates.