Design credit card bill payments and rewards (CRED)
The interviewer asks
“Design a CRED-like app where users add their credit cards, see outstanding bills and due dates, pay the bill from any bank account via UPI or netbanking, and earn reward coins they can redeem for offers.”
What a strong answer looks like
A strong answer treats this as two connected systems. The payment side is a two-leg money movement: collect money from the user's bank (UPI or netbanking via a payment gateway) and then pay the card issuer through Bharat BillPay (BBPS), which RBI has required for credit card bill payments since mid-2024. Each payment is a durable state machine driven by an orchestrator, with idempotency keys on every external call, because the dangerous state is 'collected but not yet paid to the issuer'. Bills are fetched from issuers through BBPS bill fetch and refreshed before due dates, and reminders are scheduled. The rewards side is a ledger: coins are credited only after the issuer confirms the payment, through an outbox event, and a rules engine computes rewards with budget caps and abuse checks. The hard parts are the money-in-flight state, reconciliation with three parties (PG, BBPS, issuer), and the spike around common due dates.
Concepts to mention
1 Requirements
Functional
- User adds a credit card (last 4 digits + issuer, verified via bill fetch) and sees the latest bill: total due, minimum due and due date
- Pay the full, minimum or a custom amount using UPI, netbanking or debit card
- Show payment status clearly: processing, paid to bank, failed and refunded
- Send reminders before the due date and after the statement is generated
- Credit reward coins for successful bill payments according to campaign rules
- Redeem coins for offers, cashback or vouchers; show a coin history
Non-functional
- No money lost: every rupee collected is either paid to the issuer or refunded, with daily reconciliation to zero breaks
- Payment initiation API p99 under 300 ms (excluding the user's UPI approval time)
- Issuer confirmation for most payments within minutes; refunds for failed payments started within T+1
- 99.95% availability; payment state machine survives any single component failure
- Peak of ~1,000 payments/s on heavy due-date days (about 5x normal)
- Rewards credited at most once per payment; reward budget never overspent by more than a small, known amount
- Card data handled per RBI rules: no full card number or CVV stored by the app
Out of scope (say it out loud)
- Building a payment gateway or UPI switch
- Credit underwriting or card issuance
- The marketplace and shopping side of rewards in detail
- Credit score fetch and display
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/cards | Add a card: issuer, last 4 digits, registered mobile; triggers a bill fetch to verify it |
GET/cards/{card_id}/bill | Latest bill from cache: total due, minimum due, due date, fetched_at |
POST/payments | Create a bill payment: card_id, amount, method. Header Idempotency-Key. Returns payment_id and PG intent (UPI intent / netbanking redirect) |
GET/payments/{payment_id} | Current state and timeline of a payment |
POST/webhooks/pg | Payment gateway callback: collection success or failure (signature verified) |
POST/webhooks/bbps | BBPS / BBPOU callback: bill payment status from the issuer side |
GET/rewards/ledger | User's coin balance and history (paginated) |
POST/rewards/redeem | Redeem coins for an offer; idempotent by redemption key |
4 Data model
cardsPostgrescard_id (PK)user_idissuer_idlast4card_networkbbps_biller_idcustomer_params (encrypted)statuscreated_atOnly last 4 digits and the identifiers needed for BBPS bill fetch; no full PAN. Index on user_id.
billsPostgres + Redis cachecard_idstatement_datetotal_duemin_duedue_datefetched_atsource (bbps, sms, email)Latest bill cached in Redis with a TTL of a few hours; Postgres keeps history for reminders and analytics.
paymentsPostgres (sharded by user_id later)payment_id (PK)user_idcard_idamountstatepg_txn_idbbps_txn_refidempotency_key (unique)created_atupdated_atversionThe state machine record. Conditional updates with version prevent two workers moving the same payment. Unique keys on pg_txn_id and bbps_txn_ref make callbacks idempotent.
payment_eventsPostgres append-onlypayment_idseqfrom_stateto_stateactorpayloadatFull audit trail for support and disputes.
outboxPostgres (same DB as payments)idaggregate_idevent_typepayloadcreated_atpublished_atWritten in the same transaction as the state change; a relay or Debezium CDC publishes to Kafka. Guarantees rewards and notifications see every final state.
reward_ledgerPostgresentry_id (PK)user_idtype (credit, debit, expire, reverse)coinssource_ref (unique per type)campaign_idcreated_at; balances(user_id, coins, version)Append-only. Unique (type, source_ref) means one credit per payment. Balance row updated in the same transaction.
campaign_budgetRedis counters + Postgrescampaign_idbudget_coinsspent_coinsper_user_caprules (JSON)Atomic DECRBY in Redis for fast budget checks; reconciled to the ledger every few minutes.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Pay a credit card bill via UPI
- 1App calls POST /payments with card_id, amount ₹42,350 and an Idempotency-Key. Orchestrator checks the amount against the latest bill and limits, creates the payment in state CREATED, and creates a PG order.
- 2The app opens the UPI app via intent; the user approves with the UPI PIN. The orchestrator moves the payment to COLLECTING.
- 3The PG sends a signed webhook: collection success. The orchestrator verifies the signature, then also confirms with the PG status API, and moves to COLLECTED with a conditional update.
- 4A durable workflow step sends the bill payment request to the BBPOU bank via BBPS with a unique reference = payment_id, and moves to PAYING_ISSUER.
- 5BBPS returns success with a BBPS transaction reference once the issuer accepts it. The orchestrator moves to PAID and, in the same transaction, writes an outbox event 'payment.paid'.
- 6CDC publishes the event to Kafka. Rewards consumes it, evaluates campaign rules and writes a credit entry with source_ref = payment_id; a duplicate event hits the unique constraint and is ignored.
- 7Notification service sends 'Your ₹42,350 payment to HDFC card xx1234 is successful' and the bill cache for the card is invalidated.
Issuer payment fails after money is collected
- 1BBPS returns a hard failure (for example, card closed) or the request times out repeatedly.
- 2For timeouts, the orchestrator does not assume failure; it polls BBPS status by the same reference with backoff for up to a set window.
- 3On a confirmed failure, the payment moves to REFUND_PENDING and a refund is created at the PG with idempotency key = payment_id.
- 4When the PG confirms the refund, the payment moves to REFUNDED and the user is told when to expect the money (UPI refunds usually reach the account within a few working days at most).
- 5Any reward already given is reversed with a 'reverse' ledger entry; if the user has spent the coins, the balance can go negative and is recovered from future credits.
7 Deep dives
Two-leg payment as a durable state machine
The core risk is money stuck between the two legs: collected from the user but not yet paid to the issuer. Model each payment as an explicit state machine: CREATED → COLLECTING → COLLECTED → PAYING_ISSUER → PAID, with side branches COLLECT_FAILED, ISSUER_UNKNOWN, REFUND_PENDING and REFUNDED. Store the state in Postgres and move it only with conditional updates (UPDATE payments SET state = 'PAYING_ISSUER', version = version + 1 WHERE id = ? AND state = 'COLLECTED' AND version = ?), so a webhook, a poller and a retry can all race safely. Run the multi-step process in a durable workflow engine such as Temporal, or a home-grown 'job table + workers' if you want fewer moving parts: each step is an activity with retries, timeouts and an idempotency key, and the workflow resumes after crashes. Every external call carries a reference that the partner deduplicates on (the PG order id, the BBPS reference = payment_id). Treat timeouts as UNKNOWN and resolve them by status queries, never by blind retry with a new reference, or the issuer may receive the money twice. Finally, set SLAs per state: a payment in COLLECTED for more than 10 minutes pages on-call. This is a saga: the compensating action for a failed issuer leg is a refund, not a rollback.
Three-way reconciliation
Webhooks get lost and partners make mistakes, so the database is not trusted until reconciled. Every day, and intraday for faster detection, download three sources: the payment gateway settlement file (money collected and fees), the BBPS / BBPOU settlement report (bill payments made to issuers), and your own payments table. Load them into staging tables and match on references: pg_txn_id and payment_id, bbps_txn_ref. Each payment should land in exactly one bucket: matched (collected and paid), collected-not-paid, paid-not-collected (rare, means a bug), or refunded. Breaks create tickets and automatic actions: collected-not-paid older than the SLA triggers a status query and then a refund; amount mismatches go to finance ops. The nodal or escrow account balance must also match the sum of money in flight. Keep the raw files in S3 for audit. In an interview, saying 'I reconcile three ledgers daily and alert on breaks, because webhooks are a hint and settlement files are the truth' is a strong signal. Also note the user-facing angle: when a payment is PAID on BBPS, the issuer may take a little time to reflect it on the card, so show 'paid to bank, will reflect on your card soon' instead of confusing users.
Rewards ledger and budget control
Rewards are money-like: users notice missing coins, and finance notices overspent budgets. Use an append-only ledger with a balance row, updated in one transaction, never a single mutable 'coins' column. Credit only on the final PAID state, through the outbox event, so a payment that is later refunded does not keep its reward; if it does get reversed, write a 'reverse' entry. Idempotency comes from a unique constraint on (type, source_ref): replaying Kafka from the start credits nothing twice. Campaign rules ('2x coins on bills above ₹10,000 paid 3+ days before due date, max 5,000 coins per user per month') live in a rules engine with versioned rule sets, so you can explain any credit later. Budgets are the tricky part at peak: checking a Postgres row per credit makes it a hot row. Instead, keep the remaining budget in Redis and use an atomic DECRBY (or a Lua script that checks per-user caps too); on success, write the ledger. If Redis and the ledger disagree after a crash, a reconcile job fixes Redis from the ledger. Accept a tiny overspend window and size alerts to it. Add abuse checks: many cards on one device, repeated small payments to farm coins, and payments refunded right after the reward.
Bill fetch and reminders at scale
Bills come from BBPS bill fetch, which calls the issuer through the BBPS network. These calls are slow (seconds), sometimes fail, and may be rate limited by issuers. So never fetch on every app open. Cache the latest bill per card with a TTL of a few hours, and refresh in the background around the predicted statement date (learned from history: statements usually arrive on the same day each month). Spread refresh jobs with jitter so ten million cards with a statement on the 5th are not fetched at 00:00. Use per-issuer concurrency limits and circuit breakers: if one issuer's biller is down, mark its bills as 'last updated 2 days ago' instead of failing the screen. Reminders are scheduled jobs: when a bill is fetched, create reminder tasks at due_date - 5 days and due_date - 1 day, cancelled when the bill is paid. A time-bucketed scheduler (tasks partitioned by hour) handles millions of tasks a day. Respect quiet hours and send via push first, SMS only for important reminders, because SMS costs money and has DLT template rules in India.
8 Trade-offs
Temporal workflow vs hand-rolled state machine
Temporal gives retries, timers and resumable steps for free and makes the flow readable. It adds an operational dependency. A Postgres state machine plus a polling worker is simpler and is enough at ~1,000 payments/s. Either way, keep the payments table as the source of truth for state that users and finance see.
Reply to user on collection vs on issuer confirmation
Waiting for the issuer can take seconds to minutes. Showing success right after collection risks a confusing reversal later. The usual answer: show 'payment received, sending to your bank' after collection, and 'paid' after BBPS confirms, with a push notification. Honest intermediate states reduce support tickets.
Reward at payment time vs after settlement
Crediting instantly feels great but is exposed to refunds and fraud. Crediting after settlement is safe but feels slow. Credit on PAID, hold redemption of high-value rewards for a short cooling period, and reverse on refund.
9 Bottlenecks
Due-date and salary-day peaks
At 10x volume, BBPS and PG partners become the limit, not your servers. Use per-partner rate limiters and queue payments into PAYING_ISSUER at the rate partners accept; keep the user-facing part fast. Use multiple PG and BBPOU partners with health-based routing.
Hot campaign budgets
A popular campaign means thousands of credits per second on one budget. Shard the budget into N Redis counters (budget split across sub-keys) and pick one at random; when one runs out, try another. This removes the hot key at the cost of slightly less exact limits.
10 Failure handling
PG webhook lost
The payment stays in COLLECTING. A poller queries the PG status API for payments older than a few minutes, and reconciliation catches anything left. Webhooks speed things up but are never the only path.
BBPS or an issuer biller is down
Payments queue in COLLECTED and retry with backoff using the same reference. Users see 'delayed at bank'. If the outage passes the SLA (and the due date is close), the user is offered a refund. Before collecting new money, check the issuer's health and warn users or block payment for a down issuer, so money is not collected that cannot be delivered.
Rewards service down
Payments are unaffected because rewards consume events asynchronously. Kafka retains the events; when the service recovers it catches up, and the ledger's unique constraint keeps it idempotent.
11 What impresses
- Naming the money-in-flight state (collected but not paid) as the main risk and putting SLAs and alerts on it
- Knowing credit card bill payments in India must go through BBPS under RBI rules
- Reconciliation across PG, BBPS and the internal ledger, with break categories
- Crediting rewards from an outbox event on the final state and reversing on refund
- Sharded Redis budget counters to avoid a hot row during a campaign
- Checking issuer health before collecting money
12 Mistakes
- Treating it as one payment call instead of two independent legs
- Retrying a timed-out issuer payment with a new reference (double payment risk)
- Storing reward coins as a mutable balance with no ledger
- Crediting rewards before the payment is confirmed
- Relying only on webhooks without polling and reconciliation
- Fetching bills from issuers on every app open
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.