Designing a Recurring Subscription Billing System

Designing a Recurring Subscription Billing System

Designing a Recurring Subscription Billing System

How to charge customers on a schedule, retry failed payments safely, and never lose track of a billing cycle — even when a million requests hit your system in a single minute.

01

Introduction and History

Every month, billions of small transactions happen quietly in the background of modern life. Your Netflix subscription renews. Your gym membership charges your card. Your cloud hosting bill gets deducted. Your favorite meal-kit service ships a new box and bills you for it. None of these require you to lift a finger — the money simply moves, on schedule, every single time.

This entire category of software is called recurring subscription billing, and it is one of the most deceptively difficult problems in system design. It looks simple on paper: “charge the customer’s card every 30 days.” But underneath that one sentence hides a minefield of distributed systems problems — network failures, duplicate charges, expired cards, currency conversion, retries, timezones, and money that must never be lost, never be double-counted, and always be traceable.

Subscription billing as a business model is not new. Newspapers and magazines have billed subscribers on a recurring basis for over a century. What changed in the last two decades is the scale and the automation. In the 1990s, a magazine’s billing team might process a few thousand renewals a month, largely by mail or manual card entry. Today, a company like Netflix, Spotify, or Amazon Prime bills tens of millions of customers, often on similar dates (like the 1st of the month), which creates massive traffic spikes that a system must absorb without falling over.

The rise of Software-as-a-Service (SaaS) in the 2000s and 2010s made recurring billing a core piece of infrastructure for almost every tech company, not just media companies. Payment processors like Stripe, Braintree, Razorpay, and PayPal built dedicated “Billing” and “Subscriptions” APIs specifically because so many companies were solving the same hard problem — and getting it wrong in expensive ways, either by charging customers twice or by silently losing revenue when charges failed.

Era 1

Manual paper billing

Pre-1990s: subscription businesses like magazines and newspapers processed renewals by mail and hand-entered card slips, sized for thousands of accounts per month at most.

Era 2

Merchant-managed batch billing

1990s–2000s: dedicated in-house scripts ran nightly batch jobs against merchant gateways, sufficient for a few hundred thousand subscribers but fragile under bursts.

Era 3

PSP-managed, event-driven billing

2010s onward: PSPs like Stripe expose Subscription APIs, idempotency keys, and Smart Retry logic as first-class primitives, because so many companies were independently reinventing the same wheel.

Real-life analogy

Think of a subscription billing system like a very disciplined tax collector for a housing society. Every month, on a fixed date, the collector visits every house and asks for the maintenance fee. If a resident is not home, the collector does not just give up — they come back the next day, then a few days later, then a week later, following a strict schedule. But critically, the collector keeps a notebook so they never ask the same resident twice for the same month’s fee, even if they visit multiple times. That notebook is the equivalent of the idempotency and ledger systems we will build in this tutorial.

In this tutorial, we will design a recurring subscription billing system from scratch — the kind of system that could power a company like Netflix, Spotify, or a SaaS startup. We will focus heavily on two of the hardest requirements in this space: handling failed payment retries without double-charging, and never losing track of a customer’s billing cycle, even while the system is under extreme load — think a million requests landing in a single minute during a mass renewal event.

02

Problem and Motivation

Let us break down exactly why this problem is hard. On the surface, “charge a customer every month” sounds like a single database update. In reality, it touches at least five separate concerns that can each fail independently:

Concern 1

Scheduling

Knowing precisely when each customer’s next charge is due, across different signup dates, timezones, and billing intervals (monthly, yearly, weekly).

Concern 2

Payment execution

Actually talking to a bank or card network through a Payment Service Provider (PSP), which can be slow, can time out, or can fail for reasons outside our control (insufficient funds, expired card, bank downtime).

Concern 3

Idempotency

Guaranteeing that if a request is retried (by us, or because a network call was ambiguous), the customer is charged exactly once, not zero times and not twice.

Concern 4

Retry and dunning management

If a charge fails, deciding intelligently when and how many times to retry, and what to do if all retries fail (this second part is called “dunning” in the payments industry).

Concern 5

State consistency

Keeping the subscription’s billing cycle, the customer’s access to the product, and the financial ledger all in sync, even when parts of the system crash mid-operation.

Now layer on scale. Imagine a company with 50 million subscribers, and a large fraction of them signed up around the same promotional date, so their renewals cluster together. On the 1st of the month, the scheduler may need to trigger a million or more billing events within a single minute. If our system architecture cannot absorb that burst without falling over, we get cascading failures: timeouts to the payment gateway, duplicate charge attempts because the customer’s request retried before the first one finished, and potentially very angry customers charged twice for the same subscription.

Why this matters

Double-charging a customer is not just a bug — it is a trust-destroying event and, in many jurisdictions, a compliance violation. A payment system that occasionally double-charges customers will lose them permanently, generate chargebacks (which cost money and hurt the merchant’s reputation with card networks), and can trigger regulatory scrutiny. On the other side, silently losing track of failed payments means the company loses revenue while continuing to give away the product for free.

2.1 The two core guarantees we must design for

Everything in this tutorial ultimately serves two non-negotiable guarantees:

  • Exactly-once effective charging. A given billing cycle for a given subscription must result in at most one successful charge, even if the request to charge it is sent, retried, times out, or is processed by ten different service instances simultaneously.
  • Durable billing cycle state. At any point in time, we must be able to answer, with certainty, “has this customer’s current cycle been paid, is it pending, or has it failed and how many times has it been retried?” — and that answer must never be lost, even during a crash, a deployment, or a network partition.

2.2 Framing the problem against CAP

It helps to place this problem on the consistency-availability spectrum before drawing any boxes. The write path — the actual movement of money and the update of billing cycle state — is deliberately biased toward consistency: we would rather delay a charge safely in a queue than risk writing conflicting records from two nodes that both think they are the primary. The read path — a customer browsing their past invoices, a support agent looking up account history — is deliberately biased toward availability, served from read replicas that may lag the primary by a second or two. Recognizing that one system can (and should) sit in different places on the CAP spectrum for different flows is the single most important framing decision in this design.

2.3 Scale numbers we need to plan for

To ground the discussion, a realistic mid-to-large scenario looks like this: 50 million active subscriptions across a mix of monthly, yearly, and weekly plans; roughly 1–3% of those subscriptions renew on any given calendar day, clustered heavily around calendar boundaries; peak burst of about 1 million billing events per minute, roughly 16,700 events per second; a first-attempt success rate typically in the 85–95% range depending on catalog and geography, meaning 5–15% of cycles enter the retry pipeline; and a support-visible tail of retries and dunning communications that can persist for up to two weeks after the original due date. Every architectural choice from this point on is measured against these numbers.

03

Core Concepts

Before we draw any architecture diagrams, let us build a shared vocabulary. Each of these terms will come up repeatedly.

3.1 Subscription

A subscription is a record that links a customer to a plan (e.g., “Pro Monthly Plan, $15/month”) and tracks its own lifecycle: active, past due, suspended, or cancelled. Think of it as a standing agreement — “keep billing me this amount, this often, until I cancel.”

3.2 Billing Cycle

A billing cycle is one specific period of time within a subscription — for example, “March 1 to March 31” — and it has a due date, an amount owed, and a status (scheduled, paid, past due). A subscription generates a new billing cycle every time the previous one completes. This is the unit we must never double-charge and never lose.

Analogy

If a subscription is like a magazine subscription card sitting in a filing cabinet, a billing cycle is like one individual invoice stapled to it every month. You do not pay the “subscription” — you pay a specific invoice for a specific month. Keeping invoices distinct, numbered, and trackable is what prevents confusion about whether March was already paid.

3.3 Idempotency and idempotency keys

An operation is idempotent if performing it multiple times has the same effect as performing it once. In payments, we achieve this using an idempotency key — a unique identifier generated for each intended charge (usually a combination of subscription ID and billing cycle ID) that is sent to the payment gateway. The gateway (and our own internal services) remember which keys have already been processed, and refuse to process the same key twice, simply returning the original result instead.

Beginner example

Imagine you order food online, and the app crashes right after you tap “Pay”. You do not know if the payment went through, so you tap “Pay” again. If the app sends a fresh, unique order ID each time you genuinely place a new order, but reuses the same ID when it silently retries after a crash, the restaurant’s system can recognize “I have already seen this exact ID” and avoid cooking (and charging you for) two meals.

3.4 Retry with exponential backoff and dunning

When a charge fails — say, due to insufficient funds — we do not retry immediately in a loop. That would hammer the payment gateway and annoy banks. Instead, we use exponential backoff: retry after 1 day, then 3 days, then 7 days, giving the customer time to fix their payment method or their bank balance to recover. This structured retry process, combined with customer communication (emails, in-app banners) asking them to update their card, is called dunning — an old accounting term for the process of pursuing overdue payments.

3.5 Ledger

A ledger is an append-only record of every financial event: every attempted charge, every success, every failure, every refund. Critically, ledger entries are never edited or deleted — if a mistake happens, you add a new correcting entry, you never rewrite history. This is the same principle accountants have used for centuries with physical ledger books, and it is what allows us to reconstruct exactly what happened to any dollar at any time.

3.6 Payment Service Provider (PSP)

A PSP (like Stripe, Razorpay, Braintree, or Adyen) is the external service that actually talks to card networks and banks on our behalf. We never store raw card numbers ourselves (this is a huge security and compliance simplification); instead we store a token or reference that the PSP gives us, and we ask the PSP to charge that token.

💬
What an interviewer may ask

“Why do you not just retry the charge immediately when it fails?” — A strong answer explains that immediate retries (a) do not fix the underlying reason for failure (the card is still declined a millisecond later), (b) risk violating PSP rate limits and getting your merchant account flagged for suspicious activity, and (c) can create race conditions if the previous attempt’s response is still in flight. Exponential backoff with idempotency keys avoids all three problems.

04

Architecture and Components

Now let us design the actual system. We will build it as a set of independently deployable services, connected through an event backbone, sitting behind a load balancer and API gateway. Every request — whether it comes from a scheduler firing a billing event or a customer updating their card — passes through the same edge layer, so we get consistent authentication, rate limiting, and traffic shaping no matter the entry point.

Client Layer Web AppReact client Mobile AppiOS and Android Edge Layer CDNStatic assets Load Balancer (L7)TLS termination API GatewayAuthN/AuthZ · rate limit · routing Core Billing Services Subscription Svcplans & cycles Payment Svccharges & idempotency Retry Orchestratordunning & backoff Ledger Svcsource of truth Notificationemail/SMS Event Backbone Message Queue (Kafka)partitioned by customer ID Scheduler Servicecron for billing cycles Data Layer Primary DB (Postgres)billing records Cache (Redis)idempotency keys Ledger DBappend-only store External Providers Payment Service ProviderStripe / Razorpay gateway
Figure 1 — High-level architecture. Every core service sits behind its own logical API Gateway and Load Balancer entry, communicates asynchronously through a partitioned Message Queue, and never talks to the Payment Service Provider except through the Payment Service.

4.1 Component breakdown

ComponentResponsibilityWhy it exists
Load BalancerDistributes incoming traffic across many instances of the API Gateway; terminates TLSNo single machine can handle a million requests a minute; this spreads load evenly and removes unhealthy nodes automatically
API GatewayAuthentication, authorization, rate limiting, request routing to the correct microserviceCentralizes cross-cutting concerns so individual services do not reimplement auth and throttling
Subscription ServiceOwns subscription and plan data; computes when the next billing cycle is dueSeparates “what is owed and when” from “how do we actually collect it”
Scheduler ServiceRuns on a cron-like schedule, scans for cycles due today, and emits billing events onto the queueDecouples “time-based triggering” from the rest of the system so it can be scaled and tested independently
Message QueueBuffers billing events, partitioned by customer ID, so downstream services consume at a sustainable paceAbsorbs traffic bursts (like a million renewals in one minute) without overwhelming the Payment Service
Payment ServiceExecutes the actual charge against the PSP; enforces idempotencyThe single, tightly controlled gate through which money moves — this isolation is a deliberate security and correctness boundary
Retry OrchestratorWatches for failed payments and schedules backoff retries and dunning communicationsKeeps retry logic out of the hot payment path, so the Payment Service stays simple and fast
Ledger ServiceRecords every financial event as an immutable entryProvides the auditable source of truth that finance, support, and compliance teams rely on
Notification ServiceSends receipts, failure alerts, and dunning emails/SMS/pushKeeps customer communication logic separate from billing logic
Cache (Redis)Stores idempotency keys and their outcomes for fast lookupIdempotency checks must be sub-millisecond even under heavy load, which a primary database alone struggles with at this scale
Primary DatabaseStores subscriptions, plans, and billing cycle state durablyThe system of record for “what is the current state of this subscription”
💬
What an interviewer may ask

“Why is the Retry Orchestrator a separate service instead of just retrying inside the Payment Service?” — Separating them means a burst of retries never competes for the same compute and connection pool as fresh, first-time charge attempts. It also lets you apply completely different scaling and rate-limiting policies to retries (which can tolerate more delay) versus new charges (which customers are actively waiting on).

4.2 What happens if each component fails, in isolation

A useful way to stress-test any architecture is to remove one box at a time and ask what actually breaks. If the Load Balancer fails, no external traffic reaches the platform at all — this is why load balancers are always deployed as redundant pairs or as a managed cloud service, not as a single instance. If the API Gateway tier fails, backend services become unreachable, so gateway instances themselves are horizontally scaled and load-balanced. If the Scheduler is briefly down, no new billing events are emitted, but nothing already in-flight is lost — the moment it recovers, it picks up the cycles it missed based on their still-Scheduled state in the primary database. If the Payment Service is fully down, queued events accumulate safely in Kafka and are processed once it recovers, and no charge is ever silently lost because nothing is dequeued and forgotten. If Redis goes down, idempotency checks fall back to a database-backed lookup path with strictly worse latency but identical correctness. If the PSP is down, the circuit breaker discussed later trips and holds new charges in the queue rather than firing them into a failing dependency — retries automatically resume when the PSP recovers.

05

Internal Working

Let us trace exactly what happens, step by step, when a billing cycle comes due. This is the sequence that must be bulletproof, because this is where money changes hands.

Scheduler Gateway Subscription Kafka Payment Svc Cache PSP Ledger Notification publish BillingCycleDue consume for customer compute invoice amount publish ChargeRequested + idem key route to Payment Svc forward charge request check idempotency key key is new reserve key = PROCESSING submit charge to gateway return charge result store final result against key write immutable ledger entry update cycle = Paid send success receipt (on success) mark cycle Past Due (on failure) send failure alert (on failure) publish PaymentFailed (on failure) Figure 2 — Charge attempt sequence; the idempotency check gates every path.
Figure 2 — Sequence of a single billing cycle charge attempt, showing exactly where the idempotency check gates duplicate execution.

5.1 Why the idempotency check comes first

Notice that the very first thing the Payment Service does — before touching the PSP at all — is check the cache for the idempotency key. This ordering is deliberate. The idempotency key is deterministically generated from the subscription ID and the billing cycle ID (for example, sub_8842_cycle_2026_08), so no matter how many times this exact charge request is retried — by the customer’s client, by a queue redelivery, or by our own Retry Orchestrator misfiring — it always produces the same key, and the cache will recognize it.

Beginner example

It is like writing your name on a sign-in sheet at an event. Before you write your name, you first glance down the list to see if it is already there. If it is, you do not sign again — you just walk in. The sign-in sheet is the idempotency cache; your name written once is the “reserved” state; the act of walking in is the actual charge.

5.2 The “reserve then confirm” pattern

A subtle but critical detail: the Payment Service does not just check the key and then charge — it reserves the key (marks it as “processing”) before calling the PSP, and only writes the final result after the PSP responds. This two-step reserve-then-confirm pattern prevents a dangerous race condition: if two instances of the Payment Service somehow received the same charge request at nearly the same moment (for example, due to an at-least-once delivery guarantee from the message queue), only the first one to successfully reserve the key proceeds to call the PSP. The second one sees the key is already reserved and waits for, or defers to, the first attempt’s result.

Common pitfall

A very common bug is checking the idempotency key and calling the PSP as two separate, non-atomic steps without a reservation step in between. Under high concurrency, two threads can both “see” the key as unused, and both proceed to charge the customer. The fix is to use an atomic “set if not exists” operation (like Redis’s SETNX) to claim the key, so only one caller can ever win the race.

Java example: atomic idempotency reservation

PaymentService.java
public ChargeResult processCharge(ChargeRequest request) {
    String idempotencyKey = request.getIdempotencyKey();

    // Atomic claim: only one caller can succeed here
    boolean claimed = redisClient.setIfNotExists(
        idempotencyKey, "PROCESSING", Duration.ofMinutes(10));

    if (!claimed) {
        // Someone else already owns this key; poll for the final result
        return waitForFinalResult(idempotencyKey);
    }

    try {
        ChargeResult result = paymentGatewayClient.charge(
            request.getPaymentMethodToken(),
            request.getAmount(),
            idempotencyKey);

        redisClient.set(idempotencyKey, serialize(result), Duration.ofDays(7));
        ledgerService.recordTransaction(request, result);
        return result;

    } catch (GatewayTimeoutException e) {
        // Do not assume success or failure; enqueue for reconciliation
        redisClient.set(idempotencyKey, "UNKNOWN_NEEDS_RECONCILIATION",
            Duration.ofDays(7));
        reconciliationQueue.enqueue(idempotencyKey);
        throw e;
    }
}
💬
What an interviewer may ask

“What happens if the network call to the PSP times out and you genuinely do not know whether the charge succeeded?” — This is the single best question to demonstrate depth on this topic. The correct answer is: never assume success or failure. Mark the state as “unknown” and enqueue it for reconciliation, where you later query the PSP directly (most PSPs offer an idempotency-key lookup or a “get charge by reference” API) to find the true outcome before deciding whether to retry.

5.3 Algorithms, data structures, and concurrency control underneath the flow

Behind the simple-looking sequence diagram in Figure 2, several classic computer science ideas are doing real work.

Consistent hashing for queue partitioning

When the Scheduler publishes a ChargeRequested event, it does not pick a queue partition at random — it hashes the customer ID (typically with an algorithm like Murmur3) and maps that hash onto one of the queue’s partitions using consistent hashing. This guarantees that all events for a given customer always land on the same partition, which in turn guarantees they are consumed in order by a single consumer. Without this, two events for the same subscription could be processed by two different consumer threads simultaneously, reintroducing the very race condition idempotency keys are meant to prevent at the network layer, but now at the ordering layer.

Compare-and-swap for state transitions

The database update that moves a billing cycle from Scheduled to Processing is a compare-and-swap (CAS) operation in spirit: UPDATE ... SET status = 'PROCESSING' WHERE id = ? AND status = 'SCHEDULED'. This is the same fundamental primitive used in lock-free concurrent data structures — it either succeeds atomically because the expected old value matched, or fails atomically because someone else changed it first, with no in-between state ever observable to other transactions. Relational databases give us this for free through their row-level locking and MVCC (Multi-Version Concurrency Control) implementations.

Priority queues for retry scheduling

The Retry Orchestrator does not scan the entire billing_cycle table every second looking for cycles whose retry time has arrived. Instead, it maintains (or queries an index equivalent to) a min-heap ordered by “next retry timestamp,” so it can always cheaply find the next batch of cycles due for a retry attempt without an expensive full scan. In practice this is usually implemented as a well-indexed database column combined with a delayed-message feature of the queue (many queue systems, including Kafka via a delay-topic pattern, or purpose-built schedulers like temporal workflows, support this natively).

Token bucket for rate limiting

The outbound rate limiter toward the PSP is a classic token bucket: tokens refill at a fixed rate (say, 2,000 per second, matching the PSP’s contracted limit), and each outbound charge call consumes one token. If the bucket is empty, the call waits or is deferred back onto the queue rather than being sent and rejected. This smooths bursty internal traffic into a steady stream the PSP can reliably accept.

💬
What an interviewer may ask

“Why not just use a simple counter that resets every second for rate limiting?” — A fixed-window counter allows up to 2x the intended rate right at the boundary between two windows (a burst at the end of second N, followed immediately by a burst at the start of second N+1). Token bucket (or a sliding-window log) avoids this edge effect by smoothing the rate continuously rather than resetting abruptly.

06

Data Flow and Billing Cycle Lifecycle

A billing cycle moves through a well-defined set of states from the moment it is scheduled to the moment it is either paid or the subscription is cancelled. Modeling this explicitly as a state machine — rather than scattering booleans and flags across the code — is what keeps the system predictable under retries and failures.

Scheduled Processing Paid Failed Attempt 1 Failed Attempt 2 Failed Attempt 3 Dunning Suspended Cancelled due date reached success declined retry after 1 day retry declined retry after 3 days retry declined retry after 7 days all retries failed customer updates card grace expired policy timeout reactivates next cycle begins
Figure 3 — The billing cycle and dunning state machine. Every transition is triggered by an explicit event, never by silent polling of ambiguous flags.

6.1 Walking through the lifecycle

  • Scheduled — the cycle exists with a known due date and amount, but nothing has happened yet.
  • Processing — the Payment Service currently has an in-flight or reserved idempotency key for this cycle. No other charge attempt can be made while in this state.
  • Failed_Attempt_N — the charge was declined; the system now waits according to the backoff schedule before trying again.
  • Dunning — all automated retries have been exhausted; the customer is actively notified and given a grace period to fix their payment method.
  • Suspended — access to the product is paused (but the subscription is not yet deleted), giving the business one more chance to recover the customer.
  • Paid — the cycle is closed successfully, and a new Scheduled cycle is created for the next period.
  • Cancelled — terminal state; the subscription ends.
🏭
Production example

Netflix and Spotify both use a similar graduated approach: a failed payment does not cut off access immediately. Instead, there is typically a grace period of several days to a couple of weeks, during which the customer keeps their access while the system retries the charge and sends reminder emails. Only after the grace period expires does the account get suspended. This maximizes recovered revenue, because the vast majority of failed payments are due to temporary issues like an expired card, not an unwillingness to pay.

6.2 Why we never let two states be true at once

Every billing cycle row in the database has exactly one status column, and transitions between statuses happen through a single, guarded update — usually a conditional SQL update like UPDATE billing_cycle SET status = 'PROCESSING' WHERE id = ? AND status = 'SCHEDULED'. If the row was already moved out of “Scheduled” by another process, this update affects zero rows, and the caller knows immediately that someone else got there first. This is the database-level equivalent of the atomic idempotency reservation we saw earlier, and it is what prevents two concurrent scheduler runs from both trying to charge the same cycle.

07

Advantages, Disadvantages, and Trade-offs

Pros

Advantages of this architecture

  • Event-driven design absorbs traffic bursts without overwhelming downstream services.
  • Idempotency keys eliminate double-charging even under retries or duplicate delivery.
  • Clear state machine makes billing cycle status auditable and debuggable.
  • Ledger separation gives finance teams an immutable, trustworthy source of truth.
  • Services scale independently — Payment Service can scale differently from Notification Service.
Cons

Disadvantages and costs

  • Eventual consistency between services means brief windows where subscription state and payment state can appear out of sync.
  • More moving parts than a single monolithic billing job — higher operational complexity.
  • Requires careful reconciliation tooling for the “unknown outcome” case, which is extra engineering investment.
  • Message queue becomes a critical dependency; its own availability and ordering guarantees matter a lot.

7.1 Key trade-off: synchronous vs asynchronous charging

We chose an asynchronous, queue-based flow for the main billing cycle path. The alternative — charging synchronously inside a single API call — is simpler to reason about but does not scale to a million-events-per-minute burst, because it ties up a request thread for the entire duration of the (sometimes slow) call to the PSP. The trade-off is that asynchronous flows introduce latency between “cycle due” and “customer notified” — usually seconds, sometimes longer under load — which is an acceptable cost for the throughput and resilience gained.

💬
What an interviewer may ask

“When would you use synchronous charging instead?” — A good answer: when the customer is actively waiting in the UI, such as during initial signup or manually retrying a failed payment right now. In those cases, low-latency synchronous confirmation is more important than throughput, so you would route that specific call directly to the Payment Service rather than through the batch scheduler’s queue, while still reusing the exact same idempotency logic.

7.2 A quick summary of the deliberate compromises

CompromiseWhat we gainedWhat we accepted
Async queue instead of sync chargeAbsorbs million-event bursts, isolates failuresSmall latency between due-time and receipt delivery
Separate Retry OrchestratorRetry storms cannot starve fresh charge capacityExtra service to deploy and monitor
Ledger separated from subscription DBAuditability and compliance-ready trailTwo writes coordinated via outbox pattern
Consistency for writes, availability for readsCorrect money handling plus fast browsingRead replicas can lag by a second or two
08

Performance and Scalability

This is where the “million requests in a minute” requirement lives. Let us design for it explicitly rather than hoping the architecture happens to hold up.

8.1 Where the burst actually comes from

The burst is not customer-initiated traffic — it is our own scheduler generating events because many subscriptions happen to renew on the same date. This is actually good news: because we control the producer, we can smooth the burst ourselves rather than relying on downstream systems to absorb it unpredictably.

Global Load BalancerDNS + Anycast routing Region US-East, Zone A Load Balancer AAPI Gateway Cluster A Payment Service Podsauto-scaled 50 → 500 Region EU-West, Zone B Load Balancer BAPI Gateway Cluster B Payment Service Podsauto-scaled 50 → 500 Sharded Message Queuepartitioned by customer ID Distributed Cache (Redis)idempotency keys Database Shard 1customers A – M Database Shard 2customers N – Z Figure 4 — Multi-region scaling with a Global Load Balancer, per-region API Gateways, and sharded data stores
Figure 4 — Multi-region scaling strategy. A Global Load Balancer routes traffic to regional clusters, each with its own Load Balancer and API Gateway layer, feeding a shared partitioned queue and sharded data stores.

8.2 Techniques that make a million events a minute survivable

Jittered scheduling

Instead of firing all due billing events at exactly midnight, the Scheduler Service spreads them across a window (say, a 2-hour band) using deterministic jitter based on customer ID. Customers still get billed “on their date,” but not all in the same 60-second window. This alone can turn a million-events-in-a-minute problem into a much gentler few-thousand-events-per-minute problem.

Partitioned, backpressure-aware queueing

The message queue (e.g., Kafka) is partitioned by customer ID, so charge events for different customers can be processed fully in parallel, while events for the same customer stay strictly ordered (important so we never process two cycles for one subscription out of order). Consumers pull at a rate they can sustain rather than events being pushed faster than they can be handled — this is backpressure, and it protects the Payment Service and the PSP from being overwhelmed.

Horizontal auto-scaling

The Payment Service, Subscription Service, and Retry Orchestrator are all stateless and horizontally scalable — state lives in the database, cache, and queue, not in service memory. This means Kubernetes (or an equivalent orchestrator) can scale Payment Service pods from a baseline of 50 up to 500+ automatically as queue depth grows, and scale back down afterward to save cost.

Rate limiting toward the PSP

Even with all our own scaling, the PSP itself has rate limits. The Payment Service enforces a token-bucket rate limiter on outbound calls to the PSP, so a burst on our side degrades into a longer (but steady) processing queue rather than a flood of failed requests and PSP-imposed throttling.

Common pitfall

A frequent mistake is scaling the Payment Service aggressively but forgetting the PSP has its own ceiling. If you fire 50,000 requests per second at a PSP that only accepts 2,000, you will get a wave of 429 (Too Many Requests) errors that your Retry Orchestrator then dutifully retries — creating a self-inflicted retry storm. Always rate-limit at the edge closest to the true bottleneck.

8.3 Back-of-the-envelope capacity estimate

MetricEstimate
Peak burst1,000,000 billing events / minute ≈ ~16,700 events/second
Payment Service instances needed (at ~200 req/s each)~85 instances at peak, auto-scaled down otherwise
Kafka partitions (for parallelism headroom)256–512 partitions, keyed by customer ID hash
Redis cluster throughput requirement~20,000+ ops/sec for idempotency key checks, sized with headroom
Database write throughputSharded across multiple primaries; each shard handles a fraction of total cycle updates

8.4 Request coalescing on the read path

Popular subscription-holder screens like “my invoices” and “my plan” occasionally see coordinated bursts — for example, when a product-wide email is sent linking to that page. A coalescing layer in front of the read replicas ensures that if thousands of requests miss the cache for the same page fragment at once, only one actually queries the database, while the rest wait on the shared result. This turns a potential thundering-herd on the read path into a single origin call, at the cost of a small amount of added latency for the waiting requests.

09

High Availability and Reliability

9.1 No single point of failure

Every layer in the architecture is deployed across multiple availability zones at minimum, and often multiple regions for the largest deployments. The Load Balancer itself is typically a managed, redundant service (like an AWS Application Load Balancer or a cloud provider’s equivalent) that has no single instance to fail.

9.2 Handling partial failures gracefully

Failure

PSP is down

The Payment Service detects elevated error rates via a circuit breaker, stops sending new requests for a cooldown period, and lets events queue up safely rather than failing them outright.

Failure

Database is temporarily unreachable

Writes are retried with backoff; if the Payment Service cannot confirm the ledger write, it does not proceed to mark the cycle paid — better to retry the whole flow than to have a payment go unrecorded.

Failure

Message queue broker fails

Kafka’s replication (typically 3 replicas per partition) ensures no events are lost even if one broker node dies.

9.3 Circuit breaker pattern in practice

PaymentGatewayCircuitBreaker.java
public class PaymentGatewayCircuitBreaker {
    private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
    private volatile long openedAtMillis = 0;
    private static final int FAILURE_THRESHOLD = 20;
    private static final long COOLDOWN_MS = 30_000;

    public boolean allowRequest() {
        if (openedAtMillis == 0) return true;
        if (System.currentTimeMillis() - openedAtMillis > COOLDOWN_MS) {
            openedAtMillis = 0; // half-open: allow a trial request
            return true;
        }
        return false;
    }

    public void recordSuccess() {
        consecutiveFailures.set(0);
        openedAtMillis = 0;
    }

    public void recordFailure() {
        if (consecutiveFailures.incrementAndGet() >= FAILURE_THRESHOLD) {
            openedAtMillis = System.currentTimeMillis();
        }
    }
}

9.4 CAP theorem in the context of billing

The CAP theorem states that a distributed system can only guarantee two of three properties during a network partition: Consistency, Availability, and Partition tolerance. Since partitions are a fact of life in any real network, the practical choice is really between consistency and availability when a partition occurs.

For the Ledger and billing cycle state, we deliberately favor consistency over availability. If the primary database is unreachable, we would rather delay processing a charge (and let it queue up safely) than risk writing an inconsistent or duplicate financial record from a stale replica. This is why the Payment Service’s core write path talks to a single-writer primary database rather than accepting writes on multiple replicas.

For read-heavy, less critical paths — like a customer viewing their past invoice history in the app — we lean toward availability over strict consistency, serving from read replicas that might lag the primary by a second or two. A customer seeing yesterday’s invoice list refresh a moment late is a minor inconvenience; a customer being charged twice because we chose availability for the write path would not be.

Analogy

Think of the primary database as the one official cash register in a small shop, and the read replicas as photocopies of yesterday’s receipts pinned to a corkboard for customers to browse. You would never let a customer pay at the corkboard — only the register can take money, because only it has the true, current, single copy of the till. But browsing old receipts on the corkboard is perfectly fine even if it is not perfectly up to the second.

9.5 Consensus and leader election

Several components in this architecture rely on distributed consensus under the hood, even though we rarely implement it ourselves. Kafka brokers use a consensus protocol (via a controller elected through KRaft or historically ZooKeeper) to agree on partition leadership, ensuring exactly one broker is authoritative for a given partition at a time — this is what gives us the “one consumer group member processes a given partition” guarantee we rely on for ordered, non-duplicated event handling. Similarly, our database’s primary-replica setup uses a consensus or quorum mechanism to agree on which node is the current primary during failover, preventing a dangerous “split-brain” scenario where two nodes both believe they are the primary and accept conflicting writes.

💬
What an interviewer may ask

“What happens if a network partition causes two database nodes to both think they are the primary?” — This is called split-brain, and it is exactly what consensus protocols (like Raft, used in many managed database failover systems) are designed to prevent, by requiring a majority quorum of nodes to agree before promoting a new primary. A well-configured managed database service (like Amazon RDS Multi-AZ or a Postgres cluster with Patroni) handles this for you, but you should be able to explain why it matters: split-brain in a billing system could mean two “primaries” both accepting charge confirmations for the same cycle.

9.6 Disaster recovery

The Ledger Database and Primary Database are continuously replicated to a secondary region with a defined Recovery Point Objective (RPO) of under one minute and a Recovery Time Objective (RTO) of a few minutes, using automated failover. Because billing correctness matters more than almost anything else in the business, backups are tested regularly with real restore drills, not just taken and forgotten.

💬
What an interviewer may ask

“If your primary database fails over to a replica mid-billing-run, how do you know you have not lost or duplicated any charges?” — Strong answer: because idempotency keys are deterministic (derived from subscription ID + cycle ID, not randomly generated per attempt), even if the failover causes the Retry Orchestrator to reprocess some in-flight events, the same key will be recognized by the cache and the PSP, preventing duplication. The design assumes failures will happen and leans on idempotency rather than trying to prevent every possible failure.

9.7 Graceful degradation from the customer’s point of view

It is worth explicitly designing what a customer experiences when parts of this pipeline are degraded, rather than leaving it as an accident of implementation. If the Notification Service is temporarily behind, receipts arrive a few minutes late but no billing action is affected. If the Retry Orchestrator is paused, past-due cycles simply stay past-due until it resumes; no cycle is prematurely cancelled just because retries are delayed. If read replicas lag, past-invoice screens may briefly show a slightly older list, but the current cycle’s payment status — the one that matters — is always served from the primary. The one thing that must never degrade, under any combination of failures, is the correctness of the ledger itself, which is why it is deliberately the simplest and most carefully guarded write path in the entire system.

10

Security

10.1 Never touch raw card data

The single most important security decision in this design is that our services never store or even see raw card numbers. All card entry happens through the PSP’s own hosted fields or SDK, which returns us an opaque token. This dramatically shrinks our PCI-DSS (Payment Card Industry Data Security Standard) compliance scope — we only ever handle tokens, not primary account numbers.

10.2 API Gateway as a security boundary

Every request to any internal service passes through the API Gateway, which enforces authentication (via signed JWTs or OAuth 2.0 tokens), authorization (does this caller have permission to touch this subscription), and rate limiting (protecting against both abuse and accidental self-inflicted traffic storms).

10.3 Idempotency keys must not be guessable

Because idempotency keys are deterministic, we must ensure they cannot be guessed and abused by an attacker to probe for information about other customers’ transactions. Keys are namespaced with an internal, non-public subscription identifier and are never accepted directly from untrusted client input for server-triggered billing cycles — only the Subscription Service, an internal trusted component, generates them.

10.4 Encryption and secrets

  • All data in transit uses TLS 1.2 or higher, terminated at the Load Balancer and re-encrypted internally between services (mutual TLS in stricter environments).
  • All data at rest — especially the Ledger Database — is encrypted using provider-managed or customer-managed keys.
  • PSP API keys and database credentials live in a secrets manager (like AWS Secrets Manager or HashiCorp Vault), never in code or environment files committed to source control.

10.5 Fraud and abuse prevention

The Payment Service integrates basic fraud signals — velocity checks (has this payment method been used across many unrelated accounts recently), geographic anomaly detection, and PSP-provided risk scores — before submitting high-risk charges, and can route suspicious transactions through 3D Secure (an additional bank-side authentication step) rather than blindly attempting to charge.

💬
What an interviewer may ask

“How would you prevent a malicious actor from replaying a captured charge request to trigger a duplicate payment?” — Because the idempotency key is server-generated and tied to internal state (not client-supplied), replaying the exact same request will simply hit the same cached result. If the attacker instead tries to fabricate a new request for the same cycle, the underlying database’s conditional state transition (only one cycle can be “Processing” at a time) blocks it.

10.6 Least-privilege and audit logging

Only a narrow set of internal services and a small, well-defined administrator role are ever authorized to initiate refunds or manual charge overrides through the Payment Service. Every such privileged action is written to an audit log (backed by the same append-only discipline as the ledger itself) capturing who performed it, when, from which IP, and against which subscription. Combined with mutual TLS between internal services, this makes both accidental misconfiguration and deliberate insider abuse considerably harder to hide.

11

Monitoring, Logging, and Metrics

11.1 The three pillars

We instrument the system with metrics, logs, and distributed traces, following the standard observability model:

Pillar 1

Metrics

Counters and histograms like charge success rate, retry rate, queue depth, and P99 latency for the charge-to-confirmation path, scraped by a system like Prometheus and visualized in Grafana.

Pillar 2

Logs

Structured, correlation-ID-tagged logs from every service, centralized in a system like Elasticsearch or a cloud logging service, so a single billing cycle’s journey can be reconstructed end to end.

Pillar 3

Traces

Distributed tracing (via OpenTelemetry) that follows a single billing event from the Scheduler through the queue, Payment Service, PSP call, and Ledger write, so we can pinpoint exactly where time is spent or where failures cluster.

11.2 Correlation IDs

Every billing event carries a correlation ID generated at the Scheduler and propagated through every hop — the queue message, the Payment Service’s PSP call, the Ledger entry, and the Notification Service’s email. This is what turns “we had an incident” into “here is the exact sequence of 14 log lines across 5 services for this one customer’s failed charge.”

11.3 Critical alerts

AlertWhy it is critical
Charge success rate drops below historical baselineCould indicate a PSP outage, a bug in the charge flow, or a mass card-expiry event
Idempotency cache miss rate spikesCould signal cache eviction issues, risking duplicate charges if keys expire too early
Queue consumer lag grows unboundedDownstream services cannot keep up with the Scheduler’s output — retries and customer notifications will be delayed
Reconciliation queue depth growsRising count of “unknown outcome” charges needing manual or automated resolution against the PSP
Ledger write failuresAny failure here risks an untracked financial event — treated as a page-immediately (highest urgency) alert
Common pitfall

Teams often monitor “did the charge succeed” but forget to monitor “did the ledger entry get written.” A charge can succeed at the PSP while the write to our own Ledger Service fails due to an unrelated database blip — silently creating a gap between what the bank did and what our books say happened. Both paths need equally strong alerting.

11.4 Tiered alerting and dashboards

Good alerting is tiered rather than binary. Soft warnings fire when a metric drifts a meaningful amount off baseline — for example, charge success rate falling by two standard deviations from a rolling 24-hour mean — giving on-call engineers time to investigate before customers notice. Hard pages fire only for signals that mean money is at risk right now: ledger write failure, sustained unbounded queue lag, or reconciliation backlog exceeding a business-defined threshold. A good operational dashboard groups panels into three tiers: a headline row (overall charge success, queue lag, reconciliation depth) visible at a glance; a middle row breaking down per-PSP and per-region success rates so localized issues do not hide inside a healthy global average; and a bottom row surfacing business-facing signals like recovered revenue from dunning and total value stuck in the retry pipeline.

12

Deployment and Cloud

12.1 Containerized microservices on Kubernetes

Each service (Subscription, Payment, Retry Orchestrator, Ledger, Notification, Scheduler) is packaged as a Docker container and deployed on Kubernetes, which handles scheduling, health checks, and horizontal pod auto-scaling based on CPU, memory, or custom metrics like queue depth.

12.2 Blue-green and canary deployments

Because this system touches money, deployments are rolled out cautiously. A new version of the Payment Service is first deployed as a canary receiving a small percentage (e.g., 5 percent) of traffic; only after its error rates and latency match or beat the current version does the rollout proceed to 100 percent. Blue-green deployment (maintaining two full environments and switching traffic between them) provides an instant rollback path if something goes wrong.

12.3 Infrastructure as code

All infrastructure — Kubernetes clusters, database instances, Kafka clusters, load balancer configuration, IAM roles — is defined declaratively using tools like Terraform, so environments are reproducible, reviewable through pull requests, and auditable.

12.4 Multi-region deployment for the highest tiers

For companies operating at true global scale, the entire stack (except perhaps the PSP integration, which may be region-specific for regulatory reasons) is deployed across multiple cloud regions, with the Global Load Balancer routing customers to their nearest healthy region, as shown earlier in Figure 4.

12.5 Cost optimization

A billing system’s compute needs are extremely spiky — a huge burst around common renewal dates, and comparatively idle time in between. This makes it a strong candidate for aggressive auto-scaling policies, including scaling the Payment Service tier down to a small baseline overnight and during low-traffic windows, rather than provisioning for peak capacity around the clock. Spot or preemptible instances are a reasonable fit for the Notification Service (which can tolerate brief interruptions and retries) but are deliberately avoided for the Payment Service tier itself, where mid-request interruption risk is not worth the cost savings on a workload this sensitive. Database and cache costs are managed by right-sizing read replica counts to actual read traffic and using storage tiering (hot ledger data on fast storage, older archived ledger data moved to cheaper cold storage after a defined retention period, while remaining queryable for audits).

12.6 A quick capacity planning worksheet

When sizing this system for a new deployment, work through these questions in order: what is the peak billing-events-per-minute you expect on the busiest calendar day of the year (drives Kafka partition count and Payment Service pod ceiling)? what is the typical first-attempt failure rate for your customer geography (drives Retry Orchestrator throughput and dunning email volume)? what is the average duration a cycle spends in the retry pipeline (drives Redis idempotency-key TTL and cache size)? and finally, what is the business-tolerated Recovery Point Objective for the ledger (drives replication strategy and backup cadence)?

13

Databases, Caching, and Load Balancing

13.1 Data model

CUSTOMERPK customer_idemaildefault_payment_method_idstatus SUBSCRIPTIONPK subscription_idFK customer_idFK plan_idstatuscurrent_cycle_startcurrent_cycle_end PLANPK plan_idnamepricebilling_interval BILLING_CYCLEPK cycle_idFK subscription_iddue_dateamount_duecycle_status PAYMENT_ATTEMPTPK attempt_idFK cycle_ididempotency_keyretry_numberoutcomeattempted_at LEDGER_ENTRYPK entry_idFK attempt_idamountcurrencyentry_type owns 1..* references generates 1..* triggers 1..* records 0..1 Figure 5 — Core relational data model; note that PAYMENT_ATTEMPT is a distinct entity from BILLING_CYCLE
Figure 5 — Core relational data model. Note that PAYMENT_ATTEMPT is a distinct entity from BILLING_CYCLE — a single cycle can have multiple attempts (initial plus retries), each individually tracked.

13.2 Why relational, not NoSQL, for the core billing tables

Billing data has strong relational integrity requirements (a payment attempt must belong to exactly one billing cycle; amounts must be exact decimals, never floating point) and benefits enormously from ACID transactions when updating cycle status and writing ledger entries together. A relational database like PostgreSQL, with row-level locking and conditional updates, is the right foundation here — even though other parts of the broader product (like caching or activity logs) might legitimately use NoSQL stores.

Common pitfall

Never store money as a floating-point number (like a Java double). Floating point arithmetic can introduce tiny rounding errors that compound over millions of transactions. Always use a fixed-point decimal type (Java’s BigDecimal, stored as a database DECIMAL or NUMERIC column) for any monetary value.

13.3 Sharding strategy

As the customer base grows past what a single database instance can handle, we shard by customer ID (or a hash of it), so a given customer’s subscription, cycles, and payment attempts always live on the same shard — this keeps the common query patterns (like “get this customer’s current cycle”) a single-shard operation, avoiding expensive cross-shard joins.

13.4 Caching strategy

  • Idempotency keys — stored in Redis with a time-to-live of about 7 days, long enough to cover any realistic retry window, short enough to bound memory usage.
  • Subscription plan details — rarely change, so they are cached aggressively (minutes to hours) to avoid repeated database lookups during the scheduler’s scan.
  • Cache invalidation — plan updates explicitly invalidate the relevant cache keys on write, rather than relying purely on TTL expiry, since stale pricing is a real business risk.

13.5 Load balancing algorithms

The Load Balancer uses least-connections routing rather than simple round robin for the Payment Service tier, since individual charge requests can vary significantly in duration depending on PSP response times — least-connections keeps work more evenly spread across instances under uneven latency.

13.6 Consistent hashing at the cache tier

The Redis cluster used for idempotency keys is fronted by consistent hashing so that when nodes are added or removed to handle growing load, only a small slice of keys need to move to a different node — not the majority of them, as would happen with naive modulo hashing. This matters specifically because reshuffling a large fraction of live idempotency keys during a rescaling event is exactly when the system is most vulnerable to duplicate-charge windows, and consistent hashing keeps that risk small and bounded.

14

APIs and Microservices

14.1 Key internal APIs

POST /internal/v1/charges
POST /internal/v1/charges
Headers: Idempotency-Key: sub_8842_cycle_2026_08
Body: {
  "subscriptionId": "sub_8842",
  "billingCycleId": "cycle_2026_08",
  "amount": "15.00",
  "currency": "USD",
  "paymentMethodToken": "pm_tok_9f3a"
}

Response 200 OK
{
  "attemptId": "att_772f",
  "outcome": "SUCCEEDED",
  "ledgerEntryId": "ledg_00931"
}

Notice the idempotency key is passed as a header, following the convention used by Stripe and most major PSPs, so retried HTTP requests (due to client timeouts) are inherently safe even before our own internal reservation logic runs.

14.2 Why microservices instead of a monolith here

Splitting Subscription, Payment, Retry, Ledger, and Notification into separate services allows each to scale, deploy, and fail independently. A surge in notification volume (say, a mass dunning email campaign) should never be able to slow down the Payment Service’s ability to process charges. This isolation is worth the added operational complexity at this scale.

14.3 Synchronous vs asynchronous API boundaries

InteractionStyleReason
Scheduler to Payment Service (bulk renewals)Asynchronous, via queueAbsorbs burst load; no one is waiting synchronously
Customer manually retries a failed payment in-appSynchronous, direct API callCustomer is actively waiting for immediate feedback
Payment Service to Ledger ServiceSynchronous within the transaction, or reliably asynchronous via outbox patternLedger write must not be lost, but also should not block the charge response indefinitely

14.4 The transactional outbox pattern

To guarantee that a database update (marking a cycle paid) and a queue publish (notifying downstream systems) either both happen or neither happens, we use the transactional outbox pattern: the event to be published is written to an “outbox” table in the same database transaction as the state change, and a separate relay process reads the outbox table and publishes to the queue, marking rows as sent. This avoids the classic dual-write problem where a database commit succeeds but the queue publish fails (or vice versa), which would otherwise create exactly the kind of inconsistency we are trying to eliminate.

14.5 API contract discipline

Because these services evolve independently, their contracts have to evolve carefully. Every internal endpoint carries an explicit version prefix (like /internal/v1/charges), breaking changes are shipped by adding a new version alongside the old rather than mutating the existing one, and contract tests run on every deploy to verify that changes to a producer’s schema do not silently break its consumers. Field additions are always safe (consumers ignore unknown fields), field removals are treated as breaking changes, and enum value additions require checking that every consumer’s switch statements have a sensible default branch.

15

Design Patterns and Anti-patterns

15.1 Patterns used in this design

PatternWhere it is used
Idempotent ReceiverPayment Service’s charge endpoint, keyed on subscription + cycle
Circuit BreakerPayment Service’s outbound calls to the PSP
Transactional OutboxPublishing events reliably alongside database state changes
Saga (choreography style)The multi-step flow from cycle-due through charge, ledger write, and notification, coordinated via events rather than a central orchestrator for the happy path
Exponential Backoff with JitterRetry Orchestrator’s dunning schedule and the Scheduler’s spread of billing events
BulkheadSeparate thread pools and service boundaries so Notification Service load cannot starve the Payment Service
CQRS-liteRead-heavy queries (like “show me my invoices”) served from read replicas, separate from the write path that processes charges

15.2 Anti-patterns to avoid

Anti-pattern — Client-generated idempotency keys for server jobs

Letting the Scheduler or client generate a random key per attempt (instead of a deterministic one derived from subscription + cycle) defeats the entire purpose — a retry would get a new random key and sail right past the idempotency check.

Anti-pattern — Retry-immediately-in-a-loop

Retrying a failed charge in a tight loop with no backoff wastes PSP quota, risks account flags for suspicious behavior, and rarely fixes a problem that is often “the card genuinely does not have funds right now.”

Anti-pattern — Silent truncation of failed cycles

Simply marking a subscription “cancelled” the moment a single charge fails, with no retry or dunning period, throws away recoverable revenue — most failures are transient (an expired card that gets updated, a temporarily low balance).

Anti-pattern — Storing money as floating point

As noted earlier, using float/double types for currency amounts introduces rounding drift that compounds catastrophically at scale. Always use fixed-point decimal types.

15.3 Compensating actions for partial failures

Occasionally a charge that already succeeded needs to be reversed — for example, a customer disputes a duplicate signup, or an automated fraud check catches something after the fact. Rather than trying to “undo” a ledger entry, the correct pattern is to append a new refund entry that offsets the original. This is the same forward-only correction philosophy accountants have used for centuries: history is never rewritten, only extended. It is also a lightweight relative of the saga pattern, applied to a single financial operation rather than an entire distributed workflow.

16

Best Practices and Common Mistakes

16.1 Best practices

BP 1

Deterministic idempotency keys

Derived from stable identifiers (subscription ID + cycle ID), never randomly generated per attempt, so retries are naturally safe.

BP 2

Treat “unknown outcome” as its own state

Never silently coalesce it into “failed” — this is the single biggest source of double-charge bugs in real systems.

BP 3

Jitter scheduled jobs

So a million logically-simultaneous events do not become a literal million-requests-per-second spike.

BP 4

Separate read and write paths

Customers browsing their invoice history never compete for resources with the active charging pipeline.

BP 5

Log every transition with a correlation ID

Any billing cycle’s full history can be reconstructed end to end for support and audits.

BP 6

Test failure injection regularly

Deliberately simulate PSP timeouts, database failovers, and queue redeliveries in staging to prove the idempotency guarantees actually hold.

16.2 Common mistakes

Mistake

Assuming the message queue guarantees exactly-once delivery. Most production-grade queues (including Kafka in typical configurations) guarantee at-least-once delivery — meaning your consumers will occasionally see the same message twice, and your idempotency logic is what actually makes the end result exactly-once, not the queue itself.

Mistake

Forgetting timezone handling in the scheduler. “Bill on the 1st of the month” means something different for a customer in Tokyo versus a customer in Los Angeles. Billing cycle due dates should be computed in the customer’s local timezone (or a clearly documented business timezone) and stored unambiguously, usually as UTC with the customer’s timezone recorded alongside.

Mistake

Building the Retry Orchestrator so that a retry attempt reuses the exact same idempotency key across every attempt in the sequence. This actually seems correct at first glance, but it means the system can never distinguish “attempt 1 failed” from “attempt 2 failed” for reporting purposes, since both attempts collapse to the same cached outcome once one of them resolves. In practice, the idempotency key should be scoped per attempt (for example, sub_8842_cycle_2026_08_attempt_2), while a separate, higher-level check ensures the cycle itself is never charged twice by verifying its status before allowing a new attempt to begin. The cycle-level state machine and the attempt-level idempotency key work together, at two different layers, to produce the overall guarantee.

16.3 Testing strategy

Because correctness matters more here than in almost any other subsystem, the testing pyramid for a billing system leans unusually heavily on integration and chaos-style tests rather than relying on unit tests alone.

  • Unit tests cover the pure logic — proration math, backoff interval calculation, state machine transition rules — with no network calls involved.
  • Contract tests against a PSP sandbox environment verify that our idempotency key usage actually behaves as the PSP’s real API expects, since assumptions about vendor behavior are a common source of production surprises.
  • Chaos and fault-injection tests, as described in the FAQ section below, deliberately introduce timeouts, duplicate deliveries, and mid-transaction crashes to prove the system self-heals correctly rather than merely hoping it does.
  • Load tests simulate the million-events-per-minute burst scenario in a staging environment sized proportionally, to validate that auto-scaling policies and rate limiters engage correctly before a real traffic spike ever hits production.
  • Reconciliation drills periodically run the reconciliation job against a controlled set of intentionally ambiguous transactions to confirm it correctly resolves “unknown outcome” states without either duplicating or dropping a charge.
17

Real-World Industry Examples

Platform

Netflix

Bills tens of millions of subscribers on staggered dates tied to their original signup date, which naturally spreads load across the month rather than clustering everyone on the 1st. Failed payments trigger a grace period with continued access and in-app prompts to update payment details before any suspension occurs.

Platform

Spotify

Similarly separates “entitlement” (do you currently have access to Premium features) from “billing status” (has the current cycle been paid), allowing the product experience to degrade gracefully — for example, falling back to the ad-supported tier — rather than abruptly locking users out the instant a single payment attempt fails.

Platform

Stripe Billing

Essentially a productized version of much of what we designed here: subscriptions, invoices (our “billing cycles”), configurable “Smart Retries” (an ML-tuned version of our backoff schedule), and dunning email flows, all built around the same core idempotency-key mechanism that Stripe pioneered and popularized across the payments industry.

Platform

Amazon Prime

Recurring billing integrates tightly with account-level payment method management, so a single card update anywhere in the account automatically flows into the next billing attempt — reducing the number of customers who fall into dunning in the first place, since expired-card failures are caught proactively wherever possible.

Platform

SaaS platforms (Slack, Zoom)

Seat-based SaaS products face a related but distinct challenge: the billable amount can change mid-cycle as a company adds or removes user seats. These systems use proration logic layered on top of the same billing-cycle and ledger foundation, generating smaller ledger entries for partial periods while still flowing through the identical idempotent charge path.

17.1 The common thread

Across every one of these companies, the same underlying pattern repeats: a durable record of what is owed and when, a strictly idempotent execution path for actually moving money, a graduated retry and communication strategy before punishing the customer, and an immutable audit trail. The specific technology choices vary — some companies run their own PSP integrations, others lean entirely on a vendor like Stripe Billing — but the architectural shape converges because the underlying correctness problem is the same one we solved in this tutorial.

17.2 Alternative designs considered, and why they were rejected

A strong system design answer does not just present one solution — it shows you evaluated and consciously rejected reasonable alternatives.

17.2.1 A single nightly batch job on a monolith

The simplest possible design is a single cron job that queries every due subscription and charges them one at a time from a monolithic application. This is genuinely fine at very small scale (say, a few thousand subscribers), but it collapses under a million-events-per-minute burst because one process becomes a serial bottleneck, and any single failure (a slow PSP response, a database blip) can stall the entire nightly run. It was rejected here because the whole point is designing for the burst case, not the average case.

17.2.2 Fully synchronous charging directly from the API

Another option is having every charge happen in a synchronous API call, with the caller (the Scheduler) waiting on the response before moving to the next customer. This is simpler to reason about but ties up a thread per in-flight charge for the entire duration of the PSP call, which does not scale to the throughput this system needs. Async queueing with idempotent consumers gives us the same correctness with far higher throughput.

17.2.3 A vendor-only solution (Stripe Billing or similar)

For many teams, outsourcing the entire billing pipeline to a vendor like Stripe Billing or Chargebee is the right call: it eliminates a huge category of engineering work, and vendor engineering teams have already solved most of the problems we discussed. This tutorial designs the system from scratch specifically because interviewers ask about it and because larger companies eventually build their own for cost, control, and multi-PSP flexibility reasons — but building it in-house is genuinely a choice with real trade-offs, not the only defensible answer.

18

FAQ, Summary, and Key Takeaways

18.1 Frequently asked questions

Q1

What is the single most important design decision in this whole system?

The deterministic idempotency key, combined with an atomic “reserve then confirm” step. Nearly every double-charge incident in real-world systems traces back to a violation of one of these two things.

Q2

How do we handle a payment that succeeds at the PSP but our own service crashes before recording it?

The reconciliation process periodically compares our internal records against the PSP’s own transaction history (using the idempotency key as the join point) and heals any discrepancies — this is why the idempotency key must also be sent to the PSP itself, not just used internally.

Q3

Why not just use a distributed lock (like a Redis lock) instead of an idempotency key?

A lock only prevents concurrent execution while it is held — it does not remember the result of a past execution. If the lock is released and the same logical charge is requested again five minutes later (a legitimate retry), a lock alone will not stop the duplicate. An idempotency key remembers the outcome indefinitely (within its retention window), which a lock does not.

Q4

How many retries is “enough” before giving up?

There is no universal number — most production systems use 3 to 4 automated retries spread over 1 to 2 weeks, based on industry data showing diminishing returns beyond that window, balanced against not annoying the customer or their bank with excessive attempts.

Q5

Does this design work for usage-based billing (like pay-per-API-call), not just flat subscriptions?

The core idempotency and ledger mechanisms are identical. The main difference is that the Subscription Service’s “compute invoice amount” step becomes more complex, aggregating metered usage records for the billing period instead of reading a fixed plan price.

Q6

How would you test that the double-charge prevention actually works?

Build chaos tests that deliberately deliver the same queue message twice, kill the Payment Service mid-request between the PSP call and the ledger write, and fail the database connection right after a PSP charge succeeds. Assert, after each scenario, that exactly one successful ledger entry exists for the affected billing cycle. These tests should run regularly in a staging environment against a PSP sandbox, not just once during initial development.

Q7

What is the difference between a billing cycle being “Failed” and a payment attempt being “Failed”?

A payment attempt failing is a single event — one specific try at charging the card came back declined. A billing cycle only moves toward “Dunning” after a defined number of payment attempts (each individually logged) have all failed. Keeping these as separate entities, as shown in the data model in Figure 5, lets us analyze failure patterns (which decline codes are most common, how many retries typically succeed) without losing the cycle-level view of “is this customer currently paid up.”

Q8

How does this design change for multiple countries with different currencies and payment regulations?

The core engine — subscriptions, billing cycles, idempotent charging, the ledger — stays the same. What changes is the layer just above the Payment Service: currency-specific PSP routing (different regions often use different PSPs or PSP configurations for better authorization rates), locale-aware invoice formatting, and compliance-driven rules like Strong Customer Authentication in Europe, which requires routing certain charges through an extra 3D Secure verification step before the PSP will even attempt the charge. These are best modeled as pluggable strategies selected per customer region, rather than baked into the core Payment Service logic.

Q9

How do we decide the right TTL for idempotency keys in the cache?

The TTL must comfortably exceed the longest realistic retry window (typically 1–2 weeks), plus a safety margin, so that a retry attempt one week after the original never accidentally slips past an expired key and triggers a duplicate. A 7–14 day TTL is a common, well-tested default.

Q10

Could you use a simpler polling design instead of an event-driven queue?

Yes, at very small scale — have the Scheduler simply insert rows into a “due” table and let the Payment Service poll it. For a small business it is genuinely fine. It scales badly because polling either wastes work (checking too often) or delays charges (checking too rarely), and it turns the database into a queue — a role databases handle less efficiently than purpose-built queue systems like Kafka.

18.2 Putting it all together

Stepping back from the individual components, this design ultimately rests on a small number of ideas working together rather than any single clever trick. A single authoritative write path through the Payment Service, gated by a deterministic idempotency key, guarantees that no cycle is ever charged twice regardless of what happens elsewhere in the pipeline. An explicit billing-cycle state machine, backed by conditional database updates, guarantees that no two processes can ever proceed on the same cycle simultaneously. An event backbone with per-customer partitioning decouples bursty producers from steady, sustainable consumers while preserving the ordering guarantees we depend on for correctness. And a deliberate split between eventually-consistent read paths and strongly-consistent write paths means the system can be fast where staleness is harmless, and strict where it is not. None of these ideas is unique to billing — the same pattern shows up across inventory, seat availability, ride matching, and account balances — which is exactly why this problem is such a popular and durable interview question.

Key takeaways

  • Recurring billing is fundamentally a distributed systems correctness problem, not just a scheduling problem.
  • Deterministic idempotency keys, reserved atomically before any external call, are what make “exactly-once effective charging” achievable despite an at-least-once world.
  • An explicit billing-cycle state machine, backed by conditional database updates, prevents concurrent processes from ever double-processing the same cycle.
  • Handling scale (a million events in a minute) is mostly about smoothing bursts at the source (jittered scheduling) and absorbing them in a partitioned queue, not just adding more servers.
  • An immutable ledger, kept separate from mutable subscription state, is what makes the system auditable and trustworthy to finance and compliance teams.
  • “Unknown outcome” must be treated as a first-class state with its own reconciliation path — never silently collapsed into success or failure.

18.3 Closing thought

Recurring subscription billing is a great system design interview topic precisely because it rewards depth over breadth. A candidate who draws a clean architecture diagram with an API Gateway and Load Balancer in front of a few services is showing surface familiarity with modern infrastructure. A candidate who can explain why an idempotency key must be deterministic, why it must be reserved atomically before an external call, why an “unknown outcome” must never be assumed to be a failure, and why a jittered schedule turns a million-request spike into a manageable stream, is showing the kind of judgment that actually keeps a real payments system trustworthy in production. The architecture diagrams in this tutorial are a starting point, not a finished blueprint — the reasoning behind each decision is the part worth carrying into your own designs.