Skip to main content
PProfileMatch

HardProblem 35 of 36 in the system design series

Design a Donations Website: Safe Payments and Live Totals

Cover illustration: Design a Donations Website: Safe Payments and Live Totals

A crowdfunding site lets anyone set up a campaign for a cause, share the link, and collect money from strangers while a progress bar fills toward the goal. The practice design here is a GoFundMe-style service: organisers publish a story and a target, donors pay by card or UPI, and visitors watch the total climb in close to real time.

Interviewers like it because it blends two very different pressures. On one side is money, where mistakes are not tolerated: nobody should pay twice for one gift, and a confirmed gift must not silently vanish from the books. On the other is a classic hot spot. When a campaign is mentioned on television or spreads on social media, thousands of people donate to that single campaign within the same second, and hundreds of thousands more refresh the page to watch. A strong answer keeps the payment path strictly correct, lets the displayed total be slightly behind, and spreads the load of one popular campaign so it cannot take the whole platform down.

This is an interview practice design for a Donations Website-like service, built from first principles. It is not a description of how Donations Website is actually built.

Functional Requirements

Organisers create a campaign with a title, a story, a cover photo and a goal, for instance ten lakh rupees for a medical bill. Donors contribute through an external payment provider using a card or UPI, which keeps sensitive payment handling outside our own services.

The campaign page shows how much has been raised, a progress bar against the goal and a short list of the latest donors, refreshed in near real time so supporters feel momentum. Each donor receives an email receipt once the payment is confirmed, not when they click the button. Transferring funds to organisers and processing refunds are real features of such a product, but each brings its own compliance and ledger concerns, so they are set aside here.

Create a campaign

A story, a photo and a goal amount.

goal: ₹10 lakh

Donate

Pay by card or UPI through a payment provider.

card · UPI

Live progress

Total raised, progress bar and recent donors.

near real-time

Receipts

Every donor gets a receipt by email.

after payment

  • Out of scope today: payouts to organisers and refunds

Non-Functional Requirements

Payment correctness is the top priority. The effect of each donation must be exactly once: the donor is charged a single time and the platform records it a single time, regardless of retries, timeouts or duplicate notifications. The displayed total does not need that rigour. If the progress bar runs a few seconds behind reality, nobody is harmed, so it can be eventually consistent.

The system must absorb viral bursts where a single campaign suddenly receives thousands of donations per second. Security rounds out the list: card data is entered on the provider's checkout and never touches or is stored by our servers, which keeps the scope of PCI compliance small and reduces what an attacker could steal.

Correct payments

Never charge twice, never lose a successful donation.

exactly-once effect

Totals can lag a bit

The live total may be a few seconds behind.

eventual consistency

Handle viral spikes

One campaign can suddenly get thousands of donations a second.

hot campaign

Secure

We never store card numbers; the provider does.

PCI scope small

Scale Estimation

Assume about 2 million donations a day. Spread evenly, that is only around 23 per second, a load any single database handles easily. The average is misleading, though. A viral campaign can draw roughly 5K donations per second on its own, and if each donation corresponds to about 100 people simply watching, that one page sees around 500K views per second at its peak.

Storage is modest. At about 1 KB per donation record, the platform adds roughly 2 GB per day, or around 730 GB per year. So the challenge is not total volume. It is concentration: one campaign, one running total, and thousands of increments per second all targeting the same piece of data, which a single database row cannot absorb.

2million

Donations per day

≈ 23 per second on average

5K / sec

Viral peak

on a single campaign

500K / sec

Page views at peak

≈ 100 views per donation

730GB

Storage per year

1 KB per donation

The hot spot is one campaign → one row can't take every write

Core Entities & API

Four entities cover the domain: Campaign, Donation, Payment (the record that lives at the provider) and Donor. Keeping Donation and Payment separate lets our ledger track its own state machine while still pointing at the provider's transaction ID for audits and reconciliation.

POST /campaigns creates a campaign from a title, story and goal. GET /campaigns/{id} returns the story, amount raised, goal and recent donors. POST /campaigns/{id}/donations takes an amount and an idempotency_key and returns a donation ID together with a payment session for the provider's hosted checkout. Finally, POST /webhooks/payments is called by the provider, not by users, with an event ID, payment ID and status, telling us whether the charge succeeded or failed.

Core entities

CampaignDonationPaymentDonor

API

  • POST /campaigns { title, story, goal }→ { campaign_id }
  • GET /campaigns/{id}→ { story, raised, goal, recent[] }
  • POST /campaigns/{id}/donations { amount, idempotency_key }→ { donation_id, payment_session }
  • POST /webhooks/payments { event_id, payment_id, status }→ 200 OK

High-Level Design

The front door is an API gateway that checks identity and enforces rate limits. Campaign page views are handled by the campaign service, which returns stored details and reads the live total from Redis, so heavy viewing never reaches Postgres.

A donation follows a stricter route. The donation service inserts a pending record in Postgres, keyed by the idempotency key, and then opens a payment with a provider such as Stripe or Razorpay. The donor completes payment on the provider's checkout page, and the provider later calls our webhook with the outcome. Only at that point is the donation marked paid.

In the same transaction that marks it paid, the service writes an event to an outbox table. A relay publishes outbox rows to Kafka, and an aggregator consumes donation events per campaign and increments that campaign's Redis total by each amount. The outbox guarantees that a confirmed donation always produces its event, and the progress bar catches up within a few seconds.

read totalpending donationcharge · webhookoutboxadd to totalClientweb · appAPI gatewayauth · rate limitsCampaign servicepages · totalsLive totalsRedisDonation servicepending → paidDonations DBPostgres · ledgerPayment providerStripe · RazorpayDonation eventsKafkaAggregatorsums per campaign
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Idempotent Payments

Preventing double charges starts on the client. When the donate form opens, the app generates a unique idempotency key and reuses it for every retry of that donation, including the second tap from a nervous user on a flaky connection. The donations table enforces a unique constraint on this key, so the first request creates the pending row and any repeat simply finds that row and returns the identical response.

The same key is forwarded to the payment provider, which applies its own deduplication; even if our service times out and calls the provider again, the card is debited only once. Webhooks are verified by checking the provider's signature, and processed event IDs are recorded so a redelivered event is ignored. Donation state only moves forward, from pending to paid or failed, never back.

Webhooks can arrive late or not at all. Once a day, a reconciliation task lines up every ledger entry against the provider's settlement report, marks missing successes as paid, and flags anything that does not match for review.

  1. 1Idempotency keyClient creates a unique ID when the form opens
  2. 2Insert onceUnique key in the DB; a retry returns the same row
  3. 3Same key to the PSPThe provider also refuses to charge twice
  4. 4Verify the webhookCheck signature, dedupe by event ID, update state
  5. 5Reconcile dailyMatch our ledger with the provider's report

Deep Dive 2 — Live Campaign Totals

Updating a raised column on the campaign row for each donation is exact, but at 5K donations per second every transaction contends for that one row lock and the queue of writers grows without bound. Computing SUM(amount) across all donations on each page view avoids extra state, but doing it 500K times per second would overwhelm the database.

Sharded counters solve both problems and are the chosen design. Each campaign gets a set of counters, say 16 Redis keys, and every donation applies INCRBY to one of them picked at random, spreading the writes. Reading the total means adding the 16 values, and that sum is cached for a second or two so viewers do not repeat the work.

Kafka delivers at least once, so the aggregator remembers which donation IDs it has already counted and skips duplicates. A periodic job recomputes each total from the Postgres ledger and corrects any drift, which keeps the fast path honest.

Update one row

  • + Simple, always exact
  • − Hot row lock in a spike
  • − Writes queue up

SUM on every view

  • + No extra state
  • − 500K sums a second
  • − Kills the database

Sharded countersChosen

  • + Spreads hot writes
  • + Cheap reads with caching
  • − A few seconds behind
  • 16 keys per campaign: each donation INCRBY one random shard; read = sum
  • Dedupe by donation ID; a periodic job recomputes from the ledger

Deep Dive 3 — Viral Spikes

When a campaign goes viral, most traffic is people looking, not paying. The CDN caches the campaign page itself, including story, photos and layout, so only the total needs a live fetch. Browsers re-request just that figure on a timer of a few seconds, and it comes from a cache entry that lives only briefly, which means half a million viewers are served from memory rather than from the database.

The donation service holds no state and each donation is a simple insert, so it can autoscale horizontally as traffic rises. Popular campaigns also attract card-testing fraud, where bots try stolen cards with tiny amounts; rate limits per IP address and per card, combined with the provider's fraud screening, block most of it. The recent donors list lives in a Redis list trimmed to the latest fifty entries, which makes rendering it trivial.

  • Cache the campaign page at the CDN

    Story and images are cached; only the total is fetched live.

  • Poll a cached total

    Clients refresh every few seconds from a short-lived cache.

  • Autoscale stateless services

    Donation writes are simple inserts; add instances as traffic grows.

  • Stop card-testing bots

    Rate limit by IP and card, and use the provider's fraud checks.

  • Recent donors as a capped list

    Push to a Redis list and trim it to the last fifty.

Trade-offs

Sharded counters handle extreme write bursts, but the total lags by a few seconds. Webhooks are faster and cheaper than polling the provider, yet they can be delayed or dropped, which is why reconciliation exists. The outbox plus Kafka ensures no confirmed donation is ever missing from the totals, at the cost of operating another component.

Aggressive caching keeps the site standing under huge traffic while visitors may see slightly older figures. The rule underneath all four decisions: demand precision for money, accept delay for displayed totals, and serve everything else from cache.

  • Sharded counters vs one total

    Handles spikes, but the total is a few seconds behind.

  • Webhooks vs polling the provider

    Fast and cheap, but they can be late, so we reconcile.

  • Outbox + Kafka vs direct update

    No lost events, but one more moving part to run.

  • Aggressive caching

    Survives huge traffic, but viewers see slightly old data.

Exact for money. Eventual for totals. Cache everything else.

Key takeaways

  • Money must be exact: idempotency keys in the database and at the payment provider.
  • Verify webhook signatures, dedupe by event ID, and reconcile daily with the provider.
  • Write an outbox event in the same transaction that marks a donation paid.
  • Sharded counters in Redis spread a hot campaign's writes; sum and cache on read.
  • Serve viral campaign pages from the CDN and poll a short-lived cached total.

Interview questions

How do donation websites avoid charging a donor twice?

The client generates an idempotency key when the donate form opens and resends it on every retry. The database has a unique constraint on that key, so duplicates return the original donation instead of creating a new one. The key is also passed to the payment provider, which deduplicates on its side, and webhooks are deduplicated by event ID.

How do you update a fundraising total with thousands of donations per second?

Use sharded counters. Instead of one total per campaign, keep several counters, for example 16 Redis keys, and increment a random one per donation so writes do not collide. The displayed total is the sum of all counters, cached for a second or two. A background job recomputes totals from the ledger to fix any drift.

What is the transactional outbox pattern in payment systems?

When a donation is marked paid, the service writes an event row into an outbox table inside the same database transaction. A separate relay reads the outbox and publishes events to Kafka. Because the state change and the event commit together, a crash can never leave a paid donation without its event, so downstream totals and receipts stay complete.

What happens if a payment webhook never arrives?

The donation stays pending in our database even though the provider may have charged the card. A scheduled reconciliation job compares our ledger against the provider's settlement report, marks matching payments as paid, emits the missing events, and flags any mismatch for manual review. This makes webhooks a fast path rather than the only source of truth.

How does a crowdfunding site handle a campaign going viral?

Cache the campaign page at the CDN and fetch only the total separately, from a very short-lived cache that clients poll every few seconds. Scale the stateless donation service horizontally, spread total updates across sharded counters, and rate limit by IP and card to block card-testing bots. The database sees only the actual donation inserts.

Keep practising