Skip to content

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:

Terminal window
proxy stripe doctor --path OT-PI --format json

Install 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

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.

  • Keep your Stripe create/edit secret only in your merchant backend. It constructs the injected stripe client 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.
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.

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.

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>
);
}

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.

The Proxy cart stays editable until a qualifying payment. For a financial edit, your backend must:

  1. write the next cart with expectedCartVersion;
  2. update the bound PaymentIntent using the merchant-owned Stripe client and a deterministic update idempotency key;
  3. retrieve the PaymentIntent if the update response is ambiguous;
  4. 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.

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.

  1. Start two payer views for one durable Proxy handoff and confirm both recover the same PaymentIntent presentation and cart.
  2. Complete one Stripe test payment and confirm the other view becomes already paid without another PaymentIntent create.
  3. Exercise action-required, processing, failure, retry, and cancellation behavior on the same acquisition.
  4. 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.