Code a payment gateway with pluggable providers
Open in the interactive app: practice, quiz, mock interview →In one minute
- A payment gateway sits between merchants and card processors. Its core object is the payment intent: one attempt to collect one amount, moving through a small, strict state machine (created, authorized, captured, refunded, plus the failure states).
- Every state change must be safe to retry. Merchants retry on timeouts, so
createtakes an idempotency key and every transition is checked against the current state, which makes a repeated call a no-op instead of a second charge. - The dangerous state is unknown. When a processor times out, the card may or may not have been authorized. Failing over to a second processor at that point can charge the customer twice; the correct move is to stop, record the ambiguity and reconcile.
- Processors differ in API but not in meaning, so put each behind an adapter with one interface, and put the order you try them in behind a routing strategy.
- The shape is multi-step consistency with trust but verify. This lesson is the object model inside one service; the ledger, the distributed workflow and the money movement are in Design Payment System. The same "ambiguous outcome means reconcile, not retry elsewhere" rule applies to any call with side effects, such as provisioning a cloud VM or sending a bank wire.
Requirements
Functional:
- A merchant creates a payment intent for an amount in minor units (cents) with an idempotency key. Repeating the request with the same key and parameters returns the same intent; reusing the key with different parameters is an error.
- The gateway authorizes the intent by calling processors in a configured order, failing over only when it is certain the previous processor did not act.
- An authorized intent can be captured once, for any amount up to the authorized amount, before the authorization expires. It can instead be voided.
- A captured intent can be refunded in one or more partial refunds whose total never exceeds the captured amount.
- An intent whose outcome is unknown can be resolved by reconciliation to authorized or failed.
Non-functional:
- Never charge a customer twice for one intent, including under merchant retries and processor timeouts.
- Every transition is validated against the current state; an invalid call changes nothing.
- Amounts are integers in minor units; no floating point anywhere.
- Time comes from an injected clock, so authorization expiry is testable.
Out of scope: card data storage and PCI scope, 3-D Secure, currencies and FX, payouts to merchants, fraud scoring and the double-entry ledger. Name each; the ledger in particular belongs to the system design lesson.
Entities and relationships
- PaymentIntent is the aggregate root: id, merchant, amount, status, the processor that authorized it, the authorization time, the captured amount and the refunded total. All rules live in its transition methods.
- Money is a value: an integer amount in minor units and a currency code. Arithmetic on two values with different currencies fails.
- IdempotencyRecord maps a merchant's key to the intent id and a fingerprint of the request (merchant plus amount), so a replay can be told apart from a conflicting reuse.
- Processor is the interface for a card processor:
authorize(intentId, amount)returning approved, declined, unavailable or timeout, pluscapture,voidandrefund. Each real processor gets an adapter. - RoutingStrategy returns the ordered list of processors to try for an intent: by cost, by card network, by merchant, or by recent success rate.
- PaymentGateway is the façade merchants call. It loads the intent, delegates to the state machine and the processors, and persists the result.
- Attempt records each processor call (processor, request time, outcome, processor reference). It is the evidence reconciliation uses.
- Clock supplies time; EventPublisher emits webhooks such as
payment_intent.succeeded.
Relationships: the gateway owns intents and idempotency records. An intent is composed of its attempts and refers to its processor by name, because processor configuration can change while intents live for days.
Keep refunds on the intent rather than in a separate aggregate: "total refunds never exceed capture" needs both numbers under one lock.
Class diagram
classDiagram
class PaymentGateway {
-intents: Map~IntentId, PaymentIntent~
-idempotency: Map~Key, IdempotencyRecord~
+create(key, merchant, amount) IntentId
+authorize(id) Status
+capture(id, amount)
+void(id)
+refund(id, amount)
+resolve(id, outcome)
}
class PaymentIntent {
+id: IntentId
+merchant: string
+amount: Money
+status: Status
+processor: string
+authorizedAt: int
+captured: Money
+refunded: Money
-attempts: Attempt[]
+markAuthorized(processor, at)
+capture(amount, now)
+refund(amount)
}
class Attempt {
+processor: string
+at: int
+outcome: Outcome
}
class IdempotencyRecord {
+key: string
+fingerprint: string
+intentId: IntentId
}
class Processor {
<<interface>>
+name() string
+authorize(intentId, amount) Outcome
}
class RoutingStrategy {
<<interface>>
+route(intent) Processor[]
}
class Clock {
<<interface>>
+now() int
}
PaymentGateway "1" *-- "many" PaymentIntent
PaymentGateway "1" *-- "many" IdempotencyRecord
PaymentIntent "1" *-- "many" Attempt
PaymentGateway --> RoutingStrategy
PaymentGateway --> Clock
RoutingStrategy --> Processor
AcquirerAAdapter ..|> Processor
AcquirerBAdapter ..|> ProcessorKey flows
Authorization with failover. The attempt is recorded before each call, so a crash mid-call leaves evidence, and failover happens only on an outcome that proves the processor did nothing.
sequenceDiagram
participant M as Merchant
participant G as PaymentGateway
participant I as PaymentIntent
participant R as RoutingStrategy
participant A as Processor A
participant B as Processor B
M->>G: authorize(pi_7)
G->>I: lock, status is created?
G->>R: route(pi_7)
R-->>G: [A, B]
G->>I: record attempt A
G->>A: authorize(pi_7, 4200)
A-->>G: unavailable (connection refused)
G->>I: record attempt B
G->>B: authorize(pi_7, 4200)
B-->>G: approved
G->>I: markAuthorized(B, now), unlock
G-->>M: authorized
Note over G,B: A timeout from B would stop here as unknownDesign patterns used and why
- State for the intent lifecycle. The set of legal moves is small and strict: created to authorized, declined, failed or unknown; unknown to authorized or failed; authorized to captured, voided or expired; captured to partially refunded or refunded. A transition table (current state, operation) to next state, checked in one place, is easier to audit than status checks scattered across methods. The State lesson covers when to promote this to classes.
- Adapter for processors. Each acquirer has its own request format, error codes and idempotency header. The adapter maps "card declined, code 05" or "HTTP 503" onto the four outcomes the gateway understands. The gateway never sees a vendor type.
- Strategy for routing. Cheapest-first, network-specific, merchant-pinned and success-rate-weighted routing are interchangeable classes behind
route(intent). - Chain of responsibility, constrained: the gateway walks the routed list and passes the request on only for outcomes that prove nothing happened. The constraint is the point; an unconstrained chain is how double charges happen. See the Chain of Responsibility lesson for the general form.
- Idempotency key with fingerprint, the same idea as the Idempotency lesson, applied at intent creation; later transitions are idempotent because of the state machine itself.
- Value object for money: integer minor units plus currency, immutable, with arithmetic that refuses mixed currencies.
Concurrency and edge cases
- Merchant retries create. Same key, same body: return the existing intent. Same key, different amount: reject with a conflict, because the merchant has a bug and silently returning the old intent would charge the wrong amount.
- Merchant retries authorize while the first call is in flight. Lock the intent (row lock or per-intent mutex) for the whole authorize step, or mark it
authorizingfirst and make the second call return that status. Two concurrent processor calls for one intent is a double charge. - Processor timeout. The request may have reached the processor. Mark the intent unknown, stop, and reconcile: query the processor by your reference, or wait for its settlement file. Retrying the same processor with its idempotency header is safe if it supports one; failing over to a different processor is not.
- Declined is final for this attempt. A decline is the issuer's answer about this card, so trying another processor just asks the same issuer again and looks like card testing to fraud systems.
- Authorization expiry. Card authorizations lapse (commonly after about seven days, varying by network and merchant type). A capture after that may fail or be downgraded, so check expiry with the injected clock and move the intent to expired rather than calling the processor.
- Partial capture. Capture 3,000 of a 4,200 authorization (a hotel that charged less than the hold). The remaining 1,200 is released, and refunds are bounded by 3,000, not 4,200.
- Refund racing refund. Two support agents refund 2,000 each on a 3,000 capture. Both checks must happen under the intent's lock so the second fails.
Down the stack
Layer 1The contract
create returns the same intent for the same key and parameters and changes nothing on replay. authorize calls at most one processor that might have acted, and returns authorized, declined, failed (no processor acted) or unknown (one might have). capture succeeds once, only when authorized and not expired, for 1 up to the authorized amount. The sum of refunds never exceeds the captured amount. Any rejected call leaves the intent unchanged.
Layer 2The mechanism
In a database, intents are rows with a status column, and every transition is a conditional update: UPDATE intent SET status = 'captured', captured = $2 WHERE id = $1 AND status = 'authorized' AND authorized_at > $3, checking that one row changed. Idempotency is a table with a unique index on (merchant_id, key); inserting first and reading on conflict turns the race between two retries into a database decision. Attempts are written before the network call ("record, then call"), so after a crash a sweeper finds attempts with no outcome and reconciles them. Underneath the processor's HTTP API, card authorization travels as ISO 8583 messages: an authorization request has message type 0100 and the response 0110, with field 39 carrying the response code (00 for approved). The adapter's job is to turn that code space into four outcomes and to forward your intent id as the processor's idempotency key or merchant reference.
Layer 3The machine
The local work is microseconds: a row lock, a conditional update, a commit whose write-ahead log fsync costs roughly 0.1 to 1 ms on an SSD. The processor call is the expensive part: a TLS connection to the acquirer, then a hop to the card network and the issuer, typically a few hundred milliseconds and sometimes several seconds. That gap shapes the code. Holding a row lock across that call is fine for one intent but not for a shared counter, so per-merchant aggregates update asynchronously. Timeouts must be shorter than the merchant's own timeout, or the merchant retries while your first attempt is still in flight; a common layout is a 30 to 60 second processor timeout under a longer merchant-facing one, with the unknown state absorbing the difference. A TCP connection reset after the request bytes left the socket buffer tells you nothing about whether the processor acted, which is why "connection refused before sending" and "reset after sending" map to different outcomes.
Why it's built this way
- Networks lose responses, so merchants retry, so creation needs an idempotency key and transitions need state checks.
- A lost response can hide a successful authorization, so a timeout is unknown, not failed, and must not trigger failover.
- Unknown outcomes must be resolved from evidence, so attempts are recorded before each call and reconciled later.
- Processors fail independently and cost different amounts, so they sit behind adapters and a routing strategy, and failover is limited to provably safe outcomes.
- Rounding and race errors in money are both expensive and visible, so amounts are integers and every check-then-act runs under one lock or one conditional update.
Extensibility
- New processor. A new adapter and a routing rule. No change to the intent.
- 3-D Secure. A new state,
requires_action, between created and authorized, entered when the processor demands a challenge and left by a callback. The transition table gains two rows. - Multiple captures. Some merchants ship orders in parts. Replace
capturedwith a list of capture entries bounded by the authorized amount, the same shape refunds already have. - Ledger. Each transition emits a balanced journal entry to the ledger described in Design Payment System. The intent stays the source of truth for lifecycle; the ledger for balances.
Leadership lens
- Build first: the transition table with tests for every illegal move, and the timeout-means-unknown rule with a reconciliation job. Fancy routing earns money; a double charge loses customers and invites chargebacks.
- Ask in review: what happens on a processor timeout; where is the lock for check-then-act; what happens if the merchant retries with a different amount; how does an unknown intent get resolved and who is paged if it does not.
- Track the share of intents in unknown and the age of the oldest one. Both are leading indicators of processor trouble and of support tickets.
- Each processor integration is a long-lived dependency with its own certification and quirks. Assign an owner per adapter, keep a sandbox contract test suite per processor, and run it daily.
Check yourself
Processor A times out on an authorization. Why is failing over to processor B wrong?
Show answer
A may have authorized the card before the response was lost. Calling B could authorize a second time, so the intent goes to unknown and is reconciled with A instead.
A merchant reuses an idempotency key with a different amount. Why reject instead of returning the original intent?
Show answer
The merchant believes it is charging the new amount. Returning the old intent hides the bug and charges the wrong amount; a conflict error surfaces it.
An intent was authorized for 4,200 and captured for 3,000. What is the refund limit, and why?
Show answer
3,000, because only the captured amount moved; the remaining authorization was released.
Why record each attempt before calling the processor rather than after?
Show answer
A crash during the call would otherwise leave no trace that the processor was contacted, and the intent could be retried elsewhere. The pre-recorded attempt is what reconciliation looks for.
Watch
- System Design: Payment Gateway (System Design Fight Club): an idempotent authorize-and-capture workflow, end to end.
- Idempotency and Intelligent Retry | PayPal (ByteMonk): how a payments company combines idempotency keys with retries.
Free resources
- The PaymentIntent object (Stripe API docs): a production intent model with its statuses, amounts and capture fields.
- Place a hold on a payment method (Stripe docs): separate authorization and capture, partial capture and authorization validity windows.
- Designing robust and predictable APIs with idempotency (Stripe): why idempotency keys exist and how clients should retry.
- Implementing Stripe-like idempotency keys in Postgres (Brandur Leach): recovery points, atomic phases and the schema behind safe retries.
- ISO 8583 (Wikipedia): the card message standard under processor APIs, including message type indicators and response codes.
- Making retries safe with idempotent APIs (AWS Builders' Library): the general rules for ambiguous outcomes and client tokens.