Payments in React: The Client Never Decides You Got Paid

On the surface, taking a payment looks like the most ordinary thing in the world: a form with some fields, a submit button, a success message. You've built a hundred forms. This is just a form where one of the fields happens to be a card number.
It is not just a form, and the card number is exactly why. That field is radioactive. The moment a real card number touches your servers, you've walked into PCI compliance — a wall of security obligations that exists for very good reasons and that you do not want to be responsible for. And the "success message" hides the second trap: unlike every other form you've built, you don't actually know the outcome when the user clicks submit. The payment confirms asynchronously, somewhere you can't see, and the single most expensive mistake in this entire domain is letting your React app believe it got paid because a client-side callback said so.
So this article isn't a Stripe tutorial. It's about the two lines that matter — the one that keeps the card number away from you, and the one that decides where "you got paid" is actually true — because getting those two lines right is the whole job, and getting either wrong is how you end up either liable or robbed.
The First Line: Never Touch the Card Number
Start with the rule that shapes everything else: the raw card number must never reach your server. Not to "just pass it through," not to log it, not for a millisecond. The instant it does, you're in PCI-compliance scope, and that is a burden measured in audits and dread, not code.
The whole architecture of a modern payments provider exists to keep you out of that scope, and the mechanism is worth understanding because it's genuinely clever. You don't build the card input. Stripe does — via Stripe Elements, an iframe-based field that Stripe hosts and controls. The card number is typed into their iframe, on your page, and it goes straight to Stripe. Your JavaScript can't read it. Your server never sees it. What you get back instead is a token — a harmless reference that means "Stripe is holding a card for you" — and that is what flows through your system.
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; export function CheckoutForm() { const stripe = useStripe(); const elements = useElements(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!stripe || !elements) return; // Stripe collects the card in its own iframe and confirms directly. // The card number never touches your JS or your server. const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: 'https://example.com/order/complete' }, }); if (error) { // Show the user the reason. This is the "did it start" answer, // NOT the "did it succeed" answer. } } return ( <form onSubmit={handleSubmit}> <PaymentElement /> <button type="submit">Pay</button> </form> ); }
Look at what your code never handles: the card number itself. <PaymentElement /> is Stripe's iframe living inside your form. You render it, you style around it, and you stay completely out of scope for the one piece of data that would otherwise make you liable. This is the buy-don't-build principle from the auth article at its most extreme — the risk here is so high that building it yourself isn't a trade-off, it's a mistake.
The Second Line: The Client Doesn't Decide
Now the subtler and more dangerous part. The payment is initiated in the browser, but the browser is the last place that should decide whether it succeeded.
Here's why this bites people. The naive flow: user pays, Stripe's client call resolves, you show "Payment successful!" and mark the order as paid in your UI. It demos perfectly. And it's exploitable and unreliable, because the client is an environment you don't control. The user closes the tab the instant before the callback fires — the card was charged, your app never knew. The network drops after the charge but before the response — same. And a hostile user can straight up call your "mark as paid" endpoint without paying at all, because anything the client asserts, a client can fake.
The answer is the one that runs through this whole series: the client is untrusted; the server is the source of truth. The real confirmation doesn't come from your React callback. It comes from Stripe, server-to-server, via a webhook — Stripe calls your backend to say "this payment actually succeeded," and that server-side event is the only thing allowed to mark an order as paid.
// On your server — the ONLY place an order becomes "paid". app.post('/webhooks/stripe', async (req, res) => { const event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], WEBHOOK_SECRET, // proves the call really came from Stripe ); if (event.type === 'payment_intent.succeeded') { const intent = event.data.object; await markOrderPaid(intent.metadata.orderId); // trusted truth } res.json({ received: true }); });
So the client's job and the server's job split cleanly. The client starts the payment and shows a hopeful "we're processing your order." The server, via the webhook, confirms it and flips the order to paid. Those are two different facts arriving through two different channels, and conflating them — treating the client's optimistic result as the settled truth — is the bug that gets apps double-charging, under-charging, or handing out product for free.
The client can tell you a payment started. Only the server, via the provider's webhook, can tell you it succeeded. Never let the browser be the thing that says "paid."
This maps exactly onto the server-state idea from the auth article: the important truth lives on the server, and the client holds a hopeful, possibly-stale view of it. Payments are the highest-stakes version of that same pattern, which is why the discipline matters more here than anywhere.
So How Hard Is It, Really?
Honestly? Less than the fear suggests, if you respect the two lines. Stripe has done the genuinely hard parts — the card handling, the PCI scope, the fraud tooling. What's left for you is smaller than people expect, but it's unforgiving about correctness.
Your real work is three things. Render Stripe's Element so the card never touches you — mechanical, and Stripe hands you the components. Kick off the payment from the client and, critically, treat the client-side result as "started," not "done." And build the webhook handler on your backend as the sole authority that marks an order paid, verifying the signature so nobody can forge that call. That's the shape of it. It's not a huge amount of code. It's a small amount of code where being casually wrong costs actual money, which is a very different kind of hard than "lots of code."
How I'd Approach It
Strip it to decisions:
- The card number is radioactive. Use Stripe Elements so it never touches your JS or server. Don't negotiate with PCI scope — avoid it.
- The client starts the payment; it does not confirm it. Show "processing," never "paid," from a client callback.
- The webhook is the source of truth. An order becomes paid on your server, when Stripe tells your server it succeeded — and never before.
- Verify the webhook signature. Otherwise anyone can call your endpoint and claim they paid.
- Buy, don't build. This is the domain where rolling your own is not clever, it's negligent.
The reason payments feel scarier than they are is that they punish the exact habit web development otherwise rewards: trusting the client. Everywhere else, an optimistic UI that assumes success is good UX. Here, an optimistic UI that records success is a liability. Once you internalize that the browser can only ever say "I tried" and the server is the only thing that can say "it worked," the whole domain stops being frightening and becomes just careful — two clean lines, respected every time.
The next article stays with the values that are deceptively easy to get wrong and quietly ruin things: time and dates in React — timezones, storage, and the bugs that only surface at the worst possible hour.
If you've got a checkout that marks orders paid from a client callback, that's the second line crossed, and it's worth fixing before it costs you. Tell me how your success path works and I'll tell you where the hole is.