Skip to content

Direct Stripe PaymentIntent

Use this path when your server creates a one-time PaymentIntent and your application renders the payment UI. If a Checkout Session creates the PaymentIntent, 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-PI --format json

Install Stripe 12.18.0 or newer:

npm install stripe@">=12.18.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@2.4.0 @stripe/react-stripe-js@2.9.0

Direct PaymentIntent requires an amount greater than zero. For a no-cost order, choose a Stripe Checkout path with request API 2023-08-16 or newer.

When the payer opens the handoff, Proxy redirects them to your configured checkout URL with the same proxy_session_id.

Call openPaymentIntent with the Proxy session ID and your server-only Stripe client.

server/route.ts
import { openPaymentIntent } from "@proxy-checkout/stripe-server-js";
import Stripe from "stripe";
import { proxy } from "./proxy";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2022-11-15",
});
// POST /api/proxy/sessions/:proxySessionId/payment-intent
export async function POST(
_request: Request,
{ params }: { params: { proxySessionId: string } },
) {
const opened = await openPaymentIntent({
buildPaymentIntentParams: () => ({
automatic_payment_methods: { enabled: true },
}),
compatibility: { apiVersion: "2022-11-15", serverSdkVersion: "12.18.0" },
proxy,
proxySessionId: params.proxySessionId,
stripe,
});
if (opened.outcome !== "ready") {
return Response.json({
cart: opened.cart,
outcome: opened.outcome,
sessionStatus: opened.sessionStatus,
});
}
return Response.json({
cart: opened.cart,
clientSecret: opened.clientSecret,
outcome: opened.outcome,
paymentIntentId: opened.paymentIntentId,
status: opened.status,
});
}

Keep STRIPE_SECRET_KEY in your backend. Do not send the Stripe client, secret, or PaymentIntent parameters to the browser.

Do not pass amount, currency, Proxy metadata, capture_method, Connect fields, or an idempotency key. The helper derives the cart amount and currency, requires automatic capture, injects Proxy metadata, and owns the Stripe idempotency key.

Call openPaymentIntent for every retry. Concurrent calls use the same Stripe idempotency key and converge on one PaymentIntent.

Retry with the same normalized provider options. Changed options fail with a configuration conflict instead of joining the existing PaymentIntent.

Omit merchandiseSubtotalMinor to use the verified Stripe gross amount as the percentage fee basis:

await openPaymentIntent({
// ...normal options
buildPaymentIntentParams: () => ({ automatic_payment_methods: { enabled: true } }),
});

If your server has an immutable merchandise subtotal, pass it to exclude non-merchandise amounts from the percentage fee:

await openPaymentIntent({
// ...normal options
merchandiseSubtotalMinor: order.merchandiseSubtotalMinor,
buildPaymentIntentParams: () => ({ automatic_payment_methods: { enabled: true } }),
});

The value is fixed at acquisition reservation. A retry cannot replace it. Never accept it from the browser. See Stripe fees for fee calculation and review thresholds.

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

import { parseProxySessionIdFromUrl } from "@proxy-checkout/client-js";
type PaymentIntentOpening =
| {
cart: unknown;
clientSecret: string;
outcome: "ready";
paymentIntentId: string;
status:
| "canceled"
| "processing"
| "requires_action"
| "requires_capture"
| "requires_confirmation"
| "requires_payment_method"
| "succeeded";
}
| {
cart: unknown;
outcome: "already_paid";
sessionStatus: "paid" | "provisionable" | "provisioned" | "provisioning_failed";
}
| {
cart: unknown;
outcome: "action_required";
sessionStatus: "merchant_action_required";
}
| {
cart: unknown;
outcome: "unavailable";
sessionStatus: "cancelled" | "created" | "expired" | "failed";
};
async function openStripePaymentIntent(): Promise<PaymentIntentOpening | null> {
const proxySessionId = parseProxySessionIdFromUrl(window.location.href);
if (!proxySessionId) return null;
const response = await fetch(
`/api/proxy/sessions/${encodeURIComponent(proxySessionId)}/payment-intent`,
{ method: "POST" },
);
if (!response.ok) return null;
return (await response.json()) as PaymentIntentOpening;
}

The only Proxy-specific browser input is proxy_session_id. Handle each Proxy outcome explicitly:

Outcome Session status Payer experience
ready — Use status and clientSecret in your existing payment UI. Keep the cart visible.
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 PaymentIntent.
action_required merchant_action_required Tell the payer that payment is unavailable until the merchant resolves the issue. Do not confirm the PaymentIntent.
unavailable cancelled, created, expired, or failed Show that payment is unavailable. Do not create another PaymentIntent.

Keep the cart visible for every viewer. Do not hide it or create another PaymentIntent:

if (checkout.outcome === "already_paid") {
return <CompletedOrder cart={checkout.cart} status={checkout.sessionStatus} />;
}
if (checkout.outcome !== "ready") {
return <UnavailableOrder cart={checkout.cart} status={checkout.sessionStatus} />;
}
return <PaymentIntentForm clientSecret={checkout.clientSecret} />;

The ready result also contains the Stripe PaymentIntent status. Keep requires_action, processing, and requires_payment_method in your existing Stripe flow. Do not treat these Stripe statuses as Proxy outcomes or create another PaymentIntent for them.

Only supersede an acquisition after Proxy records provider-confirmed cancellation, terminal failure, or a not-found result for its attached PaymentIntent. Do not supersede an unattached acquisition after a timeout, browser result, or ambiguous create response.

Configure your existing Elements provider with clientSecret. Preserve proxy_session_id in the return URL for redirect-based payment methods.

import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
export function PayButton({ proxySessionId }: { proxySessionId: string }) {
const stripe = useStripe();
const elements = useElements();
return (
<form
onSubmit={async (event) => {
event.preventDefault();
if (!stripe || !elements) return;
const returnUrl = new URL("/pay/return", window.location.origin);
returnUrl.searchParams.set("proxy_session_id", proxySessionId);
await stripe.confirmPayment({
elements,
confirmParams: { return_url: returnUrl.toString() },
});
}}
>
<PaymentElement />
<button disabled={!stripe || !elements}>Pay</button>
</form>
);
}

The browser result controls the payer experience. It does not prove payment or authorize fulfillment.

Configure an endpoint in your backend to receive Proxy events before accepting production payments. Stripe sends provider events to Proxy through the endpoint configured 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.

The OT-PI path uses proxy_session.paid for initial fulfillment. Use the diagnostic output for the complete current event set.

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 PaymentIntent.
  3. Complete one Stripe test payment. Confirm the other browser receives already_paid without creating another PaymentIntent.
  4. Exercise Stripe action-required, processing, failure, cancellation, and retry states on the same PaymentIntent.
  5. Confirm a redirect payment method returns with the same proxy_session_id.
  6. Confirm the signed Proxy event causes one fulfillment change. Redeliver the event and confirm the change does not repeat.