11ai stripe payments
Version baseline: Stripe API 2026-02-25.clover and the latest stable SDK for the project language. Preserve an existing account, endpoint, or SDK pin unless the user explicitly requests an upgrade, and validate the compatibility table before moving API versions.
Two rules govern everything here. The amount is calculated on the server, never accepted from the client. And every create that moves money carries an idempotency key derived from something stable, because a retry without one charges twice.
Inspect first
import "server-only"
import { stripe } from "@/lib/stripe"
const intent = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ["latest_charge", "customer"],
})
console.log({
id: intent.id,
status: intent.status,
amount: intent.amount,
amountReceived: intent.amount_received,
currency: intent.currency,
lastError: intent.last_payment_error?.code,
})
select id, stripe_payment_intent_id, amount, currency, status from public.orders where id = $1;
Read status and amount_received together, and compare both against the local order. A payment intent that succeeded with an amount_received different from the order total is a reconciliation problem worth catching before a refund makes it worse.
Statuses: requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, succeeded, canceled.
Create a payment intent
export async function createPaymentIntent(order: { id: string; tenantId: string }) {
const total = await calculateOrderTotal(order.id)
const customer = await ensureCustomer(order.tenantId)
return stripe.paymentIntents.create(
{
amount: total,
currency: "eur",
customer: customer.id,
automatic_payment_methods: { enabled: true },
metadata: { app_order_id: order.id, app_tenant_id: order.tenantId },
description: `Order ${order.id}`,
},
{ idempotencyKey: `order-${order.id}` }
)
}
The decisions:
calculateOrderTotalruns on the server from data you own. Never take an amount, currency, or line total from the request — a client that can send an amount can send1.amountis an integer in the minor unit.2000is 20.00 EUR; zero-decimal currencies such as JPY take the whole number. No floating point anywhere near money.- The idempotency key is the order id, so a retried request returns the same intent rather than creating a second charge. A random or time-based key defeats the entire mechanism.
automatic_payment_methodslets Stripe offer whatever is enabled and appropriate, rather than hard-coding card.metadatalinks back to the order, which is what the webhook and any later reconciliation use.
Return only the client_secret to the browser. It is scoped to that one intent and is safe to send; anything else about the intent is not needed there.
Confirm and authenticate
const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: { return_url: `${window.location.origin}/orders/${orderId}/done` },
})
Some payments need a further step from the customer — a bank authentication screen, for example — which is the requires_action status. The client library handles it when given a return_url; a hand-rolled flow that ignores that status leaves the customer stuck with no error.
Do not treat the client's result as the source of truth. A network failure after a successful payment gives the browser an error for a charge that went through. The webhook is authoritative.
Never handle raw card numbers. Collect payment details through Elements or Checkout so they never reach your server.
Capture later, when needed
await stripe.paymentIntents.create(
{ amount, currency: "eur", customer: customerId, capture_method: "manual" },
{ idempotencyKey: `order-${orderId}` }
)
await stripe.paymentIntents.capture(paymentIntentId)
await stripe.paymentIntents.capture(paymentIntentId, { amount_to_capture: 1500 })
await stripe.paymentIntents.cancel(paymentIntentId)
Manual capture authorizes now and takes the money later, which suits an order shipped after a delay. The authorization expires — typically in about a week — and an uncaptured one is released, so capture within that window or the payment is lost.
Capturing less than authorized is allowed; capturing more is not.
Read the outcome from the webhook
case "payment_intent.succeeded": {
const intent = event.data.object as Stripe.PaymentIntent
const orderId = intent.metadata.app_order_id
if (!orderId) throw new Error("payment intent with no order reference")
await markOrderPaid({
orderId,
paymentIntentId: intent.id,
amountReceived: intent.amount_received,
currency: intent.currency,
})
break
}
case "payment_intent.payment_failed": {
const intent = event.data.object as Stripe.PaymentIntent
await markOrderFailed({
orderId: intent.metadata.app_order_id!,
code: intent.last_payment_error?.code ?? "unknown",
})
break
}
Make markOrderPaid idempotent — Stripe retries, and fulfilling twice ships two parcels. Compare amount_received against the order total and flag a mismatch rather than accepting it.
Refund deliberately
const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
amount: 500,
reason: "requested_by_customer",
metadata: { app_order_id: orderId },
},
{ idempotencyKey: `refund-${orderId}-${refundRequestId}` }
)
A refund moves real money out and cannot be undone. Before issuing one:
- Read the payment intent and confirm
statusissucceededand whatamount_receivedactually was. - Sum the refunds already issued against it, so a second request cannot exceed the charge.
- State the exact amount and currency, and get explicit approval for that figure on that payment.
- Use an idempotency key tied to the refund request, not the order, so a genuine second refund is possible while a retry is not.
Omitting amount refunds the whole charge. That is easy to do by accident when a partial refund was intended.
Payment processing fees are generally not returned on a refund, so a full refund still costs the business. Say so when the amount matters.
const existing = await stripe.refunds.list({ payment_intent: paymentIntentId, limit: 100 })
const alreadyRefunded = existing.data.reduce((sum, r) => sum + r.amount, 0)
Handle disputes
case "charge.dispute.created": {
const dispute = event.data.object as Stripe.Dispute
await flagDispute({ chargeId: String(dispute.charge), amount: dispute.amount, reason: dispute.reason })
break
}
A dispute has a response deadline and the money is already held. Notify a human immediately — this is not something to handle automatically. Do not refund a disputed charge, which can result in paying twice; respond through the dispute process instead.
Verify
- Pay with
4242 4242 4242 4242and confirm the order is marked paid by the webhook. - Pay with
4000 0025 0000 3155and complete the authentication step. - Pay with
4000 0000 0000 9995and confirm the order is not fulfilled. - Retry the create with the same idempotency key and confirm one intent, not two.
- Resend
payment_intent.succeededand confirm nothing double-fulfils. - Issue a partial refund and confirm the amount, then confirm a second request cannot exceed the charge.
- Confirm
amount_receivedequals the order total for a normal payment.
Report
State the payment intent id and status, the amount and currency as integers and how the amount was calculated, the idempotency key used, where the outcome is recorded and that it is the webhook, what makes fulfilment idempotent, any refund with its exact amount and remaining refundable balance, and the verification results including the declined-card and replay checks. Flag any amount taken from the client, any create without an idempotency key, any money value held as a floating point number, and any disputed charge that was refunded.