Payment gateway integration isn't a form that posts a card number somewhere. It's a state machine spanning three parties — your server, the gateway, and the card issuer — where the same event can arrive twice, a network call can fail after the charge already succeeded, and your UI is never the source of truth. This assumes you've already made the evaluation decision in choosing the right gateway; this is what building it actually involves.
Idempotency is the pattern everything else depends on
A customer clicks "pay" once. Your client retries the request because the response was slow. Your server's own retry logic fires because a downstream call timed out.
Any of these can produce a duplicate charge unless the request itself is idempotent.
The pattern, standardized informally by Stripe years ago and now moving through the IETF as a proposed HTTP header standard, is simple: attach a client-generated idempotency key to any request that creates or mutates state. The gateway stores the key against the result of the first request, and returns that same result for any retry with the same key — instead of creating a second charge.
POST /v1/payment_intents HTTP/1.1
Host: api.gateway.example
Idempotency-Key: order_48213_attempt_1
Content-Type: application/json
{ "amount": 499900, "currency": "inr", "order_id": "48213" }
// A retry with the SAME Idempotency-Key returns the SAME response,
// even if the first request already succeeded server-side.
Generate the idempotency key from something stable on your side — an order ID plus an attempt counter — never from a timestamp or a random value per HTTP call. A random key defeats the entire point: it makes every retry look like a new request.
Where idempotency keys actually need to live
Store the idempotency key alongside the order or payment attempt in your own database, not just in the gateway's system. If your server crashes after sending the request but before recording the response, you need the same key on the next attempt — which means it has to be derivable or persisted before the first call, not generated fresh each time.
Webhooks are the source of truth, not your redirect handler
The customer's browser redirecting back to your success page tells you the customer's browser came back. It does not tell you the payment settled, and it never should be treated as confirmation. Webhooks — asynchronous server-to-server events the gateway sends you — are the only reliable signal.
Verify the signature before you trust the payload
Every major gateway signs its webhook payloads with a shared secret so you can verify the request actually came from the gateway and wasn't forged. Stripe, Adyen, and Razorpay all document HMAC-based signature verification as a required step, not an optional hardening measure.
// Conceptual signature check — verify before you parse, not after
const signature = request.headers["x-gateway-signature"];
const expected = hmacSha256(webhookSecret, request.rawBody);
if (!timingSafeEqual(signature, expected)) {
return response.status(400).send("invalid signature");
}
// Only now is it safe to parse and act on the event body
Handle duplicate delivery — it will happen
Gateways deliver webhooks at-least-once, not exactly-once. Your webhook handler needs to be idempotent on the event ID: check whether you've already processed this specific event before applying it, and no-op if you have.
- Store processed webhook event IDs, not just payment IDs — the same payment can generate multiple event types (created, succeeded, disputed).
- Return a 2xx response only after the event is durably recorded, not before — a crash between "received" and "recorded" should trigger a legitimate redelivery.
- Never do slow, synchronous work inside the webhook handler itself. Acknowledge fast, queue the actual order-fulfillment work.
3-D Secure and SCA: where a redirect becomes mandatory
EMVCo's 3-D Secure protocol is what most card networks' authentication flows (Visa Secure, Mastercard Identity Check) are built on, and it defines two paths: a frictionless flow, where risk signals let the issuer approve without customer interaction, and a challenge flow, where the customer is redirected to their bank to authenticate.
You don't get to choose which path a given transaction takes — the issuer decides based on risk scoring. Your integration has to handle both: build the challenge redirect (or in-app equivalent) as a first-class part of the checkout flow, not an edge case.
| Concern | Stripe | Adyen | Razorpay |
|---|---|---|---|
| Idempotency mechanism | Idempotency-Key header on mutating requests | Client-generated reference field checked server-side | Idempotency key parameter on order creation |
| Webhook verification | HMAC signature in Stripe-Signature header | HMAC signature (HmacSignature) per notification | HMAC signature in X-Razorpay-Signature header |
| 3DS/SCA handling | Built into PaymentIntents confirmation flow | Explicit 3DS2 native/redirect flow in Checkout API | Handled by Razorpay's own checkout for supported methods |
The sequence, end to end
The diagram below is the shape every gateway integration converges on, regardless of vendor: a payment intent created with an idempotency key, an optional 3DS challenge branching out to the customer's bank, and a webhook — not the redirect — confirming the final state.
The redirect page and the webhook are two different signals. Only the webhook confirms the payment actually settled.
Money is an integer, not a float
Every gateway referenced here represents amounts as integer minor units — cents, paise, the smallest denomination of the currency — specifically to avoid floating-point rounding errors compounding across a transaction. A payment intent for $49.99 is sent as 4999, not 49.99.
Store and manipulate money the same way in your own database and application code, end to end. The moment amounts pass through a floating-point type anywhere in the pipeline — even briefly, in a logging statement or a display formatter — you've introduced a rounding bug that will surface eventually, usually in a reconciliation report, usually at the worst time to debug it.
Retries need backoff, and backoff needs a ceiling
A naive retry loop that fires immediately on failure turns a brief gateway blip into a self-inflicted denial-of-service against your own integration, and against the gateway's rate limits. Exponential backoff with jitter — waiting progressively longer between retries, with some randomness to avoid synchronized retry storms across your fleet — is the standard pattern, and every gateway's client libraries implement some version of it by default.
Set a retry ceiling, and define what happens past it
Retries without a maximum attempt count or maximum elapsed time will eventually retry a request that should have failed permanently — a declined card doesn't become approved on the fifth retry. Define both a retry ceiling and an explicit "give up and surface to the customer or ops" path, so a persistent failure doesn't retry silently forever.
Reconciliation: the job that catches what webhooks miss
Webhooks are reliable in the common case and occasionally, unavoidably, aren't — a webhook endpoint can be down during a deploy, a message can be lost in transit, or a gateway outage can delay delivery well past your alert threshold. A nightly (or more frequent) reconciliation job that pulls the gateway's own transaction list via API and compares it against your local order records is the safety net underneath the webhook system, not a redundant step.
Webhooks tell you about events as they happen. Reconciliation tells you the truth, eventually, even when an event never arrived. Build both — a payment system that only has one of these has a blind spot it doesn't know about yet.
What reconciliation actually needs to check
- Every gateway-side successful charge has a matching local order marked paid.
- Every local order marked paid has a matching gateway-side successful charge — this direction catches a bug that marks orders paid without real confirmation.
- Amounts match exactly, in the same integer minor-unit representation on both sides.
Logging payment flows without creating a PCI problem
Verbose logging is how most integration bugs get debugged, and it's also how card data ends up somewhere it was never supposed to be — an application log, a log aggregation service, a error-tracking tool's captured request body. Redact card numbers, CVVs, and full token values in every log statement before they're written, not after an audit finds them.
Log the gateway's own transaction or payment intent ID instead of raw payment details — it's sufficient for debugging and support, and it carries none of the compliance risk. If your logging pipeline captures raw request/response bodies by default (many APM and error-tracking tools do), explicitly configure redaction rules for payment endpoints rather than assuming the default behavior is safe.
Testing without risking production
Every gateway covered here provides sandbox credentials and test card numbers that simulate specific outcomes: successful charge, decline, insufficient funds, and a 3DS challenge requirement. Build your test suite around the failure paths, not just the happy path — those are the cases production will actually surface.
The checklist that catches real bugs before launch
- Simulate a duplicate webhook delivery and confirm your handler doesn't double-fulfill the order.
- Simulate a network timeout between your server and the gateway on charge creation, then retry with the same idempotency key.
- Force a 3DS challenge in sandbox and confirm the redirect-back flow doesn't mark the order paid before the webhook arrives.
- Kill your server mid-request (after sending the charge, before recording the response) and confirm the retry doesn't double-charge.
- Send a webhook with an invalid signature and confirm it's rejected, not silently ignored or, worse, processed.
If your test suite only exercises the successful charge path, you haven't tested the integration — you've tested the demo. The failure paths are where duplicate charges and stuck orders actually come from.
FAQ
Can we skip idempotency keys if we have good frontend disable-on-click logic?
No. Disable-on-click prevents the customer from double-clicking; it does nothing for server-side retries, load balancer failovers, or your own retry logic on a timeout.
Do we need to handle webhooks if we're using a hosted checkout page?
Yes. The hosted page's redirect confirms the customer's browser returned, not that the payment settled — webhooks remain the only reliable confirmation regardless of checkout style.
What happens if our server is down when a webhook is sent?
Every gateway covered here retries webhook delivery on a backoff schedule for a period of time. Confirm your specific gateway's retry window and alert on missed webhooks past that window.
Should 3DS be forced on every transaction?
No — forcing a challenge on every transaction adds friction the risk scoring would otherwise skip. Let the gateway's risk engine decide frictionless versus challenge, and only override it where your own fraud rules require it.
How is this different from the gateway evaluation decision?
Evaluation (covered in the companion piece) decides PCI scope, methods, and settlement terms before you sign a contract. This is the engineering work once you've signed.
Can idempotency keys be reused across different orders?
No — an idempotency key should map to exactly one logical operation. Reusing one across unrelated orders will cause the gateway to return the first order's result for the second order's request.
References
- Stripe — Idempotent Requests documentation
- Stripe — Webhooks documentation
- Stripe — 3D Secure documentation
- Adyen — 3D Secure documentation
- Adyen — Webhooks documentation
- Razorpay — Payment Gateway integration documentation
- Razorpay — Webhooks documentation
- IETF — The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header-07)
Related reading
More in ArchitectureInheriting a Codebase You Did Not Write: The First Two Weeks
Two weeks to observability, not to improvements. What to read first in an inherited codebase, what to baseline before touching anything, the 5 stabilization items to land, and an evidence-based test for rescue versus rewrite.
MACH-Aligned Without Being MACH-Certified: What Composable Actually Costs
MACH certification is a vendor membership programme, not a property of your architecture. Composable commerce is worth the money for the right retailer, and the difference is whether the team budgeted for the seams: optimistic concurrency, version conflicts, eventual consistency, and the operational surface you inherit.
Replatform, Modernize, or Rebuild: Telling the Three Apart
Replatform changes where the code runs, modernize changes what the code looks like, rebuild changes what the code believes about the business. Retail teams reach for the first when they need the second. The platform gets blamed because it has a vendor name and a renewal date, and the codebase has neither.