Blog · · 11 min
Stripe Checkout with Claude Code: the part nobody writes
An agent wires Stripe correctly in about ten minutes. What it leaves out sits on both sides of the button: the amount must be computed from your own records rather than posted by the browser, the webhook is the only place an order becomes real, and the same event will arrive twice so the write has to be idempotent. Then the page that leads to the checkout still does not exist.
By uxgen
Ask a coding agent to add Stripe Checkout and you get a working payment in about ten minutes. The session route, the redirect, the test card, the environment variables: all of it correct, and none of it is where stores break.
Three things are usually missing, and they are the three that cost money. The amount is taken from the request body, so the price is whatever the browser said it was. The order is written on the return URL, so an order exists only for buyers who did not close the tab. The same webhook event is processed twice, because it will be delivered twice and nothing stops it.
Below the fix for each, and then the thing nobody mentions at all: the checkout now works and the page that leads to it does not exist.
What does an agent get right without help?
Most of it, and it is worth saying plainly rather than pretending the tooling is bad.
It picks the current SDK, it puts the secret key in an environment variable and the publishable one in the client, it uses 4242 4242 4242 4242 in test mode, it wires the success and cancel URLs, and it usually reaches for hosted Checkout rather than hand-building a card form, which is the right call for a first store.
It also tends to know that a webhook exists. What it does not do, unprompted, is treat the webhook as the only place the order is created, which is a different claim and the one that matters.
Why must the amount never come from the browser?
Because your endpoint is on the public internet and accepts JSON.
// What gets generated. Anyone can send { amount: 100 } to this route.
export async function POST(req: Request) {
const { amount, name } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: { currency: 'usd', unit_amount: amount, product_data: { name } },
quantity: 1,
}],
success_url: `${origin}/order/complete`,
cancel_url: `${origin}/cart`,
})
return Response.json({ url: session.url })
}
The client sends what it wants to pay. There is no exploit to write here; a text editor and one fetch is the whole attack.
The fix is that the request body carries identifiers and quantities, and nothing else. Prices live where you control them: as price objects in Stripe, or as rows in your own database that the server reads on every call.
import Stripe from 'stripe'
import { randomUUID } from 'node:crypto'
// Pin the API version explicitly rather than drifting with the SDK default.
// Copy the string from your own dashboard; do not copy one from an article.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: process.env.STRIPE_API_VERSION as Stripe.LatestApiVersion,
})
type CartLine = { sku: string; qty: number }
export async function POST(req: Request) {
const { lines, cartId } = (await req.json()) as { lines: CartLine[]; cartId: string }
// Quantities are clamped, prices are looked up. Nothing about money is read
// from the request.
const priced = await Promise.all(
lines.slice(0, 20).map(async (l) => {
const product = await catalogue.bySku(l.sku)
if (!product || !product.active) throw new Error('unknown sku')
return {
price: product.stripePriceId,
quantity: Math.min(Math.max(1, Math.trunc(l.qty)), product.maxPerOrder),
}
}),
)
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
line_items: priced,
client_reference_id: cartId,
customer_email: await sessionEmail(req),
shipping_address_collection: { allowed_countries: ['US', 'CA'] },
metadata: { cartId },
success_url: `${origin}/order/complete?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
},
// A double-click, or a retry on a flaky connection, reuses the same session
// instead of creating a second one.
{ idempotencyKey: `checkout:${cartId}:${await cartFingerprint(cartId)}` },
)
return Response.json({ url: session.url })
}
Two details in there that repay the reading. {CHECKOUT_SESSION_ID} is a literal placeholder that Stripe substitutes in the redirect, so it is written exactly like that, braces included. And the idempotency key is derived from the cart plus a fingerprint of its contents, so pressing the button twice reuses one session, while genuinely changing the cart produces a new one. A key derived from the cart id alone would pin a buyer to a stale total.
Idempotency keys are retained for a limited window, on the order of a day, which is fine for the case they exist to cover and useless as a long-term deduplication story. That belongs on the other side.

Which event makes an order real?
checkout.session.completed, received on your webhook endpoint, with payment_status checked. Not the return URL.
The return URL is a redirect in a browser you do not control. The buyer can close the tab, lose signal in a lift, get bounced by a bank redirect, or land on it from history three days later. An order created there exists for the buyers who cooperated with your network conditions.
// app/api/stripe/webhook/route.ts
export const runtime = 'nodejs' // needed for the sync signature check below
export async function POST(req: Request) {
// The RAW body. Parsing to JSON first and re-serialising changes the bytes
// and the signature check fails with no useful message.
const body = await req.text()
const sig = req.headers.get('stripe-signature')
if (!sig) return new Response('no signature', { status: 400 })
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
// Do not log the body here. Return 400 and stop.
return new Response('bad signature', { status: 400 })
}
// Deduplication, in the database, on a unique index. Stripe retries on any
// non-2xx, and at-least-once delivery means duplicates are normal traffic.
const fresh = await events.claim(event.id) // INSERT ... ON CONFLICT DO NOTHING
if (!fresh) return new Response('ok', { status: 200 })
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session
// Card payments are paid here. Delayed methods are not, and arrive later
// as async_payment_succeeded. Writing a paid order now would be a lie.
if (s.payment_status === 'paid') await orders.createFromSession(s)
else await orders.createPending(s)
break
}
case 'checkout.session.async_payment_succeeded':
await orders.markPaid((event.data.object as Stripe.Checkout.Session).id)
break
case 'checkout.session.async_payment_failed':
await orders.markFailed((event.data.object as Stripe.Checkout.Session).id)
break
}
// 200 quickly. Slow handlers get retried, and a retried handler that is not
// idempotent is how one order becomes three.
return new Response('ok', { status: 200 })
}
Four things in that handler that an agent does not write on its own:
- The raw body. A framework that parses JSON for you breaks the signature check. In the Next.js App Router,
await req.text()in a route handler is the raw body and it works. In older Pages-router API routes you have to disable the body parser explicitly. constructEventagainstconstructEventAsync. The synchronous form uses Node crypto. On a runtime with Web Crypto only, such as an edge function, use the async form. Choosing the wrong one produces a signature failure that looks like a wrong secret.- The unique index.
events.claimis one insert against a table with a unique constraint on the Stripe event id, returning whether the row was new. That is the entire deduplication mechanism, it is four lines of SQL, and it is the difference between a retry being harmless and a retry shipping a second parcel. payment_status. A completed session is not necessarily a paid one.
Test it with stripe listen --forward-to localhost:3000/api/stripe/webhook, which prints a signing secret for the local session. That secret is not the one from the dashboard, and mixing them up is the most common half hour lost in this entire integration.
What depends on the API version, and what does not?
Worth separating, because a confident wrong version string in an article is worse than no string.
| Stable across versions | Version-dependent |
|---|---|
| Signature verification with the raw body | the exact shape of objects inside event.data |
| Event ids, and at-least-once delivery | which fields exist, and what some of them are named |
| Retries on any non-2xx response | default expansions, and what needs expand |
| Idempotency keys on create calls | enum values added to existing fields |
The practical rule. Pin apiVersion in your SDK constructor rather than taking the default, and take the string from your own dashboard rather than from any article, this one included. Then read the two things that differ: a webhook endpoint carries its own API version, fixed when it was created, and the events it delivers are rendered in that version. So your endpoint can be receiving payloads from an older version than the one your typed SDK expects, and TypeScript will happily agree with a field that is not there.
When you upgrade, upgrade the endpoint and the SDK together, and replay a stored event against the new handler before you trust it.
The checkout works. The page in front of it does not exist.
This is the honest end of the story, and it is the reason the whole task feels finished when it is not.
What you have now is a correct payment path. What sits in front of it is usually a product page an agent also produced: a centred title, a grey <select> for variants, one price, a gradient Add to cart, and nothing between the buyer's interest and their card. The payment infrastructure is not what decides whether anyone reaches it.
The pieces that do are ordinary components, and each of them has a rule that is not obvious:
- The button that starts the process, and what it says: add to cart button.
- The wallet row, which only helps if it is above the form: where do express checkout buttons go.
- The field list, and the tokens that let a browser type it: guest checkout, the field list that converts.
- The regulated bits of the checkout screen itself: checkout page design, field by field.
- The screen after payment, which most stores leave as a receipt: the thank-you page is a component.
An agent will build any of them on request. It builds the median published version of each, because that is what it has read a great many times, and the median published version of a checkout was never audited against anything. Instructions can tell an agent a rule once per session. A component carries the rule every time it is used, which is the difference between telling an agent and handing it a part.
Disclosure. We build uxgen, an MCP server that hands a coding agent commerce components as HTML the merchant keeps in their own repository, with no runtime dependency and no per-order fee. It has no bearing on the Stripe code above, which is yours to copy.
FAQ
Should I create the order on the Stripe success URL or in the webhook?
In the webhook. The success URL is a redirect in a browser you do not control, so an order created there exists only for buyers who kept the tab open and had a working connection. Handle checkout.session.completed, check payment_status, and let the confirmation page read back what the webhook wrote.
Why does my Stripe webhook signature verification fail?
Almost always because the body was parsed before verification. The check runs over the exact bytes Stripe sent, so read the raw body with await req.text() and pass that string. The other two causes are using the dashboard signing secret while forwarding with the CLI, which issues its own, and calling the synchronous constructEvent on a runtime that only has Web Crypto, where constructEventAsync is required.
How do I make a Stripe integration idempotent?
On two sides. Outbound, pass an idempotency key to create calls so a double-clicked button reuses one session instead of opening two. Inbound, insert the Stripe event id into a table with a unique constraint and stop if the row already existed, because delivery is at-least-once and any non-2xx response is retried.
Can I trust the amount sent from the client to create a Checkout Session?
No. Your route is a public endpoint that accepts JSON, so a request can name any price. Accept product identifiers and quantities only, clamp the quantities, and look the prices up server-side, either as Stripe price objects or as rows in your own database read on every call.