Choosing who builds your payments is a correctness decision, not a design one. The checkout page is the easy part. What separates a solid integration from an expensive one is what happens on retries, disputes, proration, and the webhook your server accepted with a 200 and then quietly dropped. Here is how we build Stripe integrations, and when you should not hire us for one.
The payment is not finished when the customer sees the thank you page
Treat the browser redirect as a hint, not a fact. The customer closes the tab, the network drops, the mobile browser kills the page, and the charge still succeeded. The only reliable source of truth is the webhook, and webhooks come with a contract that is easy to get wrong.
Stripe delivers events at least once and does not guarantee order. You will receive the same invoice.paid twice, and a customer.subscription.updated can arrive before the created event that logically precedes it. Every handler has to be idempotent: persist the event id, wrap the state change and that insert in one transaction, and let a unique constraint reject the replay. Idempotency runs the other way too. Put an Idempotency-Key on every write you send to Stripe, or a PaymentIntent creation retried after a network timeout becomes a second charge on a real card.
Signature verification needs the exact bytes Stripe signed. In a Next.js route handler that means reading the request with req.text() and handing that string to constructEvent. Anywhere a body parser re-serialises the payload before you hash it, verification fails in a way that looks like a Stripe bug and never is.
Return 200 quickly and do the real work behind a queue. A handler calling three internal services inline will eventually blow the timeout, Stripe will retry, and now you have concurrency stacked on duplication. Then ship a reconciliation job that walks the Events API from your last cursor and repairs whatever the endpoint missed. Stripe stops retrying after a few days. Bugs outlive that window.
Subscriptions are a state machine, and the bugs live in the gaps
A subscription moves through trialing, incomplete, active, past_due, unpaid, paused and canceled, and your product has to grant or revoke access at every transition. The classic production bug is entitlement drift: your database says the account is on the Pro plan, Stripe says the subscription ended six weeks ago, and nobody noticed because it ended through a failed renewal rather than a click in your interface.
The fix is architectural. Stripe is the billing ledger and your database is a mirror, never the other way round. Derive access from the subscription status and period end delivered by webhook, rebuild that mirror from the API when the two disagree, and never let an admin flip a plan flag outside that path. The same discipline covers the Stripe billing portal: a customer who cancels there never touches your code, and cancel_at_period_end means access should survive to the end of the paid period rather than die on the click.
Plan changes are where the money leaks. An upgrade mid-cycle creates proration line items whose behaviour depends on the proration setting, the billing cycle anchor and when you invoice. Downgrades leave a credit balance that surprises finance months later. Usage-based pricing needs idempotent usage reporting and an explicit rule about which side of the window a late event belongs to.
Dunning deserves a decision rather than a default: how many retries, what the customer sees, whether access is cut at past_due or at unpaid. Those are business rules, and they belong in writing before anyone opens an editor.
SCA, disputes, and the card data that must never touch your servers
In Europe, strong customer authentication under PSD2 is not a checkbox. On-session payments trigger 3D Secure through the Payment Element and usually pass. The failures happen off-session: a renewal or a saved-card charge comes back as requires_action with authentication_required, and without a flow that emails the customer a link to finish authenticating, that revenue is gone. Build that path before launch, and store mandates properly for SEPA Direct Debit, where a payment can fail days after it looked settled.
Disputes have hard deadlines and a fee you pay whether you win or lose. Decide up front who gets the alert, where the evidence lives (order records, delivery proof, IP and device data, the terms accepted at checkout) and who files it before the window closes. Refunds need their own rules: a partial refund has to reverse the correct tax line, and one issued after the payout has left leaves a negative balance somebody has to fund. On a marketplace it gets sharper: a dispute can land on the platform after the connected account has been paid out, which makes destination charges versus separate charges and transfers a liability decision, not plumbing.
Card testing needs forethought: a public payment endpoint with no rate limiting and no Radar rules gets found.
The compliance rule is short: card numbers never touch your infrastructure. Checkout and the Payment Element tokenise inside a Stripe-hosted iframe, which keeps you in the lightest self-assessment bracket, SAQ A. Build your own card fields and PCI scope swallows the whole stack, logging pipeline included.
Testing what actually breaks, and noticing when webhooks stop arriving
Most payment code is tested with the 4242 test card and shipped. The interesting cases are the declines: insufficient funds, lost card, expired card, the card that demands authentication, the one that succeeds and then triggers a dispute. Stripe publishes test cards for all of them, and each deserves a test proving the system reacts correctly, including the dunning sequence and the moment access is revoked.
Subscriptions add a time dimension you cannot wait out in real life. Test Clocks fast forward a test customer through a trial end, a renewal, a retry sequence and a cancellation in a single run, and the Stripe CLI triggers and replays events against your machine. Together they move a class of bugs from something you discover next month to something CI catches today.
Operationally, the failure that costs money is silent. An endpoint that answers 200 and then loses the event looks perfectly healthy in the dashboard, because as far as Stripe is concerned it was delivered. So we measure the lag between event creation and processing and alert on that, not only on HTTP errors, keep unprocessable payloads in a dead-letter table a human can replay, and use restricted keys with a separate endpoint and signing secret per environment.
We write the runbook too: what each alert means, how to replay events, how to reconcile a day. And to be clear about the limit, we are not a 24/7 on-call rota, so the system is built so that a missed hour is always recoverable.
When Stripe, or hiring us, is the wrong call
If you sell physical products and already run Shopify, Shopify Payments is integrated, reconciled and supported, and a custom Stripe build mostly recreates what you already own. The same goes for a WooCommerce, Squarespace or Wix store: use the payments the platform already ships with and spend the budget on traffic. Custom payment work earns its keep when the billing logic is part of the product, not when it is a checkout button on a catalogue.
If you sell software worldwide and do not want to own VAT registration, OSS filings and US sales tax nexus, a merchant of record such as Paddle or Lemon Squeezy takes a larger cut of each sale than a processor does and takes the tax liability with it. For a small team that is often the right trade. Stripe Tax calculates and reports well, but you remain the merchant of record, and somebody still has to file.
Check coverage before committing. Stripe's supported countries for accepting payments, for receiving payouts and for running a Connect platform are three different lists. If your buyers pay mainly through a local method Stripe does not support in your market, that settles it. In parts of Europe, Mollie or Adyen can fit better, and at serious volume you should be negotiating rates directly rather than accepting published pricing.
And do not hire us if you need someone on a call, in a workshop, in your standup or on a pager. We work in writing, asynchronously, and we will politely decline. If an incident has to reach a human within fifteen minutes at any hour of the night, that capability belongs in-house.
How two people in France work with a client in California
We are two founders in Dijon, France: Robin Monteiro writes the code (full-stack, Next.js and React) and Florian Loppion handles the marketing side. No account manager layer, no delivery director, no team behind the logo you never meet. You write to the person doing the work. Our named references are French SMEs, including Chouchou Ribeyre, Au Petit Detail, LB Athletic, Mediavocats and Vectosolve, and payments work travels well across borders because the Stripe API does not care where anyone sits.
Everything happens in writing, by email, with a reply within 24 business hours. No calls, no video, no scheduled slots, not even as an option. For an international client that removes the time zone problem instead of creating one: nobody in San Francisco, London or Bangalore gets up at 3am to approve a proration rule, and no half day is lost waiting for a slot next week.
The second benefit shows up later. When a chargeback is contested a year from now, or a new developer asks why refunds behave the way they do, the answer is a searchable email thread rather than someone's memory of a meeting.
What you do differently: write the requirement down instead of explaining it out loud, hand over a test-mode restricted key early, and expect batched questions rather than a trickle. Decisions are confirmed in a thread before anything is built. Pricing is on request, there is no mandatory retainer, and you keep the code, the Stripe account and the domain.