Skip to content

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.

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 json

Install Stripe 12.18.0 or newer:

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

Retrieve the Proxy session and return the checkout details that the payer page needs.

server/route.ts
import { proxy } from "./proxy";
type CartSnapshot = {
currency: string;
lineItems: Array<{
amountMinor: number;
name: string;
quantity: number;
}>;
};
// GET /api/proxy/sessions/:proxySessionId
export 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,
});
}

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.

server/route.ts
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-session
export 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 });
}

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.

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.

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.

  1. Follow Fulfillment and entitlements to configure the 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. 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.

  1. Create one handoff from the buyer checkout. Confirm the payer page loads the expected cart.
  2. Open the handoff in two browser sessions. Confirm your backend creates only one Stripe Checkout Session.
  3. Complete a Stripe test payment in one browser. Confirm the other browser receives already_paid without creating another Checkout Session.
  4. Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.
  5. Cancel a separate test Checkout. Confirm Stripe returns the payer with the same proxy_session_id.