The system design question
Guests can book now and pay later, on or before check-in. A third-party API gives us newly reported fraudulent cards. Design a service that finds and blocks bookings made with those cards.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Pulling new reports from the fraud API | Building our own fraud model |
| Finding existing bookings made with a reported card | How card numbers are stored and secured |
| Checking new bookings against cards we already know are bad | Chargebacks and disputes |
| Blocking a booking and telling the guest and hotel | The payment flow itself |
Then agree on what "good" means:
- Never charge a reported card: once a card's reported, no booking on it gets charged, even one made months ago.
- Don't miss a report: if the fraud API or our service goes down for a while, we catch up on everything we missed.
- Fast enough: a new report blocks its bookings within a few minutes, well before any scheduled charge.
- Fair to real guests: a wrong report shouldn't silently cancel someone's holiday. They get a chance to fix it.
Rough numbers (5 minutes)
These are assumptions, made up but reasonable: 1.5 million bookings a day with 40% of them pay later, about 60 days on average between booking and paying, and 100,000 newly reported cards a day from the fraud API.
- ~600K
- new pay later bookings a day
- ~36M
- unpaid pay later bookings at any time
- ~1
- new bad card per second, on average
- ~2 GB
- for a card to bookings lookup
| Question | Rough math | What it means |
|---|---|---|
| How many open bookings to check? | 600K a day × 60 days | About 36 million unpaid bookings, any of which could match a new report |
| How big is the lookup? | 36 million × about 50 bytes for a card fingerprint and booking ID | About 2 GB, small enough for one indexed table or key-value store |
| How much work per report? | 100K reports a day ÷ 86,400 seconds | About 1 a second, each one a single lookup by card. Tiny |
The overall design (10 minutes)
- When a guest books with pay later, the payment provider gives us a card fingerprint, a stable ID for the card that isn't the card number. We save it with the booking and add it to a card to bookings lookup.
- A feed reader polls the fraud API every minute, turns each reported card into the same fingerprint, saves it in a bad cards table, and puts a message on a queue.
- The matcher reads the queue, looks up every unpaid booking on that card, and asks the booking service to put each one on a payment hold.
- A held booking won't be charged. The guest gets asked for a different card, and the hotel sees the booking's at risk.
- New bookings check the bad cards table when they're made, and the payment service checks it once more right before any charge.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Check a card | Booking and payment services | Card fingerprint. Returns whether it's reported and when |
| Hold a booking | Matcher | Booking ID, reason, report ID |
| Replace the card | Guest | Booking ID, new card from the payment page |
| Review a hold | Fraud team | Booking ID, release or cancel |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Bad cards (fingerprint, when reported, source) | Relational database with a unique rule on fingerprint | Small, and a repeated report just hits the unique rule |
| Card fingerprint to open bookings | Indexed table or key-value store, split by fingerprint | Turns 'who used this card?' into one lookup |
| Feed position | Same database as bad cards | So we can pick up where we left off after a crash |
| Holds and decisions | Booking database, plus an audit log | Who held what, why, and what happened next |
Step 1: Not missing a report (10 minutes)
Third-party APIs go down, rate limit and change. Assume the fraud API gives us reports since a cursor or timestamp. The reader asks for everything after its saved position, saves the new bad cards, and only then moves its position forward. If it crashes in between, it fetches the same page again next time.
Fetching the same report twice is fine. The bad cards table has a unique rule on the fingerprint, so a repeat changes nothing, and the matcher's actions are safe to repeat too. That's idempotency, and it's what lets us retry freely.
Put each new card on a queue instead of matching inside the reader. The reader stays simple and fast, and if matching is slow or broken, messages wait instead of getting lost.
Watch how far behind the feed is. If we haven't fetched anything new in 15 minutes, page someone. If the API offers a full list, compare against it once a day to catch anything that slipped through.
Step 2: Matching new reports to old bookings (12 minutes)
This is the twist in the question. A card gets reported weeks after someone booked with it. Scanning 36 million open bookings for every report would be silly, so we keep a lookup from card fingerprint to the unpaid bookings that use it. Add to it when a pay later booking is made, remove from it when the booking's paid or cancelled. Those updates can come from booking events.
We never keep raw card numbers in this service. The payment provider already gives us a fingerprint per card, and we turn the fraud API's card numbers into the same fingerprint as soon as they arrive, then throw the number away.
There's a race to think about. A booking could be made on a card at the same moment that card's being reported. Handle it from both sides: the reader saves the bad card first and then matches, and a new booking adds itself to the lookup first and then checks the bad cards table. Whichever happens second sees the other one, so the booking gets caught either way.
One last check before charging
Even if the matcher's down for an hour, the payment service checks the bad cards table right before it charges anything. It's one lookup, and it means a missed match can't turn into a charge on a reported card.
Step 3: What blocking actually means (8 minutes)
Ask what 'block' should mean, because it changes the design. Cancelling right away is simple, but fraud reports are sometimes wrong, and cancelling a real guest's stay is a big deal. A gentler default works better: put the booking on a payment hold, stop any scheduled charge, and ask the guest to add a different card.
If they add a new card within, say, 48 hours, the hold comes off. If they don't, or check-in is very close, cancel the booking and give the room back to the hotel. Big or unusual bookings can go to the fraud team to look at first.
Every hold and cancellation goes in an audit log with the report that caused it. Keep each action safe to repeat by keying it on booking and report, so a retried message doesn't send the guest three emails.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Polling the fraud API | Waiting for it to push to us | We control the pace and can always catch up from our saved position | The provider offered reliable webhooks and we needed seconds, not minutes |
| A lookup from card to bookings | Scanning bookings for each report | One lookup instead of 36 million rows, per report | Never, at this size |
| Holding the booking first | Cancelling right away | Wrong reports happen, and a hold gives real guests a way out | The report source was always right, or fraud losses were climbing |
| A final check before charging | Trusting the matcher alone | One cheap lookup covers any bug or delay in matching | Hard to see a reason to drop it |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| The fraud API is down for hours | We keep our saved position and catch up when it's back. An alert fires if we're too far behind |
| The matcher falls behind | Messages wait on the queue, and the check before charging still stops any bad charge |
| The same card gets reported twice | The unique rule on fingerprint ignores the second one |
| A report turns out to be wrong | The guest's booking is on hold, not cancelled. The fraud team releases it, and the card's marked as cleared |
| A booking and a report land at the same moment | Both sides write first, then check, so one of them catches it |
Follow-up questions (5 minutes)
How do you know it's working?
Track how long it takes from a report arriving to its bookings being held, how many charges the last check stops (should be near zero), and how many holds get released as wrong.
What if we add a second fraud provider?
Add another reader that writes to the same bad cards table and queue. Keep the source on each report so you can see which provider is right more often.
What about guests who already paid?
Out of scope for blocking, since the money's taken. Send those bookings to the fraud team, who can look at refunds or chargebacks.
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 | Not missing a report |
| 30–42 | Matching new reports to old bookings |
| 42–50 | What blocking actually means |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Relational vs non-relational databasesPick a database based on how you'll actually use it.
- 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 Booking.com.