Low-Level Design: Payment Gateway — Card Processing, Idempotency, Refunds, and Fraud Detection

Core Entities

PaymentIntent: intent_id (idempotency key), amount_cents, currency, customer_id, payment_method_id, status (CREATED, PROCESSING, SUCCEEDED, FAILED, CANCELLED), created_at, metadata. PaymentMethod: method_id, customer_id, type (CARD, BANK, WALLET), last_four, card_brand, billing_address, is_default, tokenized_card_id (from card vault). Refund: refund_id, payment_intent_id, amount_cents, reason, status (PENDING, SUCCEEDED, FAILED), created_at. FraudSignal: signal_id, payment_intent_id, signal_type, score, action (ALLOW, REVIEW, BLOCK).

Card Processing Flow

1. Customer submits card details -> client-side tokenization (Stripe.js / Braintree SDK) — raw card data never touches your server. 2. Your server receives the token and creates a PaymentIntent. 3. Submit to card network via acquirer: Authorization request (Reserve funds on card). 4. Card network routes to issuer. 5. Issuer approves/declines (based on funds, fraud rules). 6. Authorization response: approved with authorization_code, or declined with reason_code. 7. On approval: Capture (move funds from card to merchant). Authorization and capture can be separate (authorize-capture flow for pre-authorization) or combined (charge immediately). 8. Settlement: acquirer batches and settles with card network daily.

Idempotency

Payment operations must be idempotent — a client retry (network timeout) should not double-charge. Implementation: the client generates an idempotency_key (UUID) for each payment attempt. Server: SELECT * FROM payment_intents WHERE idempotency_key = X. If found: return the existing result (same response as the original). If not found: process and store with the key. The SELECT + INSERT must be atomic: use INSERT … ON CONFLICT (idempotency_key) DO NOTHING and check if the row was inserted. Idempotency keys expire after 24 hours. Same key, different amount: reject (key collision with conflicting parameters — return error).

Refund Processing

class RefundService:
    def create_refund(self, intent_id: str, amount_cents: int,
                      reason: str) -> Refund:
        intent = self.db.get_intent(intent_id)
        if intent.status != PaymentStatus.SUCCEEDED:
            raise ValueError("Can only refund succeeded payments")
        already_refunded = self.db.sum_refunds(intent_id)
        if already_refunded + amount_cents > intent.amount_cents:
            raise ValueError("Refund exceeds original charge")
        refund = Refund(intent_id=intent_id, amount_cents=amount_cents,
                        reason=reason, status=RefundStatus.PENDING)
        self.db.insert(refund)
        result = self.acquirer.refund(
            intent.acquirer_transaction_id, amount_cents
        )
        status = (RefundStatus.SUCCEEDED
                  if result.success else RefundStatus.FAILED)
        self.db.update_refund(refund.id, status)
        return refund

Fraud Detection

Rule-based signals: velocity checks (more than 3 failed attempts in 10 minutes from same IP/card), high-risk countries, mismatched billing address vs IP geolocation, unusual purchase amount vs customer history. ML signals: model trained on historical fraud data returns a fraud score [0, 1]. Actions based on score: score 0.7: decline. Card velocity: track failed authorization attempts per card_number per hour in Redis (INCR card:{hashed_number}:failures:hour EXPIRE 3600). Decline the card if failures > 3 in any hour window.

Card Vault

Never store raw card numbers (PAN) in your database — this requires PCI DSS compliance at the highest level. Use a card vault (Stripe, Braintree, or self-hosted like Basis Theory). The vault stores the raw PAN and returns a token. You store the token. For recurring charges: submit the token to the vault; it retrieves the PAN and processes the charge. This limits your PCI scope dramatically (SAQ A vs SAQ D). Network tokens: Visa and Mastercard issue network tokens that replace the PAN — immune to merchant database breaches since the token is useless without the merchant-specific key.

Webhook Delivery

Payment outcomes (success, failure, dispute) are delivered asynchronously via webhooks. Your system must handle: duplicate webhook deliveries (same event sent twice), out-of-order delivery, and processing failures. Implement an idempotent webhook handler: store processed webhook event_ids; skip duplicates. Use FOR UPDATE SKIP LOCKED for concurrent webhook processors. Sign webhooks with HMAC (Stripe uses X-Stripe-Signature) and verify signatures before processing. Retry failed webhook processing with exponential backoff.

Asked at: Stripe Interview Guide

Asked at: Coinbase Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top