Direct Stripe PaymentIntent
Choose this guide when your server already creates a one-time PaymentIntent and your application renders Elements, Payment Element, PaymentSheet, or another merchant-owned confirmation UI. If a Checkout Session creates the PaymentIntent, return to the Stripe flow chooser.
The technical ID for this selected path is OT-PI:
proxy stripe doctor --path OT-PI --format jsonInstall the Stripe server package before copying this path's backend example. The exact version comes from the same canonical capability manifest as stripe doctor.
npm install --save-exact stripe@12.18.0
pnpm add --save-exact stripe@12.18.0
yarn add --exact stripe@12.18.0
Your backend injects its own Stripe client into openPaymentIntent. The helper
reserves or joins Proxy before Stripe creation, injects the required metadata,
uses the acquisition’s deterministic Stripe idempotency key, attaches or
retrieves the known object, and confirms the exact cart version.
Credential ownership
Section titled “Credential ownership”- Keep your Stripe create/edit secret only in your merchant backend. It constructs the injected
stripeclient and is never sent to or stored by Proxy. - Give Proxy the separate restricted key with the exact combined read permissions shown in Portal for every implemented path. Proxy uses it for reconciliation and endpoint verification.
- Never send
merchandiseSubtotalMinor, a Stripe secret, or a PaymentIntent create parameter from the browser.
Backend preparation
Section titled “Backend preparation”import Stripe from "stripe";import { createProxyCheckoutServerClient } from "@proxy-checkout/server-js";import { openPaymentIntent } from "@proxy-checkout/stripe-server-js";
const proxy = createProxyCheckoutServerClient({ apiKey: process.env.PROXY_SECRET_KEY!,});
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2022-11-15",});
export async function POST(request: Request) { const { proxySessionId } = (await request.json()) as { proxySessionId: string };
const opened = await openPaymentIntent({ compatibility: { apiVersion: "2022-11-15", serverSdkVersion: "12.18.0", }, proxy, proxySessionId, stripe, buildPaymentIntentParams: ({ cart }) => ({ automatic_payment_methods: { enabled: true }, description: displayDescriptionFor(cart), }), });
if (opened.outcome !== "ready") { return Response.json({ cart: opened.cart, outcome: opened.outcome, sessionStatus: opened.sessionStatus, }); }
return Response.json({ acquisitionAttemptId: opened.acquisitionAttemptId, clientSecret: opened.clientSecret, outcome: opened.outcome, paymentIntentId: opened.paymentIntentId, status: opened.status, });}Do not set amount, currency, Proxy metadata, capture method, Connect transfer fields, or an idempotency key in the builder. The helper uses the current Proxy cart amount/currency, requires automatic capture, injects exact acquisition/session/path metadata, and owns the root idempotency key.
Multiple identical callers may reach stripe.paymentIntents.create concurrently, but they use the same Stripe idempotency key and converge on one PaymentIntent. A caller that pauses or crashes does not own a lease. Changed normalized provider options fail as a configuration conflict instead of joining the wrong root.
Optional server-only merchandise basis
Section titled “Optional server-only merchandise basis”Omit extra input to use provider gross, which is the default:
await openPaymentIntent({ // ...normal options buildPaymentIntentParams: () => ({ automatic_payment_methods: { enabled: true } }),});Only when your server has an immutable merchandise subtotal may it pass:
await openPaymentIntent({ // ...normal options merchandiseSubtotalMinor: order.merchandiseSubtotalMinor, buildPaymentIntentParams: () => ({ automatic_payment_methods: { enabled: true } }),});The value is fixed at reservation. A retry cannot replace it. Browser request types and public payer APIs do not expose the field. Proxy logs/metrics/alerts zero, very low, or extremely divergent values without blocking a verified payment.
Browser confirmation
Section titled “Browser confirmation”Render your existing Stripe UI with the returned client secret. This browser result is UX state only; it never grants access.
import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
export function PayButton() { const stripe = useStripe(); const elements = useElements();
return ( <form onSubmit={async (event) => { event.preventDefault(); if (!stripe || !elements) return; await stripe.confirmPayment({ elements, confirmParams: { return_url: `${location.origin}/pay/return` }, }); }} > <PaymentElement /> <button disabled={!stripe}>Pay</button> </form> );}Render terminal outcomes explicitly
Section titled “Render terminal outcomes explicitly”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} />;requires_action, processing, and payment failure leave the session actionable and keep the same PaymentIntent available for retry. Only provider-confirmed cancellation/not-found can support an explicit supersession. Elapsed time or a rootless ambiguous create is not terminal evidence.
Cart revisions
Section titled “Cart revisions”The Proxy cart stays editable until a qualifying payment. For a financial edit, your backend must:
- write the next cart with
expectedCartVersion; - update the bound PaymentIntent using the merchant-owned Stripe client and a deterministic update idempotency key;
- retrieve the PaymentIntent if the update response is ambiguous;
- confirm the acquisition cart with the exact new Proxy cart version, verified provider amount/currency, and deterministic evidence hash.
Do not treat a local rollback as confirmation that Stripe did not update. Until provider retrieval confirms the new revision, leave it unconfirmed; a success then becomes reconciliation visibility rather than silently charging/provisioning stale state.
Webhooks and fulfillment
Section titled “Webhooks and fulfillment”Subscribe the dedicated classic Stripe Webhook Endpoint to the PaymentIntent
events reported by stripe doctor. Stripe sends them to Proxy. Your
application subscribes its Proxy webhook endpoint to
proxy_session.paid/proxy_session.provisionable, verifies with
proxy.webhooks.handle(...), resolves current state, and applies one
idempotent merchant-owned order/entitlement mutation.
Do not grant from browser success, client secret, processing/requires-action status, or a raw Stripe webhook in a second competing fulfillment path. Refund/dispute observation is optional and never revokes fulfillment or reverses the immutable 3% + 30-minor-unit earned fee.
See Stripe fees, events and permissions, and order identity.
Validate
Section titled “Validate”- Start two payer views for one durable Proxy handoff and confirm both recover the same PaymentIntent presentation and cart.
- Complete one Stripe test payment and confirm the other view becomes already paid without another PaymentIntent create.
- Exercise action-required, processing, failure, retry, and cancellation behavior on the same acquisition.
- Retry signed events and confirm fulfillment or entitlement changes once.
Next, add entitlements when payment grants access and follow cart updates when the payer can change price, quantity, plan, or another financial detail.