Back to Resources

The Anatomy of a Payment Transaction: What Actually Happens When Money Moves

Card swipe to settlement in 3 seconds. Behind those 3 seconds are six institutions, three phases, and a dozen failure modes. Here's how it all works.

The Anatomy of a Payment Transaction: What Actually Happens When Money Moves

A customer taps their card. Three seconds later, the terminal says “Approved.” Behind those three seconds, data has traveled through at least six different institutions, been validated against fraud models, checked against account balances, and routed through an international card network.

Most developers treat payments as a black box: call the Stripe API, get a response, move on. That works until it doesn’t. And when it doesn’t, you need to understand what actually happened between “card tapped” and “money arrived.”

This article walks through the full lifecycle of a payment transaction, from the moment a customer initiates it to the moment funds land in a merchant’s bank account. We’ll cover the institutions involved, the failure modes that matter, and the engineering patterns (idempotency, double-entry ledgers, reconciliation) that keep financial systems from losing money.


The Players

Before we trace a transaction, you need to know who’s involved. There are more parties than most engineers expect.

RoleWhat They DoExample
CardholderThe person payingYour customer
MerchantThe business accepting paymentYour client’s shop
Payment GatewayEncrypts and routes card dataStripe, Paystack, Flutterwave
Payment ProcessorHandles the transaction mechanicsOften the same as the gateway
Acquiring BankThe merchant’s bankThe bank that holds the merchant account
Card NetworkRoutes between banks, sets the rulesVisa, Mastercard, Verve
Issuing BankThe customer’s bankThe bank that issued the card

The important thing to understand: the gateway (Stripe, Paystack) is not the bank. It’s a middleman that abstracts the complexity of talking to acquiring banks and card networks. When you call stripe.charges.create(), Stripe is doing all of the below on your behalf.


The Three Phases

Every card transaction goes through three distinct phases: Authorization, Clearing, and Settlement. Most developers only think about the first one.

sequenceDiagram
    participant C as "Customer"
    participant M as "Merchant"
    participant G as "Payment Gateway"
    participant A as "Acquiring Bank"
    participant N as "Card Network"
    participant I as "Issuing Bank"

    Note over C, I: "PHASE 1: AUTHORIZATION"
    C->>M: "Tap / Enter card"
    M->>G: "Send card + amount"
    G->>A: "Forward transaction"
    A->>N: "Route request"
    N->>I: "Check funds + fraud"
    I-->>N: "Approved / Declined"
    N-->>A: "Response"
    A-->>G: "Response"
    G-->>M: "Response"
    M-->>C: "Receipt"

    Note over C, I: "PHASE 2: CLEARING (end of day)"
    M->>G: "Submit batch"
    G->>A: "Forward batch"
    A->>N: "Send to network"
    N->>I: "Debit details"

    Note over C, I: "PHASE 3: SETTLEMENT (1-3 days)"
    I->>N: "Transfer funds"
    N->>A: "Transfer funds"
    A->>M: "Deposit to merchant"

Phase 1: Authorization

This is the part that happens in real time. When a customer taps their card:

  1. The merchant’s terminal (or your checkout page) sends the card number, expiry, CVV, and amount to the payment gateway
  2. The gateway encrypts this data and forwards it to the acquiring bank
  3. The acquiring bank routes the request through the card network (Visa, Mastercard)
  4. The card network forwards it to the issuing bank (the customer’s bank)
  5. The issuing bank checks: Is the card valid? Is there enough balance? Does this look like fraud?
  6. The issuing bank sends back an authorization code (approved) or a decline code
  7. This response travels back through the entire chain to the merchant’s terminal

All of this happens in roughly 1-3 seconds. The key thing to understand: no money has actually moved yet. Authorization is a promise, not a transfer. The issuing bank has placed a “hold” on the customer’s funds, but the money is still in their account.

This is why you sometimes see a “pending” charge on your bank statement that disappears days later. The authorization was approved, but the merchant never completed the clearing step.

Phase 2: Clearing

At the end of the business day, the merchant submits all their approved transactions as a “batch” to the acquiring bank. The acquiring bank forwards these to the card networks, which distribute them to the respective issuing banks.

Clearing is where the actual accounting happens. Each party calculates what they’re owed:

  • The interchange fee goes to the issuing bank (typically 1.5-3% of the transaction)
  • The network assessment fee goes to the card network (Visa/Mastercard)
  • The remainder goes to the acquiring bank and the payment processor

Phase 3: Settlement

Settlement is when money actually moves between bank accounts. The issuing bank transfers funds (minus interchange fees) to the card network, which forwards them to the acquiring bank, which deposits them into the merchant’s account.

This typically takes 1-3 business days. For some merchants (especially new ones or those in high-risk categories), it can take longer because the acquiring bank holds funds as a risk buffer.

This is the gap that kills naive payment implementations. If your system treats “authorized” as “paid,” you will have cases where the authorization was approved, the customer sees a charge, but your merchant never actually receives the money. This happens more often than you’d think.


Why Payments Fail (And Where)

Understanding the happy path is important. Understanding the failure modes is essential.

flowchart TD
    TX["Transaction Initiated"] --> AUTH{Authorization}
    AUTH -->|Declined| D1["Insufficient Funds"]
    AUTH -->|Declined| D2["Card Expired"]
    AUTH -->|Declined| D3["Fraud Detection"]
    AUTH -->|Timeout| T1["Network Timeout"]
    AUTH -->|Approved| HOLD["Funds Held"]
    
    HOLD --> CLEAR{Clearing}
    CLEAR -->|Batch Failed| F1["Settlement Delayed"]
    CLEAR -->|Chargeback| F2["Funds Reversed"]
    CLEAR -->|Success| SETTLE["Settlement"]
    
    SETTLE --> DONE["Funds in Merchant Account"]

    style D1 fill:#c0392b,color:#fff
    style D2 fill:#c0392b,color:#fff
    style D3 fill:#c0392b,color:#fff
    style T1 fill:#e67e22,color:#fff
    style F1 fill:#e67e22,color:#fff
    style F2 fill:#c0392b,color:#fff

The most dangerous failure mode is the timeout. When your system sends a payment request and doesn’t receive a response:

  • Did the payment go through?
  • Did the gateway receive it?
  • Was it authorized but the response was lost?

You genuinely don’t know. And the worst thing you can do is retry without protection. This brings us to the single most important concept in payment engineering.


Idempotency: The Guard Against Double Charges

Idempotency means that performing the same operation multiple times produces the same result as performing it once. In payment systems, this is not optional. It is the difference between “we handled a network timeout” and “we charged the customer twice.”

The pattern is simple: every payment request gets a unique key. If the same key is sent again (because of a retry, a network hiccup, a user double-clicking), the system returns the original result instead of processing a new transaction.

// Go: Idempotent payment handler
func (s *PaymentService) ProcessPayment(ctx context.Context, req PaymentRequest) (*PaymentResult, error) {
    // Step 1: Check if we've already processed this idempotency key
    existing, err := s.store.GetByIdempotencyKey(ctx, req.IdempotencyKey)
    if err == nil && existing != nil {
        // Already processed. Return the original result.
        return existing, nil
    }

    // Step 2: Create a pending record BEFORE calling the payment provider
    record := &PaymentRecord{
        IdempotencyKey: req.IdempotencyKey,
        Amount:         req.Amount,
        Currency:       req.Currency,
        Status:         "pending",
        CreatedAt:      time.Now(),
    }
    if err := s.store.Insert(ctx, record); err != nil {
        // If this fails with a unique constraint violation,
        // another goroutine is processing the same key. Return conflict.
        return nil, ErrDuplicateRequest
    }

    // Step 3: Call the payment gateway
    gatewayResp, err := s.gateway.Charge(ctx, req.Amount, req.Currency, req.CardToken)
    if err != nil {
        // Mark as failed, but keep the record so retries hit step 1
        record.Status = "failed"
        record.Error = err.Error()
        s.store.Update(ctx, record)
        return nil, fmt.Errorf("gateway error: %w", err)
    }

    // Step 4: Update the record with the gateway response
    record.Status = "completed"
    record.GatewayID = gatewayResp.TransactionID
    s.store.Update(ctx, record)

    return &PaymentResult{
        TransactionID: record.ID,
        GatewayID:     gatewayResp.TransactionID,
        Status:        "completed",
    }, nil
}

The critical detail is Step 2: you create the record before calling the gateway. If the gateway call times out and the client retries, the second attempt finds the pending record and either waits or returns early. Without this, you’re relying on the gateway’s own idempotency (which Stripe supports, but not every provider does).

// Node.js: Idempotent payment endpoint
app.post('/api/payments', async (req, res) => {
  const { idempotencyKey, amount, currency, cardToken } = req.body;

  // Check for existing payment with this key
  const existing = await db.payments.findOne({ idempotencyKey });
  if (existing) {
    return res.json(existing); // Return original result
  }

  // Create pending record first
  const payment = await db.payments.create({
    idempotencyKey,
    amount,
    currency,
    status: 'pending',
  });

  try {
    const charge = await stripe.charges.create(
      { amount, currency, source: cardToken },
      { idempotencyKey } // Stripe also supports idempotency natively
    );

    payment.status = 'completed';
    payment.gatewayId = charge.id;
    await payment.save();

    res.json(payment);
  } catch (err) {
    payment.status = 'failed';
    payment.error = err.message;
    await payment.save();

    res.status(500).json({ error: 'Payment failed' });
  }
});

💡 Stripe’s idempotency key documentation explains their server-side implementation in detail. Keys are cached for 24 hours, and Stripe validates that retry parameters match the original request.


Double-Entry Ledgers: Why Balance Fields Lie

Here’s a pattern that burns teams every few months: storing a user’s balance as a single number in the database.

-- THE TRAP: a mutable balance field
UPDATE wallets SET balance = balance - 100.00 WHERE user_id = 42;
UPDATE wallets SET balance = balance + 100.00 WHERE user_id = 77;

This looks simple. It is also wrong for any system that handles real money. Here’s why:

  • If the second UPDATE fails, user 42 lost ₦100 that user 77 never received
  • You have no audit trail showing why the balance changed
  • Two concurrent transactions can read the same balance and both succeed, creating money from thin air
  • You cannot reconstruct the account history from a single number

The fix is double-entry bookkeeping: every movement of money creates two entries that sum to zero. Money doesn’t appear or disappear. It moves from one account to another, and the system can prove it.

-- The ledger schema
CREATE TABLE accounts (
    id          UUID PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    type        VARCHAR(50) NOT NULL,  -- 'asset', 'liability', 'revenue'
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE transactions (
    id              UUID PRIMARY KEY,
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    description     TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE postings (
    id              UUID PRIMARY KEY,
    transaction_id  UUID REFERENCES transactions(id),
    account_id      UUID REFERENCES accounts(id),
    amount          BIGINT NOT NULL,  -- in smallest currency unit (kobo, cents)
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- CONSTRAINT: every transaction must balance to zero
-- Enforced at the application level before insert:
-- SUM(postings.amount) WHERE transaction_id = X must equal 0

Now a transfer looks like this:

-- Transfer ₦100 from User 42's wallet to User 77's wallet
BEGIN;

INSERT INTO transactions (id, idempotency_key, description)
VALUES ('txn-001', 'transfer-42-77-1715600000', 'P2P transfer');

-- Debit User 42 (money leaves their account)
INSERT INTO postings (id, transaction_id, account_id, amount)
VALUES ('post-001', 'txn-001', 'wallet-42', -10000);

-- Credit User 77 (money enters their account)
INSERT INTO postings (id, transaction_id, account_id, amount)
VALUES ('post-002', 'txn-001', 'wallet-77', +10000);

COMMIT;

-- Both postings are written atomically. If anything fails, nothing is written.
-- The balance check: SELECT SUM(amount) FROM postings WHERE account_id = 'wallet-42';

The balance is never stored. It is always derived. To get User 42’s current balance, you sum all their postings:

SELECT SUM(amount) FROM postings WHERE account_id = 'wallet-42';

This is slower than reading a single field. For performance, you can maintain a materialized balance that’s updated asynchronously, but the postings table remains the source of truth. If the materialized balance ever disagrees with the sum of postings, the sum of postings wins.


Reconciliation: When Your Numbers Don’t Match

Here’s the uncomfortable reality of payment systems: your database and the bank will disagree. Not sometimes. Regularly.

Reasons include:

  • A charge was authorized but never settled
  • A refund was processed by the gateway but your webhook failed
  • A chargeback was filed and you weren’t notified
  • Settlement timing differences (you recorded it Tuesday, the bank settled it Thursday)
  • Currency conversion rounding

Reconciliation is the process of comparing your internal ledger against the bank’s records and resolving the differences. It is not glamorous work, but it is the work that determines whether a payment system is trustworthy.

flowchart LR
    IL["Internal Ledger"] --> R["Reconciliation Engine"]
    BS["Bank Statement"] --> R
    R --> MATCH["Matched Records"]
    R --> DISC["Discrepancies"]
    DISC --> INV["Investigation Queue"]
    INV --> RES["Resolution"]
    RES -->|Adjust| IL

    style DISC fill:#e67e22,color:#fff
    style INV fill:#c0392b,color:#fff

A basic reconciliation process:

type ReconciliationResult struct {
    Matched       []MatchedRecord
    InternalOnly  []LedgerEntry    // We have it, bank doesn't
    ExternalOnly  []BankEntry      // Bank has it, we don't
    AmountMismatch []MismatchRecord // Both have it, amounts differ
}

func Reconcile(ledger []LedgerEntry, bankStmt []BankEntry) ReconciliationResult {
    result := ReconciliationResult{}
    bankMap := make(map[string]BankEntry)

    // Index bank entries by reference
    for _, entry := range bankStmt {
        bankMap[entry.Reference] = entry
    }

    // Compare each internal record against the bank
    for _, entry := range ledger {
        bankEntry, found := bankMap[entry.GatewayRef]
        if !found {
            // We recorded a transaction the bank doesn't have
            result.InternalOnly = append(result.InternalOnly, entry)
            continue
        }

        if entry.Amount != bankEntry.Amount {
            // Both have the record but amounts disagree
            result.AmountMismatch = append(result.AmountMismatch, MismatchRecord{
                Internal: entry,
                External: bankEntry,
            })
        } else {
            result.Matched = append(result.Matched, MatchedRecord{
                Internal: entry,
                External: bankEntry,
            })
        }

        delete(bankMap, entry.GatewayRef) // Mark as processed
    }

    // Remaining bank entries we don't have internally
    for _, entry := range bankMap {
        result.ExternalOnly = append(result.ExternalOnly, entry)
    }

    return result
}

The output of reconciliation is not “everything matches.” The output is a discrepancy report that your operations team investigates. A healthy payment system reconciles daily and resolves discrepancies within 24-48 hours.


The Practical Takeaway

If you’re building a system that handles money, here’s the minimum engineering bar:

  1. Never treat authorization as settlement. Track transaction states explicitly: pending → authorized → captured → settled (or failed, refunded, disputed)

  2. Use idempotency keys on every payment request. Network failures are not edge cases. They are Tuesday.

  3. Implement a double-entry ledger. If your system has a balance field that gets incremented and decremented directly, you will lose money. It’s not a question of if, but when.

  4. Reconcile daily. Compare your ledger against your payment provider’s records. Automate what you can, but expect to manually investigate discrepancies.

  5. Store amounts as integers in the smallest currency unit. ₦100.50 is stored as 10050 kobo. Floating-point arithmetic and money do not mix.

The engineers who build payment systems that don’t lose money aren’t doing anything exotic. They’re being disciplined about fundamentals that most teams skip because they seem tedious.

Tedious is the correct word. Reliable is the result.


Further Reading & Resources

🚀 Stripe: Idempotent Requests ↗ Official API documentation

🚀 How Credit Card Processing Works ↗ Visual explanation of the full payment flow

💡 Double-Entry Bookkeeping ↗ on Wikipedia

💡 Payment Card Industry ↗ on Wikipedia

💡 Interchange Fees ↗ on Wikipedia

🚀 Concurrency Decoded: Threads, Processes, and Runtimes ↗ How systems handle many things at once

🚀 Why We Chose Go for Our Infrastructure Layer ↗ Our engineering decision-making process

💡 ISO 8583: Financial Transaction Messaging ↗ The standard format for card transaction messages


This technical article is part of the Ellomas Technologies Knowledge Repository. We specialize in building high-reliability digital infrastructure for credit, utilities, and financial services.

Oluwatomiwa Amole
Written by

Oluwatomiwa Amole

Founder & Lead Engineer

7+ years designing distributed systems, payment infrastructure, and credit platforms serving millions of users. Founder of Ellomas Technologies.

Have a technical challenge?

Let's discuss how we can engineer a solution for your infrastructure.

Work with Ellomas