Skip to content

Stripe Elements

Use this path when a Checkout Session creates the PaymentIntent and your page renders Custom Checkout with Elements. This path’s technical ID is OT-CO-C.

Custom Checkout still uses a Checkout Session. If your server creates the PaymentIntent directly, use Direct PaymentIntent. To mount Stripe’s complete Checkout UI, use Embedded Stripe Checkout.

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-C --format json

Install Stripe 18.0.0 or newer:

npm install stripe@">=18.0.0"

Install the Stripe browser packages before copying the client example. These exact versions come from the same canonical capability manifest as stripe doctor.

npm install --save-exact @stripe/stripe-js@7.0.0 @stripe/react-stripe-js@3.6.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 and a server-only Stripe client configured for Custom Checkout.

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: "2025-03-31.basil",
});
function requireOrderEmail(proxySession: {
beneficiaryContact: { email: string | null } | null;
}) {
const email = proxySession.beneficiaryContact?.email;
if (!email) throw new Error("Custom Checkout requires an order email.");
return email;
}
// POST /api/proxy/sessions/:proxySessionId/custom-checkout
export async function POST(
_request: Request,
{ params }: { params: { proxySessionId: string } },
) {
const checkout = await openCheckout({
commercialMode: "one_time",
compatibility: {
apiVersion: "2025-03-31.basil",
browserSdkVersion: "7.0.0",
reactSdkVersion: "3.6.0",
serverSdkVersion: "18.0.0",
},
pricingMode: "fixed",
proxy,
proxySessionId: params.proxySessionId,
stripe,
uiMode: "custom",
validateCart: ({ cart }) => validateCurrentOffer(cart),
buildCheckoutSessionParams: ({ offer, proxySession }) => ({
customer_email: requireOrderEmail(proxySession),
line_items: offer.lineItems,
mode: "payment",
return_url: `https://example.com/pay/return?proxy_session_id=${encodeURIComponent(params.proxySessionId)}`,
ui_mode: "custom",
}),
});
if (checkout.outcome !== "ready") {
return Response.json({
cart: checkout.cart,
outcome: checkout.outcome,
sessionStatus: checkout.sessionStatus,
});
}
if (
checkout.presentation?.kind !== "client_secret" ||
checkout.presentation.uiMode !== "custom"
) {
throw new Error("Expected a Custom Checkout client secret.");
}
return Response.json({
cart: checkout.cart,
clientSecret: checkout.presentation.clientSecret,
outcome: checkout.outcome,
});
}

Replace the sample offer map in server/types.ts with your server-owned catalog. Keep the same checks for the stored price, availability, and eligibility before creating Checkout.

Use the immutable beneficiary or shared billing email stored on the Proxy session. Do not use the current viewer’s email or call checkout.updateEmail() with viewer-specific data.

Keep STRIPE_SECRET_KEY in your backend. Pass the returned client secret to Stripe.js without decoding or modifying it.

Parse the Proxy session ID and request the Custom Checkout state from your backend.

import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type CustomCheckoutOpening =
| { cart: unknown; clientSecret: string; outcome: "ready" }
| {
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 openCustomCheckout(): Promise<CustomCheckoutOpening | null> {
const proxySessionId = parseProxySessionIdFromUrl(window.location.href);
if (!proxySessionId) return null;
const response = await fetch(
`/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/custom-checkout`,
{ method: "POST" },
);
if (!response.ok) return null;
return (await response.json()) as CustomCheckoutOpening;
}

Mount Custom Checkout only for the ready outcome. Set return_url when your backend creates the Checkout Session. Do not also pass returnUrl to checkout.confirm().

import { CheckoutProvider, PaymentElement, useCheckout } from "@stripe/react-stripe-js";
import type { StripeCheckoutSession } from "@stripe/stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { type SubmitEvent, useMemo, useState } from "react";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
function CheckoutForm({
onComplete,
}: {
onComplete: (session: StripeCheckoutSession) => void;
}) {
const checkout = useCheckout();
const [error, setError] = useState<string>();
const [submitting, setSubmitting] = useState(false);
async function submit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
setError(undefined);
setSubmitting(true);
const confirmation = await checkout.confirm({ redirect: "if_required" });
if (confirmation.type === "error") {
setError(confirmation.error.message);
setSubmitting(false);
return;
}
onComplete(confirmation.session);
}
return (
<form onSubmit={submit}>
<p>Total: <output>{checkout.total.total.amount}</output></p>
<PaymentElement />
<button disabled={submitting || !checkout.canConfirm} type="submit">
{submitting ? "Submitting…" : "Pay"}
</button>
{error ? <p role="alert">{error}</p> : null}
</form>
);
}
export function CustomCheckoutForm({
clientSecret,
onComplete,
}: {
clientSecret: string;
onComplete: (session: StripeCheckoutSession) => void;
}) {
const options = useMemo(
() => ({ fetchClientSecret: async () => clientSecret }),
[clientSecret],
);
return (
<CheckoutProvider stripe={stripePromise} options={options}>
<CheckoutForm onComplete={onComplete} />
</CheckoutProvider>
);
}

Use onComplete to update the payer UI after a successful non-redirect confirmation. The returned session does not authorize fulfillment.

Handle every response from openCustomCheckout explicitly.

Keep the cart visible for every outcome. Do not request another client secret after Proxy returns a terminal state.

Outcome Session status Payer experience
ready — Mount Custom Checkout with clientSecret.
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. Do not request another client secret.
action_required merchant_action_required Tell the payer that checkout is unavailable until the merchant resolves the issue. Do not mount Checkout.
unavailable cancelled, created, expired, or failed Show that checkout is unavailable. Do not mount Checkout.

An asynchronous payment can return processing after the Checkout presentation ends. Keep the pending state until Proxy receives a successful or failed Stripe event.

A fixed no-cost order requires Stripe request API 2023-08-16 or newer. This path’s minimum version meets that requirement. A verified no-cost Checkout Session has no PaymentIntent and earns no Proxy fee. Do not create a PaymentIntent or placeholder for a no-cost order.

  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. Make fulfillment idempotent. Enforce one fulfillment record for each resolved.session.id.

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.

Follow Cart updates when the payer changes the order. Mutable Custom Checkout fields can use syncCheckoutCart; immutable Session options require replacement.

  1. Create one handoff and confirm the payer page loads the expected cart.
  2. Open the handoff in two browser sessions. Confirm both can use the same Checkout Session.
  3. Complete a Stripe test payment in one browser. Confirm the other browser receives already_paid without creating another Session.
  4. Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.
  5. Exercise asynchronous payment, cancellation, action-required, and unavailable states without falling back to another presentation.

Review Stripe events and permissions for the exact events required by this path.

Before launch, review order identity and duplicate prevention.