Case study · Payments

Scaling a payments platform past a million transactions a day without double charges

As volume grew, retries and timeouts started producing duplicate charges and a growing manual reconciliation queue. The goal was a payment path that was safe to retry at every step, published events reliably, and reconciled itself, sized for more than a million transactions a day.

Case studyPayments · PlatformSumanta Mukhopadhyay8 min read
#architecture#streaming#platform#kubernetes#observability
CASE STUDY · CASE STUDY · PAYMENTS28 Oct 2026 · 01:18 GST
At a glance
SectorPayments · Platform
My roleFractional CTO and principal engineer
EngagementArchitecture, hands-on build and team enablement
ScopeAPI idempotency, data model, eventing, ledger, reconciliation, operations
What changed
  • Client and provider retries made safe, so a timeout no longer risked a second charge
  • Events published if and only if the payment write committed
  • Reconciliation against providers automated, with exceptions routed to people
  • A platform the in-house team could run and extend as volume grew past 1M+ transactions a day
PostgresIdempotency-Key headerTransactional outboxDebeziumKafkaDouble-entry ledgerKubernetesOpenTelemetry

Anonymised composite. Client names, identifying details and confidential figures are withheld; public facts are cited in the references.

The situation

A payments platform processing card and bank transfers for merchants was growing quickly. The architecture had served it well at lower volume: a payments service writing to Postgres, calling external providers, then publishing an event for the ledger and notifications. As traffic climbed towards and past 1M+ transactions a day, the edges started to fray. Mobile clients retried on timeouts. Providers occasionally responded slowly and then succeeded anyway. Events were sometimes published for writes that rolled back, or not published for writes that committed.

The symptoms were familiar: occasional duplicate charges, refunds issued by hand, and a reconciliation team matching provider settlement files to internal records in spreadsheets.

What had to be true

  • Every step safe to retry, from the client to the provider call.
  • One source of truth per payment, with state changes that cannot be lost or invented.
  • Reliable events: published exactly when the database change commits.
  • A ledger that balances, and reconciliation that runs itself for the normal cases.
  • Operable by the in-house team, with clear signals when something drifts.

Architecture

Edge
Merchant and app clientssend Idempotency-KeyAPI gatewayauth, rate limits
Payments service
Idempotency storekey, request hash, responsePayment state machineexplicit transitionsProvider adaptersprovider-side keys
Data
Postgrespayments and outbox, one transactionOutbox relaylog-based CDC
Events
Kafkakeyed by paymentLedger servicedouble-entry, idempotent consumerNotificationsidempotent consumer
Assure
Reconciliationprovider files vs ledgerException queuehuman reviewTracing and SLOsend to end
Layered architecture: idempotency at the edge, a single write plus outbox, and reconciliation closing the loop.

The data flow, end to end

  1. KeyClient sends an Idempotency-Key; a repeat with the same key returns the stored result
  2. WritePayment row and outbox record written in one Postgres transaction
  3. CallProvider called with its own idempotency key derived from the payment ID
  4. PublishRelay reads committed outbox records from the database log and publishes to Kafka
  5. PostLedger consumes events idempotently and records balanced double entries
  6. ReconcileProvider settlement data matched to ledger entries; mismatches become exceptions
Each hop either deduplicates or is safe to repeat, so retries anywhere are harmless.

The edge followed the IETF Idempotency-Key header draft, which defines how clients make non-idempotent methods such as POST fault-tolerant1, and borrowed conventions familiar from established payment APIs, such as rejecting a reused key with different parameters2. The full pattern is in idempotent payments and order pipelines.

The hard part: the ambiguous middle

Duplicates at the edge are the easy case. The hard case is a request that times out somewhere in the middle: did the provider charge or not? The design handled it explicitly:

  • The payment state machine had an "unknown" state for ambiguous provider outcomes, rather than guessing success or failure.
  • Unknown payments were resolved by querying the provider, never by blindly retrying a charge.
  • Provider calls carried their own idempotency keys, so a retry that did happen could not create a second charge on the provider's side.
  • Events were produced through a transactional outbox: the change and the outbox record commit together and a relay publishes afterwards, which removes the dual-write problem without distributed transactions3. The relay used Debezium's outbox event router to turn outbox rows into routed Kafka events4.

Because the relay can deliver a message more than once, every consumer was idempotent too, keyed by event ID. CDC details are in change data capture and event streams.

Exactly-once is not a property you buy from a broker. It is what you get when every producer is reliable and every consumer is idempotent.

How the engagement ran

Client
  1. Generates key
  2. Retries on timeout
  3. Gets the same result
Payments service
  1. Deduplicates by key
  2. Writes payment and outbox
  3. Resolves unknown states
Ledger and recon
  1. Consumes idempotently
  2. Posts double entries
  3. Matches provider data
Retries are expected at every lane; none of them changes the outcome.
Phase 1Stabilise
  • Map failure modes
  • Idempotency at the edge
  • Tracing across hops
Phase 2Outbox
  • Single write plus outbox
  • CDC relay
  • Idempotent consumers
Phase 3Ledger
  • Double-entry postings
  • Unknown-state handling
  • Provider-side keys
Phase 4Reconcile and hand off
  • Automated matching
  • Exception queue
  • SLOs and runbooks
Stop the bleeding at the edge first, then fix the event path, then close the loop.

Every hop carried a trace context, so a single payment could be followed from the client request through the provider call, the outbox, the ledger posting and reconciliation when something looked wrong.

Service level objectives and alerting followed SLOs and error budgets for small teams.

Key decisions and trade-offs

DecisionAlternativeWhy
Idempotency keys at the API edgeDeduplicate downstreamStops duplicates before any side effect happens
Transactional outbox with CDCWrite to database then publishEvents exist if and only if the write committed
Explicit unknown stateTreat timeouts as failuresAvoids both double charges and lost payments
Postgres as the payment source of truthEvent-sourced paymentsFamiliar to the team; strong transactional guarantees
Automated reconciliation with exceptionsManual matchingScales with volume; people handle only real anomalies

What I'd do again, and differently

Again: name the ambiguous states in the data model and design for them explicitly. Differently: build reconciliation earlier. It is the system that tells you whether everything else is working, and it is the first place a new failure mode shows up.

If your platform is outgrowing its original design, the fractional CTO page describes how I work with teams on this, or analyse your challenge and bring the result to a call.

References
  1. 1
  2. 2
    Idempotent requestsStripe API documentation
  3. 3
    Pattern: Transactional outboxmicroservices.io, Chris Richardson
  4. 4
    Outbox Event RouterDebezium documentation
FAQ

Common questions

What causes double charges?

Usually a retry after an ambiguous failure: the client or a gateway times out, retries, and the original request had in fact succeeded. Without idempotency, the second request creates a second charge.

What is the transactional outbox pattern?

The service writes the business change and an outbox record in the same database transaction. A relay then publishes outbox records to the message broker, so events are sent if and only if the transaction commits.

Why automate reconciliation?

At high volume, manual matching of internal records to provider settlement files becomes a queue that never empties. Automated matching handles the routine cases and routes only genuine exceptions to people.

Keep reading

Related

Open to advisory, fractional CTO & select builds

Let's talk about what you're building.

The fastest route is a free 30-minute call. Prefer to write? Send a message and I'll reply personally within two business days.

Start a conversation

How would you like to reach me?

Book a 30-min callFastest. Pick any open slot on my calendar.→ Write in GmailOpens a pre-filled draft in your browser.→ Use my email appOutlook, Apple Mail or your default client.→

I reply personally within two business days. Time zones from the Americas and Europe to the Middle East and Asia-Pacific.