Stripe setup and hosted Checkout quickstart
This guide configures Proxy with Stripe and implements the recommended starting path: a one-time payment that redirects the payer to Stripe-hosted Checkout. Use it when you are starting fresh or do not need to keep payment inside your own page.
If your product already creates a PaymentIntent, needs subscriptions, or keeps Checkout inside your page, first use the Stripe path chooser. It will send you to the guide that matches your existing payment flow.
Before You Code
Section titled “Before You Code”Before using any SDKs, connect Stripe, create Proxy API keys, and configure the handoff experience. Configure the webhook promptly so Proxy can observe lifecycle changes.
This quickstart’s technical path ID is OT-CO-H. The Proxy CLI requires Node.js
22 or newer. Confirm the runtime, install the public CLI, then inspect its
canonical compatibility requirements:
node --versionnpm install --global @proxy-checkout/cliproxy stripe doctor --path OT-CO-H --format jsonOne Stripe configuration with valid credentials includes every implemented one-time and subscription path; no Proxy operator enables paths individually. Webhook endpoint verification reports setup health but never disables an acquisition path. Validate in Stripe test mode; live mode requires separate approval.
Connect Your Payment Provider
Section titled “Connect Your Payment Provider”Navigate to the payment providers section of your dashboard. In the Stripe section, enter the following:
- Stripe account ID (found here)
- Your Stripe publishable key
- A dedicated Stripe restricted key for Proxy reconciliation
In Stripe’s API keys settings, create a restricted key with this exact manifest:
| Stripe resource | Access |
|---|---|
| Checkout Sessions | Read |
| Invoices | Read |
| Payment Intents | Read |
| Setup Intents | Read |
| Subscriptions | Read |
| Webhook Endpoints | Read |
Leave every other Stripe permission set to None. Permission manifest v2.
Proxy uses this credential only for the exact read operations required to reconcile PaymentIntents, hosted/embedded/custom Checkout Sessions, SetupIntents, Subscriptions, and Invoices and to check the configured Webhook Endpoint. It is separate from:
- your application’s server-side Stripe key used to create SetupIntents, PaymentIntents, Checkout Sessions, or subscriptions;
- the Stripe publishable key used by Stripe Elements; and
- the webhook signing secret copied from the Stripe webhook endpoint.
Use Stripe keys that match the current Proxy merchant mode. Test merchants require rk_test_ plus pk_test_ if you provide a publishable key. Live merchants require rk_live_ plus pk_live_. Set up test and live payment providers separately, including separate restricted keys, Stripe webhook endpoints, and signing secrets.
Proxy runs the exact list probes for the capabilities being configured before saving the connection. An invalid or under-permissioned key is rejected immediately with per-operation setup guidance; response bodies and provider objects are never persisted by the setup check.
The permission table above is the complete default profile for every implemented one-time and subscription path. Direct SetupIntent and saved-method subscriptions do not grant Proxy Customer or Payment Methods access. See Stripe direct subscriptions or Stripe-hosted subscriptions for the operations and events used by those paths.
If you connected Stripe before direct subscriptions became generally available, re-save or rotate the restricted key once before using those paths. Proxy validates the expanded read profile and then makes both direct-subscription branches available; webhook endpoint verification remains advisory.
Connect a Proxy Webhook on Your PSP
Section titled “Connect a Proxy Webhook on Your PSP”Webhook setup is strongly recommended but is not an activation gate. An unverified endpoint remains visibly action_required in Proxy while every implemented acquisition path stays available. Without the required events, Proxy may not observe payment or subscription lifecycle changes, so its lifecycle and reconciliation state can remain stale.
In Proxy’s payment providers dashboard, add a webhook config and enter a route key. Proxy shows the webhook URL as soon as the route key is set. Copy that URL, but keep the Proxy form open.
Go to Stripe’s Webhooks dashboard and create a new webhook endpoint with the following settings:
| Setting | Value |
|---|---|
| Event destination scope | Your account |
| API version | 2022-11-15 for the exact classic Elements lane, or another version supported by the generated capability profile |
| Events | checkout.session.async_payment_failedcheckout.session.async_payment_succeededcheckout.session.completedcheckout.session.expiredpayment_intent.canceledpayment_intent.payment_failedpayment_intent.processingpayment_intent.succeeded |
| Destination type | Webhook endpoint |
| Endpoint URL | The webhook URL copied from Proxy |
After Stripe creates the endpoint, copy the new webhook’s signing secret. Paste it into the Proxy webhook secret field and create the webhook config.
That event list is the Checkout quickstart baseline, not the complete setup profile. Configure the exact sorted event union shown by Proxy for the full implemented path set plus any optional observations. Direct subscriptions add SetupIntent progress, trial ending, payment action required, Invoice finalization/uncollectible/voided, Subscription creation, and complete lifecycle events as listed in Stripe direct subscriptions. proxy listen --stripe derives the same union from the configuration and rejects a stale profile.
Create Proxy API Keys
Section titled “Create Proxy API Keys”Head over to the API Keys section of your Proxy dashboard and create a secret key and a publishable key. Our SDKs will use these keys to connect to Proxy.
Configure Hosted Handoff
Section titled “Configure Hosted Handoff”A handoff is the Proxy-hosted payment link the buyer shares when someone else needs to pay. Proxy uses this setup to know what name to show on that link and where to send the payer after they open it.
In the Hosted handoff section, save these required fields:
- Display name: the merchant or product name shown to the payer
- Default checkout URL: the default page in your app where the payer completes checkout
- Allowed destination hosts: the domains Proxy is allowed to forward payers to
The default checkout URL’s domain must be included in Allowed destination hosts. Save this before creating payment links; without it, Proxy cannot create a shareable handoff link.
See Hosted Handoff for the Logo, Title, Description, and Site name fields, plus custom-domain preview behavior.
Install SDKs
Section titled “Install SDKs”For your backend apps:
npm install @proxy-checkout/server-js @proxy-checkout/stripe-server-jspnpm add @proxy-checkout/server-js @proxy-checkout/stripe-server-jsyarn add @proxy-checkout/server-js @proxy-checkout/stripe-server-jsWe currently don’t have SDKs for other languages. Refer to the API reference for the available endpoints.
The selected terminal section also provides a stripe install command pinned
to the server_sdk version reported by stripe doctor. Run it before copying
that path’s backend example; do not install an unversioned current Stripe SDK
and report an older compatibility lane.
For a hosted Checkout frontend, install the Proxy client:
npm install @proxy-checkout/client-jspnpm add @proxy-checkout/client-jsyarn add @proxy-checkout/client-jsEmbedded and custom Checkout also need Stripe’s browser packages. The selected
terminal section provides exact install commands generated from the same
canonical versions as stripe doctor; run them before copying its client
example.
Create the Handoff Link
Section titled “Create the Handoff Link”Start in the checkout page the buyer already uses. Proxy does not render this button for you. Add your own delegated payment button and call your backend when it is clicked.
type HandoffResponse = { expiresAt: string; handoffUrl: string; proxySessionId: string;};
async function startDelegatedPayment( payerEmail?: string,): Promise<HandoffResponse | null> { const response = await fetch("/api/proxy/handoffs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ payerEmail }), });
if (!response.ok) { // fallback return null; }
const handoff = (await response.json()) as HandoffResponse; // Present the url to the user. Whether you copy it to clipboard, // show a modal, or open up a mobile share sheet is your decision. return handoff;}Create the Proxy client in server-only code. The selected terminal guide creates its Stripe client with that path’s generated request API version; do not create one shared Stripe client with the hosted path’s older version.
import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";
export const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!, publishableKey: process.env.PROXY_PUBLISHABLE_KEY!,});Then implement the endpoint your button calls. Authenticate the buyer, load the current cart, pass that cart snapshot to Proxy, then ask Proxy for a shareable handoff link.
// POST /api/proxy/handoffsexport async function POST(request: Request) { const { payerEmail } = (await request.json()) as { payerEmail?: string };
// Use the buyer from your current checkout session. const buyer = { email: "recipient@example.com", id: "buyer_123" }; // Load a durable merchant-owned purchase-request row created for this // intended order. Reuse its id for every retry and every invited payer. const purchaseRequest = { id: "purchase_request_123" }; // Use the cart shown in your checkout. const cartSnapshot = { currency: "usd", lineItems: [ { name: "Annual membership", amountMinor: 5000, quantity: 1, }, ], }; const amountMinor = cartSnapshot.lineItems.reduce( (total, item) => total + item.amountMinor * item.quantity, 0, );
const handoff = await proxy.sessions.createHandoff({ amountMinor, beneficiaryContact: { email: buyer.email }, buyerReference: buyer.id, cartSnapshot, currency: cartSnapshot.currency, idempotencyKey: `proxy-session:${purchaseRequest.id}`, payerContact: payerEmail ? { email: payerEmail } : undefined, });
return Response.json({ expiresAt: handoff.expiresAt, handoffUrl: handoff.handoffUrl, proxySessionId: handoff.id, });}The cart snapshot is not a replacement for your pricing logic. Treat it as checkout context that Proxy returns later, then re-check price, eligibility, and availability before creating the Stripe Checkout Session.
Persist handoff.id and handoff.handoffUrl on the purchase-request row before sharing them. buyerReference and the session idempotency key answer different questions: buyerReference identifies who receives the entitlement, while the durable purchase-request id identifies the one intended order/Proxy session. Reuse the same purchase-request id for retries and for every parent or other payer invited to that order. Use a new purchase-request id for a genuinely new purchase, even when buyer, offer, and amount are identical. Do not use email, Stripe Customer id, payer identity, amount alone, a random value regenerated on retry, or buyerReference alone as the order key.
buyerReference should be a stable, non-PII reference to the entitlement owner in your system. Use an existing account/profile id for logged-in buyers. For pre-account purchases, create a pending entitlement record first and use that pending entitlement id as buyerReference; do not use an email address as the reference.
If you selected a subscription
Section titled “If you selected a subscription”The handoff example above stores a one-time cart. Before calling
createHandoff, replace that cartSnapshot with the subscription shape used by
your selected terminal guide. Load Stripe Price IDs from trusted server
configuration or your merchant-owned catalog—never accept them from the payer
browser.
For a subscription created by Stripe Checkout, store the recurring Price. Add the one-time Price only when the first invoice actually includes one:
const amountMinor = 7500; // Calculate from your trusted catalog.const initialOneTimePriceId = process.env.STRIPE_SETUP_PRICE_ID;const cartSnapshot = { currency: "usd", recurringPriceId: process.env.STRIPE_RECURRING_PRICE_ID!, ...(initialOneTimePriceId ? { initialOneTimePriceId } : {}), quantity: 1,};For a direct subscription using a SetupIntent or an already-authorized saved payment method, store the fixed recurring lines:
const amountMinor = 5000; // Calculate from your trusted catalog.const cartSnapshot = { currency: "usd", lines: [ { priceId: process.env.STRIPE_RECURRING_PRICE_ID!, quantity: 1, }, ], trialDays: 0,};Use the initial amount expected for this purchase; use 0 when a supported
free trial or zero-value initial invoice makes nothing due now. The terminal
guide revalidates the stored Price IDs, quantities, eligibility, and current
pricing before creating anything in Stripe.
Build the Payer Checkout
Section titled “Build the Payer Checkout”When the payer opens the handoff link, Proxy sends them to the default checkout URL you configured in the dashboard with a proxy_session_id query parameter. This example opens OT-CO-H and redirects to Stripe’s hosted one-time Checkout page.
Parse the Proxy session ID, load the checkout state from your backend, then ask your backend for the hosted redirect when the payer is ready to pay.
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" | "action_required" | "processing" | "unavailable"; sessionStatus: string; };
if (opened.outcome === "ready" && "redirectUrl" in opened) { location.assign(opened.redirectUrl); } return opened;}The only Proxy-specific browser input is proxy_session_id. Redirect only for ready. Render already_paid as an explicit completed order with the returned stable cart; render other terminal states without starting Stripe. The Checkout success URL is navigation, not fulfillment authority.
First, return payer-safe checkout state. Retrieve the Proxy session with your secret key, parse the cart snapshot Proxy returns, and return only the checkout details the payer page needs.
Read the returned cart snapshot and rebuild public checkout details from your source of truth.
// GET /api/proxy/sessions/:proxySessionIdtype CartSnapshot = { currency: string; lineItems: Array<{ name: string; amountMinor: number; quantity: number; }>;};
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, });}Then create or retrieve the Stripe Checkout Session for this Proxy session.
// POST /api/proxy/sessions/:proxySessionId/checkout-sessionimport { openCheckout } from "@proxy-checkout/stripe-server-js";import Stripe from "stripe";
// This guide selected OT-CO-H. Use the request API reported by// `proxy stripe doctor` for a different selected path.const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2022-11-15",});
type CartSnapshot = { currency: string; lineItems: Array<{ name: string; amountMinor: number; quantity: number; }>;};
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", buildCheckoutSessionParams: ({ cart }) => { const checkoutCart = cart as CartSnapshot;
return { mode: "payment", line_items: checkoutCart.lineItems.map((item) => ({ price_data: { currency: checkoutCart.currency, product_data: { name: item.name }, unit_amount: item.amountMinor, }, quantity: item.quantity, })), 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 });}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.