Payments Integration Checklist: Stripe, Adyen, Checkout.com and Failure Modes
Stripe, Adyen, and Checkout.com are easy to call and easy to get catastrophically wrong. The money lives in the webhooks, idempotency keys, and the reconciliation job nobody wants to own.
A correct payments integration is mostly plumbing: webhooks as the source of truth, idempotency keys on every charge, real failure-mode testing, and a daily reconciliation job.
We have shipped payment integrations that moved real money, and we have also been on the call at 2am explaining why a customer got charged twice. The gap between those two outcomes is not the API. It is the boring plumbing around it: webhooks, idempotency, retries, and a reconciliation job nobody wants to own. Get those right and Stripe or Adyen is a few hours of work. Get them wrong and the SDK being pleasant to use will not save you.
The core rule we hold every integration to: webhooks are the source of truth, not the API response your client got back. Verify every payment server-side, and test the failure paths as hard as the happy path. Most teams do the opposite, which is why most payment bugs are silent until finance finds them.
Picking a processor
Three serious options, and the choice is mostly about volume and where your customers are. Stripe charges 2.9% + $0.30 per US card transaction, onboards in minutes, and has the docs everyone copies. Adyen runs interchange++ on negotiated rates, takes days to weeks to onboard, and only pays off once you are doing enough volume to bother negotiating. Checkout.com sits around 2.5% + $0.25 depending on region, onboards in a few days, and is the EU-leaning pick with modern APIs and a thinner ecosystem than Stripe.
Our default advice: start on Stripe. It is the fastest to ship and the easiest to hire for. Revisit Adyen or Checkout.com when you cross roughly $10M in annual transaction volume and the rate negotiation is worth the heavier integration. Switching later is annoying but cheaper than over-engineering for scale you do not have yet.
The flow, and why the order is fixed
There is exactly one correct sequence and it is not negotiable. The server creates the payment intent, because the server controls the amount and the client cannot be trusted with it. The client collects card details directly through Stripe Elements or Adyen's components, so raw card data never touches your servers. The webhook confirms the payment. You fulfill the order after the webhook fires, not after the browser says it succeeded. Client confirmation can be spoofed or simply lost on a flaky network; the webhook is what actually happened.
Webhooks: the part everyone breaks
This is where integrations go to die. API responses get lost to timeouts and dropped connections. Payments succeed after the initial request already gave up. Disputes and refunds arrive hours or days later and only come through as async events. If you are reading payment status off the synchronous API response, you will miss all of that.
Two non-negotiables. Verify the webhook signature on every single request using the raw request body, before you parse anything. And make the handler idempotent, because Stripe retries failed deliveries for up to three days and will absolutely send you the same event more than once. Dedupe on the event ID and treat a repeat as a no-op.
Idempotency keys stop double charges
The double-charge story is always the same. Customer hits Pay. The request times out even though the charge went through. Customer hits Pay again. Now they have two charges and you have a refund and an apology to write. An idempotency key kills this: same key plus same parameters returns the same result instead of a second charge. Put one on every create-charge call, no exceptions.
Handling declines and network failures
Declines are not all the same and should not be handled the same. A processing_error is transient, so retry it automatically with backoff. insufficient_funds, card_declined, and expired_card are dead ends for that card, so ask the customer to try another or update it. incorrect_cvc means re-enter, not retry. A fraudulent flag goes to manual review, never an automatic retry. Surface a useful message for the human cases and stay quiet on the ones you can recover from yourself.
Network failures are their own category. If you do not get a response, you do not know whether the charge happened. Do not assume failure and re-charge. Reuse the idempotency key, retry with exponential backoff, and if you still cannot tell, query the processor for the intent's actual state before doing anything that touches money.
Reconciliation catches what your code misses
Run a reconciliation job daily, every day, automatically. Pull the processor's record of what was captured, refunded, and disputed, and diff it against your own ledger. This is the job that catches the missed webhook, the order you fulfilled but never charged, and the refund that updated the processor but not your database. It is unglamorous and it is the single highest-leverage thing on this list. Reconciliation finds money bugs before your customers and your accountant do.
Test the failures, not just the successes
Stripe's test cards exist so you can exercise the ugly paths on demand: 4242 4242 4242 4242 succeeds, 4000 0000 0000 0002 declines, 4000 0000 0000 9995 hits insufficient funds, 4000 0025 0000 3155 forces 3D Secure, 4100 0000 0000 0019 trips fraud, and 4000 0000 0000 0341 fails mid-network. Build an end-to-end suite that drives each one and asserts the right downstream effect. Then replay webhooks against your handler and confirm the idempotency actually holds. If you have not tested a webhook replay, assume it is broken.
Security and the go-live checklist
Payment endpoints are high-value targets, so the security baseline is not optional. Never log full card numbers and never store the CVV, encrypted or otherwise. Keep card data off your servers entirely by using Elements or hosted components, which also keeps you in PCI SAQ A. TLS 1.2 or higher everywhere. Verify webhook signatures, rate-limit the endpoint, and rotate the webhook secret. API keys live in a secrets manager, never in source, with separate test and production keys and a rotation after anyone with access leaves. On top of that, turn on Stripe Radar or an equivalent, add velocity and geographic checks, and keep a manual review queue for flagged payments.
Payments are unforgiving in a way most software is not, because the bugs cost real money and someone always notices. Spend the time on the plumbing. The API was never the hard part.
We help teams design and ship production-grade software in eLearning, fintech, and AI. Let's talk about your project.
Book a call