Stripe-hosted Checkout
Use this path for one-time payments completed in Stripe-hosted Checkout. If your product already creates a PaymentIntent, needs subscriptions, or keeps Checkout inside your page, use the Stripe path chooser.
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 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-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 that URL. Proxy sends them to your configured checkout URL with the same proxy_session_id; the code below retrieves that session and opens Stripe Checkout.
Backend
Section titled “Backend”Return payer checkout state
Section titled “Return payer checkout state”Retrieve the Proxy session and return the checkout details that the payer page needs.
import { proxy } from "./proxy";
type CartSnapshot = { currency: string; lineItems: Array<{ amountMinor: number; name: string; quantity: number; }>;};
// GET /api/proxy/sessions/:proxySessionIdexport async function GET( _request: Request, { params }: { params: { proxySessionId: string } },) { const session = await proxy.sessions.retrieve(params.proxySessionId); const cart = session.cartSnapshot as CartSnapshot;
return Response.json({ cart, proxySessionId: session.id, status: session.status, });}import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";
export const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!, publishableKey: process.env.PROXY_PUBLISHABLE_KEY!,});To validate cartSnapshot at runtime and receive a typed cart, see Validate a cart snapshot with retrieveTyped.
Create or retrieve the Stripe Checkout Session
Section titled “Create or retrieve the Stripe Checkout Session”Create or retrieve the Stripe Checkout Session for the Proxy session.
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: "2022-11-15",});
// POST /api/proxy/sessions/:proxySessionId/checkout-sessionexport async function POST( _request: Request, { params }: { params: { proxySessionId: string } },) { const checkout = await openCheckout({ commercialMode: "one_time", compatibility: { apiVersion: "2022-11-15", serverSdkVersion: "12.18.0" }, pricingMode: "fixed", proxy, stripe, proxySessionId: params.proxySessionId, uiMode: "hosted", validateCart: ({ cart }) => validateCurrentOffer(cart), buildCheckoutSessionParams: ({ offer }) => ({ mode: "payment", 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 hosted Checkout redirect."); }
return Response.json({ 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 CartSnapshot = { currency: string; lineItems: Array<{ amountMinor: number; name: string; offerId: string; quantity: number; }>;};
type CurrentOffer = { amountMinor: number; available: boolean; currency: string; eligible: boolean; name: string;};
type ValidatedOffer = { lineItems: Array<{ price_data: { currency: string; product_data: { name: string }; unit_amount: number; }; quantity: number; }>;};
// Replace this example with offers loaded from your server-owned catalog.const currentOffers = new Map<string, CurrentOffer>([ [ "annual-membership", { amountMinor: 5000, available: true, currency: "usd", eligible: true, name: "Annual membership", }, ],]);
function parseCartSnapshot(value: unknown): CartSnapshot { if ( typeof value !== "object" || value === null || !("currency" in value) || typeof value.currency !== "string" || !("lineItems" in value) || !Array.isArray(value.lineItems) ) { throw new Error("Cart does not match the one-time checkout shape."); }
const lineItems = value.lineItems.map((item) => { if ( typeof item !== "object" || item === null || !("amountMinor" in item) || typeof item.amountMinor !== "number" || !Number.isInteger(item.amountMinor) || item.amountMinor < 0 || !("name" in item) || typeof item.name !== "string" || !("offerId" in item) || typeof item.offerId !== "string" || !("quantity" in item) || typeof item.quantity !== "number" || !Number.isInteger(item.quantity) || item.quantity < 1 ) { throw new Error("Cart does not match the one-time checkout shape."); }
return { amountMinor: item.amountMinor, name: item.name, offerId: item.offerId, quantity: item.quantity, }; });
return { currency: value.currency, lineItems };}
export function validateCurrentOffer(value: unknown): ValidatedOffer { const cart = parseCartSnapshot(value);
const lineItems = cart.lineItems.map((item) => { const offer = currentOffers.get(item.offerId); if (!offer || !offer.available || !offer.eligible) { throw new Error("This offer is no longer available."); } if ( offer.amountMinor !== item.amountMinor || offer.currency !== cart.currency || offer.name !== item.name ) { throw new Error("This offer changed. Refresh the cart before continuing."); }
return { price_data: { currency: offer.currency, product_data: { name: offer.name }, unit_amount: offer.amountMinor, }, quantity: item.quantity, }; });
return { lineItems };}Replace the sample offer map in server/types.ts with your server-owned catalog. Keep the same checks for stored prices, availability, and eligibility before creating Checkout.
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.
Callers select Proxy uiMode: "hosted" but omit Stripe’s provider ui_mode. Stripe 12.18.0 and newer supported versions default to hosted, and Proxy normalizes hosted_page responses. Hosted Checkout uses success_url, not return_url, and returns a redirect URL rather than a Checkout client secret.
Frontend
Section titled “Frontend”Parse the Proxy session ID and load the checkout state from your backend. When the payer is ready, request the Stripe-hosted redirect.
import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
async function loadPayerCheckout() { const proxySessionId = parseProxySessionIdFromUrl(window.location.href);
if (!proxySessionId) { return null; }
const response = await fetch( `/api/proxy/sessions/${encodeURIComponent(proxySessionId)}`, );
if (!response.ok) { return null; }
const checkout = await response.json(); return checkout;}
async function openStripeCheckout(proxySessionId: string) { const response = await fetch( `/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/checkout-session`, { method: "POST" }, );
if (!response.ok) { return null; }
const opened = (await response.json()) as | { 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"; };
if (opened.outcome === "ready" && "redirectUrl" in opened) { location.assign(opened.redirectUrl); } return opened;}The only Proxy-specific browser input is proxy_session_id. Handle each backend outcome explicitly:
| Outcome | Session status | Payer experience |
|---|---|---|
ready |
— | Redirect to redirectUrl. |
already_paid |
paid, provisionable, provisioned, or provisioning_failed |
Show that payment is complete. Use the session status to show fulfillment progress. 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 create another Checkout Session. |
action_required |
merchant_action_required |
Tell the payer that checkout is unavailable until the merchant resolves the issue. Do not start Stripe. |
unavailable |
cancelled, created, expired, or failed |
Show that checkout is unavailable. Do not start Stripe. |
Receive Proxy events in your backend
Section titled “Receive Proxy events in your backend”Configure an endpoint in your backend to receive Proxy events before accepting production payments. Use these signed events to mark an order paid, fulfill the order, or grant access. This is separate from the Stripe endpoint that sends provider events to Proxy during Stripe setup.
- Follow Fulfillment and entitlements to configure the 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. - Make fulfillment idempotent. Enforce one fulfillment record for each
resolved.session.id.
If payment grants access, use resolved.session.buyerReference to find the merchant-owned profile or pending entitlement. Do not assume the payer receives access.
For the same delegated purchase, do not run a second fulfillment path from a raw Stripe webhook.
Test the integration
Section titled “Test the integration”- Create one handoff from the buyer checkout. Confirm the payer page loads the expected cart.
- Open the handoff in two browser sessions. Confirm your backend creates only one Stripe Checkout Session.
- Complete a Stripe test payment in one browser. Confirm the other browser receives
already_paidwithout creating another Checkout Session. - Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.
- Cancel a separate test Checkout. Confirm Stripe returns the payer with the same
proxy_session_id.