Skip to content

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.

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 json

Install Stripe 12.18.0 or newer:

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

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 = "2022-11-15";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: stripeApiVersion,
});
// POST /api/proxy/sessions/:proxySessionId/hosted-subscription
export 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,
});
}

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

  1. Create one handoff and confirm Stripe Checkout shows the expected recurring plan and initial amount.
  2. Open the handoff in two browser sessions. Confirm both receive 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 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.