The system design question
Design the system that sends webhooks to merchants. When something happens in a merchant's account, like a payment succeeding, send an HTTP request to the URL they've registered. Deliver reliably, even when their servers are slow or down.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Recording events | The payment system itself |
| Sending them to merchants' URLs | Deciding which events exist |
| Retrying failures | The dashboard design |
| Letting merchants see what was sent | Billing |
Then agree on what "good" means:
- Never lose an event: every event gets delivered or clearly marked as failed.
- Quick: most webhooks should arrive within a few seconds.
- Isolated: one slow or broken merchant can't delay anyone else's webhooks.
- Secure: merchants can tell a webhook really came from us.
Rough numbers (5 minutes)
Made up, but reasonable: 500 million events a day, peaks at 5 times the average, about 10% of first attempts fail, and events kept for 30 days.
- ~6K
- events per second, on average
- ~30K
- events per second at a peak
- 10%
- of first attempts fail
- ~30 TB
- of events kept for 30 days
| Question | Rough math | What it means |
|---|---|---|
| How many requests do we keep open? | 30K a second × up to 10 seconds each | Up to 300K slow requests in flight, so workers can't wait on each one |
| How much retry traffic? | 10% of 30K | About 3K retries a second on top of new events |
| How much storage? | 500 million × about 2 KB × 30 days | About 30 TB, so it'll need splitting up |
The overall design (10 minutes)
- A service saves an event, like 'payment succeeded', in the same database update as the change itself.
- A small job publishes saved events to a delivery queue, split up by merchant.
- Delivery workers sign each event and POST it to every endpoint the merchant has set up for that event type.
- Every attempt is recorded, along with the merchant's response.
- Failed attempts go to a retry schedule, with longer and longer gaps between tries.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Register an endpoint | Merchant | URL and which event types they want |
| List events and attempts | Merchant | Filters like time range, type and status |
| Resend an event | Merchant | Event ID |
| The webhook itself | Us, to the merchant | Event ID, type, data and a signature header |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Events | Database, split by merchant | The permanent record, kept for 30 days |
| Endpoints | Relational database | Small, and read on every delivery, so cached |
| Delivery attempts | Database, split by merchant | So merchants can see what happened |
| Pending retries | A schedule ordered by next try time | So workers know what's due |
Step 1: Never losing an event (10 minutes)
If a service saves a payment and then crashes before sending the webhook, the event's gone. So save the event in the same database update as the payment change. A separate job then publishes saved events to the queue. It might publish one twice, but it'll never lose one.
That means merchants can get the same event twice. Every event has a unique ID, and merchants should skip IDs they've seen. That's idempotency, and Stripe's docs tell merchants to do exactly that.
Step 2: Delivering without getting stuck (15 minutes)
Workers sign each request with the merchant's secret, so they can check it's really from us. That's standard webhooks practice. Give each request a timeout of about 10 seconds.
The big risk is one slow merchant. If all workers are stuck waiting on them, everyone else waits too. So split delivery by merchant, a lot like sharding, and cap how many requests can be in flight to any one merchant. A slow merchant only slows themselves down.
Workers send requests without waiting on each one, so a single worker can have thousands in flight.
Not overwhelming merchants
After an outage, a merchant might have thousands of events queued up. Sending them all at once could knock them over again. Rate limit delivery per endpoint and ramp up slowly.
Step 3: Retries (5 minutes)
When an attempt fails, schedule the next one further and further out: a minute, then 5, 30, a few hours, and so on, for about three days. Add a little randomness so retries don't all land at once.
After the last try, mark the event failed, email the merchant, and let them resend it from the dashboard. If an endpoint fails constantly for days, turn it off and tell them.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Delivery split up by merchant | One shared queue | One slow merchant can't hold up everyone else | We had few merchants and they were all reliable |
| Saving events with the change | Sending webhooks right after the change | A crash can never lose an event | The extra write cost too much |
| At least once, with event IDs | Exactly once | Exactly once isn't really possible over the internet | Never |
| No ordering guarantee | Strict ordering per object | Ordering would let one failure block everything behind it | Merchants really needed order, and could live with delays |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| A merchant's server is down for days | Keep retrying for about three days, then mark failed and tell them |
| A merchant's server is really slow | Their in-flight cap fills up, and only their webhooks wait |
| A worker crashes mid-send | The event's retried, and merchants skip the duplicate by its ID |
| The queue falls behind | Events are saved, so nothing's lost. Add workers |
| A merchant rotates their signing secret | Sign with both old and new secrets for a while |
Follow-up questions (5 minutes)
What if a merchant needs events in order?
Include a timestamp and version in each event, and tell merchants to fetch the latest state from the API rather than trust arrival order.
How would merchants replay old events?
Let them pick a time range in the dashboard, and queue those events again at a gentle rate.
What about really big events?
Send a small event with the ID and type, and let merchants fetch the full object from the API.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–30 | Never losing an event |
| 30–45 | Delivering without getting stuck |
| 45–50 | Retries |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- WebhooksHow one system tells another that something happened.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
- Rate limitingStop too much traffic with token buckets and sliding windows.
- Sharding and consistent hashingSplit data across machines and keep it balanced.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by Stripe.