Payment Gateway Integration: Process, Models, Best Practices

Relia Software

Relia Software

A practical guide to payment integration for backend engineers: how card payments work, the three integration models, idempotency, webhooks, and reconciliation.

A Technical Guide to Payment Gateway Integration Process

Every backend engineer eventually gets the ticket: "integrate payments." You copy the provider's quickstart, charge a test card, and it works by lunchtime. Six months later, the system has duplicate charges, missing orders, mismatched refunds, and transactions nobody can explain.

The problem is not careless code. Payment is a distributed transaction across four to six companies that don't work for you. The message saying "this happened" and the money actually moving are two different systems on two different clocks. Once that sinks in, the design decisions in this post stop looking arbitrary and start looking inevitable.

This post covers what I wish someone had walked me through before my first integration: how a card payment works end to end, what the three integration models actually cost you, and the state machine, idempotency, webhooks, reconciliation. It's card-centric, but nearly all of it carries over to bank redirects, wallets, and QR rails.

>> Read more: A Comprehensive Guide To FinTech App Development

How Does a Card Payment Work?

When a customer pays $50 on your site, at least 4 parties are involved:

  • The customer and their issuing bank (the bank that gave them the card).
  • You (the merchant) and your acquiring bank (the bank that receives money on your behalf).
  • The card network (Visa, Mastercard) routing between the two banks.
  • A payment service provider (Stripe, Adyen, Braintree, a local gateway) sitting between you and the acquirer, so you never talk to the acquirer directly.

A card payment runs on two separate systems stacked on top of each other. That's the one idea that makes the rest of this click.

  • The top layer is a messaging network. Your authorization request travels merchant → PSP → acquirer → network → issuer and back in about two seconds.
  • The bottom layer is the actual movement of money including clearing, settlement, payout. It runs on batch files and banking days, typically one to seven days later.
  • The message says "the issuer promises to pay." The money shows up later, in aggregate, minus fees. Almost every hard problem in payments lives in the gap between these two systems.
The four-party card payment flow
The four-party card payment flow.

A card payment is a sequence of four distinct events, and your system needs to track each one separately.

  • Authorization: the issuer checks funds and fraud signals, then places a hold. No money moves yet. The hold expires around seven days for e-commerce if you don't capture it in time.
  • Capture: you tell the acquirer to actually collect the funds. Digital goods often auto-capture; shipped goods typically capture on fulfillment, per card network rules. You can capture less than the authorized amount, and in some industries, more.
  • Clearing and settlement: networks exchange batch files overnight. The issuer's money reaches your acquirer, typically T+1 to T+2.
  • Payout: the acquirer or PSP transfers your balance on a schedule (daily or weekly), as one lump sum covering many transactions, net of interchange, scheme fees, and PSP markup.
The lifecycle of one card payment
The lifecycle of one card payment.

Two things fall out of this timeline and shape everything downstream:

  • First, "payment succeeded" is a meaningless sentence on its own. Is "succeeded"  as in authorized, captured, or settled? Your order flow, your accounting team, and your fraud team each mean a different one of those when they say it.
  • Second, the number that lands in your bank account never equals the sum of your transactions: fees get deducted, refunds get netted out, and payouts group transactions by the provider's processing date, not your order date.

That gap is exactly why reconciliation (discussed later in this blog) is a real engineering discipline and not a bookkeeping chore someone does in a spreadsheet.

3 Payment Gateway Integration Models

Every card integration, whatever the provider calls it, comes down to one of three architectures. The question that separates them: which parts of your system are allowed to touch raw card data? That answer decides your PCI scope, the ceiling on your checkout UX, and how much of the hard stuff you end up owning.

Model A: Hosted Checkout (Redirect)

You create a payment session server-side and redirect the customer to a page the provider hosts. The customer enters their card on the provider's domain and returns to you with a reference. Stripe Checkout, PayPal's classic flow, and most local gateways and bank/wallet rails (ect., VNPay, MoMo, PlaceToPay, and QR) work this way.

Payment integration with hosted checkout
Payment integration with hosted checkout

Why teams pick hosted checkout: card data never touches your infrastructure, which puts you in the lightest PCI bracket (SAQ A, a self-assessment questionnaire instead of an audit). The provider owns 3-D Secure, local payment methods, input validation, and most fraud UX. It's the fastest model to ship and the right default for a first integration or for any inherently redirect-based rail.

What it costs you: the UX. The page looks like the provider's, conversion tuning is limited to whatever they expose, and switching providers becomes a customer-visible change overnight.

Hosted checkout also hands you the first genuinely hard problem in payments on day one: if your fulfillment logic lives in the return handler, customers who close the tab after paying got nothing back. The webhook is the truth; the redirect is decoration.

Model B: Embedded Fields (Client-Side Tokenization)

The checkout page is yours, but the card inputs are iframes served by the provider (Stripe Elements, Braintree Drop-in/Hosted Fields, Adyen Components). The card number travels from the customer's browser straight to the PSP, which returns a one-time token. Your server charges the token. Your servers never see a PAN.

Payment integration with embedded card fields
Payment integration with embedded card fields.

Why teams pick embedded fields:

  • Full control of checkout look-and-feel and conversion funnel, while staying in a light PCI bracket (SAQ A-EP).
  • Tokenized card-on-file comes almost free, just ask the PSP to vault the card and you get a reusable token for subscriptions and one-click checkout. This is the sweet spot for most product companies, and it's why it's the default recommendation in every major provider's docs.

What it costs you:

Everything around those fields is now yours to build, including the pay button's disabled states, double-click protection, the 3DS challenge UI, error messages for 40 different decline codes.

You're coupled to the provider's JavaScript: their SDK version bumps, iframe quirks, outages taking your checkout down with them. Backend migration is easier than Model A, but you can't take vaulted cards with you unless the provider supports token portability and you actually negotiated for it. Ask about PAN export before you vault a million cards, not after.

Model C: Direct API (Server-Side Card Handling)

Card data hits your servers, either as a raw card number posted to your backend and forwarded to a gateway, or stored in your own vault. This is how older gateway integrations work (Authorize.net AIM-style, CardPointe REST with PAN, telecom BSS platforms billing stored cards), and it's what you're forced into when you route one vault across multiple acquirers, or when you're the payments platform.

Why teams pick direct API: total control in multi-acquirer routing and failover, least-cost routing, your own retry logic for subscription billing, network tokenization, one vault serving many downstream processors. At large volume, the fee savings from routing alone can fund the team.

What it costs you: the full PCI DSS program. This includes SAQ D or a Level 1 audit, quarterly scans, penetration tests, key-management ceremonies, segmented networks, and every system that so much as touches the cardholder-data environment getting pulled into audit scope.

Honestly, you don't choose Model C, the business case chooses it for you. Until processing fees show up as a top-five line item on the P&L, that case hasn't been made yet.

PCI compliance scope by integration model
PCI compliance scope by integration model.
 Hosted redirectEmbedded fieldsDirect API
PCI scopeSAQ A (minimal)SAQ A-EP (page in scope)SAQ D / Level 1 audit
Checkout UXProvider'sYoursYours
Time to shipDaysWeeksMonths+
Card-on-fileProvider-dependentProvider vault tokensYour vault, portable
Multi-processor routingNoPainfulThe whole point
Hardest problem you inheritRedirect/webhook raceClient-side edge cases, SDK couplingPCI program, vault security
Right forFirst integration, redirect-native rails, small teamsMost product companiesPayment platforms, very high volume

One decision cuts across all three models and is more expensive to reverse than the model itself: where your reusable card tokens live. Vault with one PSP and your customer base is hostage to that PSP's pricing at renewal time. If subscriptions are your business, get the token-export terms in the contract on day one.

A note on non-card rails:

Bank redirects, e-wallets, and QR payments (popular in Southeast Asia and Latin America) are architecturally Model A: session, redirect or QR scan, asynchronous confirmation. Two differences matter operationally.

First, there's usually no auth/capture split. The payment is immediate and final, so the "authorize now, capture on fulfillment" pattern doesn't apply. Chargebacks in the card sense don't exist either, but customer-initiated bank recalls and regulator-mediated disputes do, and they arrive through entirely manual channels.

Second, confirmation is often slower and flakier. A bank-transfer webhook can arrive minutes late or not at all. That's what makes the polling backstop in section 6 mandatory, not just a defensive nice-to-have.

How Should You Design a Payment State Machine?

Do not store whatever status your payment provider returns directly in your database. That may work with one provider, but it becomes fragile once you add another gateway, receive out-of-order webhooks, or introduce refunds and disputes.

The answer that actually survives contact: build your own state machine, and map provider statuses into it at the adapter boundary, nowhere else. Every provider names things differently (settling, submitted_for_settlement, succeeded, APPROVED, 00), changes the meaning between API versions, and exposes states you don't care about. Your domain model stays provider-agnostic; the messy translation table lives in exactly one file per provider.

The full lifecycle of a payment
The full lifecycle of a payment.

3 rules make a state machine like this trustworthy once it's in production:

Separate the intent from the attempts

One customer decision to pay ("I want to buy this") can produce several attempts: a decline, a retry with another card, a timeout with no known outcome. Model those as two tables, not one. The intent carries your idempotency scope and the order relationship; each attempt carries a provider reference and its own little lifecycle.

Almost every provider converged on this shape independently (Stripe's PaymentIntent/Charge is the visible example), because flattening it into a single row can't represent "the first try failed" without destroying the history.

markdown
payment_intent
  id                uuid primary key
  order_id          references orders  -- unique: one intent per order
  amount_minor      bigint not null    -- integer minor units, never floats
  currency          char(3) not null
  state             text not null      -- the state machine diagram above
  created_at, updated_at

payment_attempt
  id                uuid primary key
  intent_id         references payment_intent
  provider          text not null      -- 'stripe' | 'braintree' | ...
  provider_ref      text               -- their transaction id, unique per provider
  idempotency_key   text not null unique
  state             text not null
  provider_raw      jsonb              -- last raw response, for forensics
  created_at, updated_at

payment_event                          -- append-only audit trail
  id, attempt_id, from_state, to_state, source, raw, created_at

Make transitions append-only and guarded

Never UPDATE payments SET status = ? from a webhook handler. Record an event, then apply it through a transition function that knows the legal edges.

When a duplicate "authorized" webhook arrives after you've already captured, the guard rejects the backwards move and logs it, instead of un-capturing a payment. When finance asks "what happened to this transaction" during an incident, the payment_event table is the answer and the current-state column never is.

Store money as integers in minor units

Store money as integers in minor units. Use amount_minor bigint plus a currency code. Floating point has no place within a kilometer of a ledger.

Minor units also force you to confront zero-decimal currencies (VND, JPY, where "50000" means 50 thousand dong, not five hundred) at the type level instead of in a production incident involving a 100x overcharge. Providers disagree on this in their APIs, so normalize at the adapter.

>> Explore more

How to Prevent Duplicate Charges With Idempotency?

Here's the scenario behind almost every double-charge incident, and it isn't sloppy code causing it, it's physics:

How idempotency keys prevent double charges
How idempotency keys prevent double charges.

Here's the rule that falls out of that: never retry a charge without an idempotency key, and never generate that key fresh at retry time.

Mint it where the business intent is born, when the order commits to paying, save it on the attempt row, and reuse it verbatim on every retry of that same attempt. Generate it inside the retry loop instead, and you've just rebuilt the naive-retry panel above with extra steps.

markdown
def pay(order):
    intent  = get_or_create_intent(order)          # unique on order_id
    attempt = latest_attempt(intent)

    if attempt is None or attempt.state == FAILED:
        attempt = create_attempt(intent, key=f"{intent.id}:{next_seq(intent)}")

    if attempt.state == UNKNOWN:                   # a previous try timed out
        result = psp.lookup(attempt.idempotency_key)   # ask before acting
        if result is None and hold_window_elapsed(attempt):
            mark_failed(attempt)                   # provably never happened
            return pay(order)                      # new attempt, NEW key
        apply(attempt, result)
        return attempt

    result = psp.charge(intent.amount_minor, intent.currency,
                        idempotency_key=attempt.idempotency_key)
    apply(attempt, result)                         # guarded state transition
    return attempt

The details that separate a correct implementation from one that only looks correct:

Not every provider has first-class idempotency keys. Stripe and Adyen do, but plenty of gateways like Braintree's transaction API among them don't. There, the substitute is your own attempt table plus the provider's search-by-merchant-reference API: pass your attempt ID as the order reference on every charge, and query before retrying on timeout. Same protocol, you're just hosting the dedupe table yourself.

UNKNOWN is a real state, not an error. The transition out of it is a lookup, never a charge. If the lookup itself keeps failing, the attempt stays UNKNOWN and pages a human, which is correct behavior, not a bug.

Idempotency keys scope one operation, not one order. A legitimate second attempt after a decline needs a fresh key (hence the sequence number). Reusing the order ID alone as the key means a customer whose first card was declined can never pay with a second one.

The front end needs the same treatment. Disable the pay button on click, but don't rely on it. Send a client-generated request token so the double-submit that gets through hits the same server-side attempt. Browser-level double-clicks are the most common duplicate source and the easiest to test for.

The inverse bug is worse: charged but no order. Your server charges the card, then crashes before writing the order. The customer paid for nothing, nothing in your database even hints it happened, it surfaces in reconciliation or in a support ticket. The fix is: persist the intent before calling the PSP, and drive fulfillment from the recorded payment state, never from the in-memory code path that happened to make the call.

How Should Payment Webhooks Be Processed Reliably?

Every asynchronous fact about a payment like capture confirmed, refund completed, dispute opened, and payout sent reaches you by webhook.

Providers spell out the delivery contract clearly, and almost everyone skims past it anyway: delivery is at-least-once (you will get duplicates), unordered (the capture event can beat the authorization event to your door, especially during their retry storms), and eventually (minutes late during an incident, and a small slice effectively never).

Build a consumer that assumes exactly-once, in-order, on-time delivery, and it'll sail through your sandbox tests and quietly corrupt state in production.

Here's the architecture that actually survives contact with that contract:

The webhook processing pipeline
The webhook processing pipeline.

The load-bearing decisions in that diagram:

Acknowledge, then process. The endpoint verifies the signature, persists the raw event, returns 200, done under 100 ms, no business logic. Do the work inline and a slow database marks you as a failing endpoint.

Providers respond by backing off, and some eventually disable delivery entirely. Now your payment status pipeline is down and the provider's dashboard says it's your fault. It will be.

Verify signatures, and pin the timestamp. An unauthenticated webhook endpoint is an API that lets anyone on the internet mark orders as paid. Verify the HMAC with the raw request body (parse-then-reserialize breaks the signature and produces intermittent, maddening failures), and reject stale timestamps to kill replays.

Treat the event as a doorbell, not a data source. The most robust pattern: on receiving payment.captured for txn_123, ignore the payload's details and fetch txn_123 from the provider's API, then converge your record to what it returns. This makes duplicate and out-of-order events much easier to handle: the webhook tells you when to check, while the API tells you what is currently true.

The sweeper is not optional. Some fraction of webhooks will not arrive during the provider's incident, your deploy, and the DNS problem in between.

Any attempt sitting in PENDING or UNKNOWN beyond its expected resolution window gets actively looked up. This one cron job converts "customer emails us a week later" into "resolved within minutes, nobody noticed."

How Does Payment Reconciliation Work?

Everything up to this point keeps individual payments correct in real time. Reconciliation asks a different question entirely: across every payment, does your database agree with the provider, and does the provider agree with the bank?

It's the payments equivalent of an accounting close, and it's the mechanism that catches whatever slipped past every defense in the sections above, plus the failure modes you haven't thought of yet.

There are three records of the same reality, produced by three independent systems:

  1. Your ledger: what your application believes happened.
  2. The provider's settlement report: a daily file (CSV over SFTP, a report API, or ISO 20022 camt.053 from banks) listing every transaction they settled, with fees, refunds, and the payout batch each one belongs to.
  3. The bank statement: the lump sums that actually arrived in your account.
reconciling-ledger-against-the-settlement-file-and-bank-statement
Reconciling your ledger against the settlement file and bank statement.

Best practies to implement payment reconciliation:

  • Match on your reference, the provider's alone.

The single highest-leverage habit in a payment integration: pass your attempt ID as the merchant reference on every provider call, so every row in their settlement file carries your key. 

Without that reference, matching degrades into (amount, timestamp) heuristics, and the day two customers pay $9.99 in the same minute, your matcher guesses. Reconciliation quality is determined at integration time, years before anyone builds the reconciler.

  • Classify every break, the category is the diagnosis.
BreakUsual causeResolution
In ours, not theirsAuth never captured; lost success webhook while you recorded a local failure; provider outage mid-flightQuery the provider API; capture, fail, or expire the attempt
In theirs, not oursCharge created, then your transaction rolled back; manual charge via the provider dashboard; a second environment pointed at production keysInvestigate immediately — this break type means real money was taken from real customers with no record on your side
Amounts differFees not modeled; partial capture or partial refund missed; FX conversion on cross-currencyFix the fee or refund model; the same break recurs until it's modeled correctly
TimingA transaction near the provider's daily cutoff lands in tomorrow's fileCarry unmatched rows forward 2–3 days before alerting
  • Run it from day one, automated, with a human queue.

The classic failure sequence: launch, spreadsheet-reconcile for a month, quietly stop, discover a 0.5% webhook-loss bug eight months later with a five-figure pile of unexplained breaks that finance has already asked about twice. Daily automated matching with an ops queue for exceptions is a week of engineering. Reverse-engineering eight months of breaks is a quarter.

  • Fees deserve their own ledger rows.

Book the gross amount, the fee, and the net as separate entries per transaction, sourced from the settlement file. That structure makes "why did the bank receive $179.39 and not $186.00" answerable by query rather than by archaeology. And it's the difference between finance trusting your system and finance rebuilding it in Excel.

Conclusion

Think back to that first afternoon: you copied the quickstart, charged a test card, and closed the ticket. None of this needs to turn a simple integration into a six-week project. A state machine is a few fields and a set of rules. Idempotency is a key you hold onto for retries. A safer webhook handler mostly means fetching the current state instead of trusting whatever just landed in the payload.

Reconciliation is the bigger piece, but it earns its keep the first time it catches a missing or mismatched payment before a customer does.

The real difference shows up six months later. The double charge never happens. A stuck payment gets picked up before support hears about it, and a strange transaction lands in a break queue with enough context to investigate, instead of arriving as an angry email and a screenshot.

  • development
  • automation
  • Mobile App Development
  • Web application Development