Design a Payment Webhook for a Ticketing App (Eventbrite / Ticketmaster)
Difficulty: Senior+ Level ยท Stripe's rare but real system design round
๐ณ First, a reality check about Stripe and system design. Stripe does not lean on system design the way Google or Amazon do. For plenty of roles it skips a dedicated system design round entirely, the take-home, the integration round, and the live debugging round carry that weight. But for senior, staff, and above, a focused system design conversation does show up, and we believe this question and close variations of it have been used in exactly those loops. It is so on-brand for Stripe: it is not really "design a giant distributed system," it is "design one realistic, money-touching slice of a product, and get every failure case exactly right." That is Stripe's entire world.
๐ชค The trap is in the prompt, and it is deliberate. You will hear something broad: "design the payment system for an app like Eventbrite or Ticketmaster." Your instinct will scream "that is a huge system, seat maps, reservations, search, real-time availability", and you will start drawing all of it. That instinct is the trap. The interviewer does not want Ticketmaster. They want the payment confirmation flow, and at the center of it, the webhook. They will not tell you this, and they will not steer you back when you wander. They are watching to see whether you find the real scope by asking the right questions. The candidate who starts sharding a seat-inventory cache has already lost the plot. The candidate who leans back and says "before I draw anything, let me pin down which part of payments we actually care about" has already half-passed.
This page is built to make you that second candidate.
Understanding the Problem
๐ฏ What you are actually being asked to build
Strip away the Ticketmaster glamour and the real prompt is small and sharp: a user has picked some tickets and clicked Pay. Money moves through Stripe. When the payment resolves, Stripe calls a URL that you own, an HTTP endpoint sitting inside your Booking Service, to tell you what happened. Your job is to design that endpoint and everything it touches: the webhook that confirms (or fails) the booking, the data it writes, and the guarantees it must hold when the network, the database, or Stripe itself misbehaves.
That endpoint, the one Stripe calls back, is a webhook: a callback you expose and Stripe invokes. Not a queue you poll. Not a request your frontend makes. A public HTTPS route that a third party (Stripe) hits whenever a payment event occurs.
Here is the whole event-ticketing system you might imagine building. Glance at it, then watch how small the actual question is.
The whole system, and the small slice this question is really about
The grey boxes (search, the events cache, the seat map, the CDN, the distributed lock that serializes seat reservations) are real, and a full "design Ticketmaster" question would cover them. This is not that question. Everything outside the dashed box is out of scope. The dashed box, the Booking Service, its payment call to Stripe, and the webhook Stripe calls back, is the entire interview.
๐งญ The single sentence that proves you understand a webhook. Say this out loud early: "The webhook is a normal HTTP route I host in my Booking Service. I give Stripe its URL in the dashboard, and from then on Stripe makes a POST to that URL whenever a payment succeeds or fails. Stripe is calling me, not the other way around, and any load-balanced instance of my service can handle it because the work is keyed off the booking id Stripe sends back." If you can say that cleanly, the interviewer relaxes. If you cannot, every later answer will sound shaky.
Clarifying Questions to Ask
The clarifying questions are the test here, more than anywhere else. The interviewer is deliberately under-specifying, so the questions you ask are the clearest signal of whether you have scoped the problem correctly. Ask these, out loud, before drawing a single box:
- "Which part of payments are we designing?" Confirm it is the post-payment confirmation flow and the webhook, not the seat-reservation engine.
- "Where does the webhook live, and who calls it?" Establish that it is your Booking Service route and Stripe is the caller. This alone separates people who have shipped a Stripe integration from people who have only read about one.
- "When exactly does it fire?" After a payment outcome is known:
payment_intent.succeededorpayment_intent.payment_failed. - "What is the guarantee?" Never confirm a booking that was not paid for; never leave a charged customer without a confirmed booking. Exactly-once effects, even though webhook delivery is at-least-once.
- "How does the user's browser learn the result?" Crucial, and a place people stumble: the browser does not hear from the webhook. The webhook writes to the database; the browser polls for the booking status. (We will come back to why this decoupling is a strength.)
- "Do we own the seat-reservation logic?" Get the explicit "no." It lets you name the Redis locks once and move on, instead of sinking the interview into Lua scripts.
โ ๏ธ A subtle scope point worth raising yourself. Even though seat-reservation internals are out of scope, you still have to model the booking and the payment, because the webhook's whole job is to mutate them. A candidate who refuses to talk about the data model "because it is out of scope" has over-corrected. The skill being tested is judgment: go deep on the booking, payment, and webhook entities, and stay shallow (one sentence each) on seat maps, search, and CDC.
Functional Requirements
In scope (design these well)
- Confirm a booking when Stripe reports the payment succeeded (tickets become
BOOKED, booking becomesCONFIRMED, a receipt is sent). - Fail a booking cleanly when the payment fails (booking becomes
PAYMENT_FAILED, the held seats are released back to inventory). - Be correct under duplicate, delayed, and replayed webhooks. Stripe guarantees at-least-once delivery, so the same event will arrive twice.
- Reconcile bookings that get stuck because a webhook was missed entirely.
- Reject forged webhook calls (the endpoint is public on the internet).
Out of scope (name them in one breath, then stop)
- The seat-reservation engine: Redis distributed locks, the atomic Lua reservation, the available-seat cache.
- Event search, browse, recommendations, the CDN, the seat-map UI.
- Refund policy math, dispute handling, and fraud scoring (mention they exist, do not design them).
Non-Functional Requirements
- Correctness over latency. This is money. A webhook that confirms a booking ten seconds late is fine. A webhook that double-confirms, or confirms an unpaid booking, is a catastrophe.
- Idempotency is mandatory, not a nice-to-have. Every effect the webhook causes must be safe to trigger more than once.
- Security. The webhook URL is public, so every request must be cryptographically verified as genuinely from Stripe before it is trusted.
- Resilience to partial failure. Any single step (your DB write, the Stripe call, the webhook delivery) can fail independently. No failure may leave a customer charged-but-unconfirmed or confirmed-but-unpaid.
- Modest, bursty scale. Webhook volume equals payment volume, far smaller than the read path, but it spikes hard during a hot on-sale. Think thousands of confirmations per minute at peak, not millions per second.
Core Entities and the Data Model
The webhook does nothing in a vacuum. It reads and mutates three things. Modelling them crisply is most of the battle, and it is the part nervous candidates skip because they have wrongly decided "the data model is out of scope."
-- The BOOKING is the thing the webhook confirms. It carries the state machine.
CREATE TABLE bookings (
booking_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
ticket_ids UUID[] NOT NULL,
status VARCHAR(30) NOT NULL, -- IN_PROGRESS | PAYMENT_PROCESSING |
-- CONFIRMED | PAYMENT_FAILED | EXPIRED | REFUNDED
total_amount DECIMAL(10,2) NOT NULL, -- locked in at reservation time, not at payment time
payment_id VARCHAR(255), -- Stripe PaymentIntent id; NULL until payment starts
expires_at TIMESTAMPTZ, -- the ~10-minute hold; NULL once CONFIRMED
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TICKETS carry exactly two states. (RESERVED is a Redis concept and is out of scope here.)
CREATE TABLE tickets (
ticket_id UUID PRIMARY KEY,
event_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | BOOKED
user_id UUID, -- set atomically, in the same txn, when BOOKED
booking_id UUID, -- set atomically, in the same txn, when BOOKED
UNIQUE (event_id, section, row, seat_number) -- last-line double-booking guard
);
-- The IDEMPOTENCY LEDGER: every Stripe event id we have already acted on.
CREATE TABLE processed_webhook_events (
stripe_event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Three details earn real credit here:
payment_id(the Stripe PaymentIntent id) lives on the booking. It is your link back to Stripe for refunds, dispute evidence, and, critically, reconciliation: if a webhook never arrives, this id lets you ask Stripe directly "what happened to this payment?"expires_atis the booking's hold clock. The webhook has to reason about it, because a payment can succeed after the hold expired (the expiry race, deep dive 4).processed_webhook_eventsis the spine of idempotency. One row per Stripe event id, inserted after you handle it. It is how at-least-once delivery becomes exactly-once effect.
The booking state machine
The webhook is, fundamentally, a driver of this state machine. Every interesting edge case the interviewer probes is a transition on it:
submit payment webhook: succeeded
IN_PROGRESS โโโโโโโโโโโโโโโโโโโโโโโโบ PAYMENT_PROCESSING โโโโโโโโโโโโโโโบ CONFIRMED
โ โ โ
abandon / 10-min hold expires webhook: failed user refund
โ โ โ
โผ โผ โผ
CANCELED / EXPIRED PAYMENT_FAILED REFUNDED
Terminal: CONFIRMED, PAYMENT_FAILED, CANCELED, EXPIRED, REFUNDED
Active: IN_PROGRESS, PAYMENT_PROCESSING
Once a booking reaches a terminal state it is never edited again, retries create new bookings, which keeps the audit trail append-only and makes "why is this booking in this state?" a question you can always answer.
API and System Interface
Only three endpoints matter for this slice. Notice that two are called by the browser and one is called by Stripe.
# Browser โ your Booking Service: start the charge
POST /bookings/{bookingId}/payment
Body: { paymentMethodId } # a Stripe.js token; the raw card never hits your server
โ { clientSecret, status } # clientSecret only used if 3DS is required
# Stripe โ your Booking Service: the webhook (the heart of this question)
POST /webhooks/stripe
Headers: Stripe-Signature: ... # you MUST verify this
Body: a Stripe Event (payment_intent.succeeded | payment_intent.payment_failed)
โ 200 { received: true } # respond fast, then process
# Browser โ your Booking Service: learn the outcome (the webhook does NOT call the browser)
GET /bookings/{bookingId}
โ { status, ... } # browser polls this every ~2s until terminal
High-Level Design: The Payment and Webhook Flow
Here is the diagram to actually draw on the whiteboard: one payment, left to right, from the user clicking Pay to the booking landing in CONFIRMED. Four actors, the browser, your Booking Service, Stripe, and the card network, and a flow that leaves your server's control in the middle and comes back via the webhook.
One payment, left to right: client to Stripe to webhook to confirmed
Walk it in numbered order, this is the spine of your whole answer:
- Browser tokenizes the card with Stripe.js. Card details go straight to Stripe and come back as a
paymentMethodIdtoken. Your server never sees a card number (this is what keeps you at PCI SAQ-A, the easy compliance tier). - Browser โ Booking Service:
POST /bookings/{id}/paymentwith that token. - Booking Service โ Postgres: flip the booking to
PAYMENT_PROCESSINGbefore talking to Stripe (ordering rule 1, deep dive 3). - Booking Service โ Stripe:
paymentIntents.create({ confirm: true }).confirm: truemeans the charge is attempted in this one call. Stripe goes to the card network. - Booking Service โ Browser: return
{ clientSecret, status }. The server's part of this request is done. The browser starts pollingGET /bookings/{id}. - Stripe โ card network. Milliseconds to seconds. Your server is idle and uninvolved.
- Stripe โ Booking Service:
POST /webhooks/stripe. This is the authoritative result, and it may land on any instance of your service. - Webhook handler: verify signature โ respond
200โ confirm the booking (ticketsBOOKED, bookingCONFIRMED). The browser's next poll seesCONFIRMEDand shows the success screen.
๐ฌ The highest-signal sentence about this whole flow: "The browser and the webhook handler never talk to each other. The webhook writes the result to the database, and the browser reads the result from the database by polling. That decoupling is the point: the user can close their laptop after clicking Pay and the booking still confirms, because confirmation does not depend on their connection staying open."
The Full Payment Flow, Actor by Actor
The board above is the picture. This is the script: every actor, every call, in exact order, with the booking's database status on the right so you can watch the state move. If you can narrate this top to bottom without skipping a step, you have answered the question.
Four actors: the BROWSER, your BOOKING SERVICE, STRIPE, and POSTGRES.
Read top to bottom. The booking's status in your database is on the right.
booking.status
A. The card never touches your server โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
BROWSER
| User clicks Pay. The card form is a Stripe Elements iframe on
| Stripe's own domain, so card data goes straight to Stripe.
v
BROWSER --> STRIPE tokenize the card, get a paymentMethodId IN_PROGRESS
| Your server never sees a card number. This is all of PCI SAQ-A.
v
BROWSER --> BOOKING POST /bookings/{id}/payment { token } IN_PROGRESS
B. Record intent BEFORE calling Stripe โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
BOOKING --> POSTGRES UPDATE bookings SET status = PAYMENT_PROCESSING
| 'PAYMENT_PROCESSING' WHERE id=$1 AND status='IN_PROGRESS'
| The conditional WHERE is the lock. If this write fails the user
| has NOT been charged yet, so the failure is completely safe.
v
C. Call Stripe; the charge is attempted โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
BOOKING --> STRIPE paymentIntents.create({ confirm: true }) PAYMENT_PROCESSING
| confirm:true means "attempt the charge now." Stripe goes to the bank.
v
STRIPE --> BOOKING returns { id, client_secret, status } PAYMENT_PROCESSING
| status = processing | requires_action | succeeded. This is the
| INITIAL state of the attempt, NOT the final outcome. The webhook is.
v
BOOKING --> POSTGRES UPDATE bookings SET payment_id = pi.id PAYMENT_PROCESSING
| Store the PaymentIntent id: your handle for the webhook,
| reconciliation, refunds, and disputes.
v
BOOKING --> BROWSER returns { clientSecret } PAYMENT_PROCESSING
| The server's job in THIS request is done. The browser starts
| polling GET /bookings/{id}. (Any 3DS challenge runs here.)
D. Stripe settles with the bank; your server is idle โโโโโโโโโโโโโโโโโโโโโโโโ
STRIPE --> CARD NET authorize with Visa / Mastercard PAYMENT_PROCESSING
| Milliseconds to seconds, entirely between Stripe and the bank.
| Your server is not polled and not waiting. It is doing nothing.
v
E. Stripe calls YOU back: the webhook (the authoritative result) โโโโโโโโโโโโ
STRIPE --> BOOKING POST /webhooks/stripe PAYMENT_PROCESSING
| event = payment_intent.succeeded OR payment_intent.payment_failed
| The load balancer routes it to ANY instance; servers are stateless.
v
BOOKING handler: 1) verify signature 2) respond 200 3) enqueue
v
JOB --> POSTGRES on succeeded: tickets -> BOOKED, booking -> CONFIRMED
| on failed: booking -> PAYMENT_FAILED
| All in one transaction, then release the Redis seat holds.
v
F. The browser learns from the database, not the webhook โโโโโโโโโโโโโโโโโโโโ
BROWSER --> BOOKING GET /bookings/{id} (poll every ~2s) CONFIRMED
The first poll after the job commits returns CONFIRMED and shows
the success screen. Browser and webhook never speak directly.
Who calls whom (and why the browser polls, not the webhook)
The single most common source of confusion is direction. Spell it out and you instantly sound like someone who has shipped this:
BROWSER --> your BOOKING SERVICE POST /bookings/{id}/payment you -> your own server
your BOOKING SVC --> STRIPE paymentIntents.create() your server -> Stripe
STRIPE --> your BOOKING SERVICE POST /webhooks/stripe Stripe -> you (webhook)
BROWSER --> your BOOKING SERVICE GET /bookings/{id} (polling) browser -> checks the DB
The webhook is not special infrastructure. It is a plain HTTP route in your Booking Service. You paste its URL into the Stripe dashboard once, and from then on Stripe makes a POST to it whenever a payment event happens. Stripe is calling you, not the other way around. Any load-balanced instance can handle it, because your servers are stateless and everything is keyed off the booking_id that Stripe echoes back in the event metadata.
And the browser never hears from the webhook at all. The webhook writes the result to Postgres; the browser reads the result from Postgres by polling. That decoupling is a feature, not a workaround: the user can close their laptop the instant they click Pay and the booking still confirms, because confirmation never depended on their connection staying open. Reconciliation jobs, delayed webhooks, even a manual admin fix all take the same path: write to Postgres, and the next poll picks it up.
The PaymentIntent's states, and how they map to your booking
One precise idea unlocks this whole flow: does creating a PaymentIntent charge the user? It depends on one flag.
confirm: false(the default): the PaymentIntent is just an object on Stripe's servers, like an open bar tab. No money moves. You would have to call.confirm()in a second round trip.confirm: true(what we use): the PaymentIntent is created and the charge is attempted in one call. Stripe goes straight to the card network.
Either way, the API response is the initial state, never the final word. Even a confirm: true call can come back as processing (bank still deciding) or requires_action (3DS needed). The authoritative outcome always arrives via the webhook. A PaymentIntent runs its own little state machine on Stripe's side, and your booking mirrors it:
Stripe PaymentIntent state meaning
โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
requires_payment_method PI created, no card attached yet
requires_confirmation card attached, awaiting confirmation
requires_action the bank wants 3DS authentication from the user
processing charge in flight, the bank is deciding
succeeded money captured -> confirm the booking
canceled declined or blocked -> fail the booking
| Stripe PaymentIntent state | Your booking.status | What your system does |
|---|---|---|
requires_payment_method | (before this flow starts) | present the card form |
requires_confirmation | (skipped, we pass confirm: true) | the charge is attempted inside create() |
requires_action (3DS) | PAYMENT_PROCESSING | browser runs the 3DS modal, then keeps polling |
processing | PAYMENT_PROCESSING | wait for the webhook, do not confirm yet |
succeeded โ
| CONFIRMED | webhook confirms the booking, tickets become BOOKED |
canceled / payment_failed โ | PAYMENT_FAILED | webhook fails it, release the held seats |
The takeaway sentence: "My booking's status is a mirror of the PaymentIntent's status, reconciled by the webhook. processing and requires_action both map to PAYMENT_PROCESSING; only the webhook moves me to CONFIRMED or PAYMENT_FAILED."
Advanced Deep Dives
This is where a senior interview is won. Each of these is a conversation you should be able to lead.
๐ You will not write code like this in the interview, and you should not try to. A system design round is a whiteboard conversation, not a coding screen. The snippets below are here for you, to make the mechanics concrete so the ideas stick: what the webhook handler actually does, why the ordering matters, how idempotency is enforced. In the room you would describe them out loud ("I verify the signature, return 200, then process on a queue, deduping on the Stripe event id"), not type them. We believe seeing the real code once is exactly what makes you fluent enough to talk through it with confidence.
1) The webhook itself: verify, acknowledge, then process
The handler does three things, in a specific order, and the order is the answer:
// This route lives in YOUR Booking Service. You register its URL in the Stripe dashboard,
// and from then on Stripe POSTs payment events to it. It is just an HTTP route you own.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// 1) VERIFY: the endpoint is public, so prove the call is really from Stripe.
// Without this, anyone on the internet could POST a fake "payment succeeded".
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // the RAW bytes, not parsed JSON (parsing breaks the signature)
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send('Invalid signature');
}
// 2) ACKNOWLEDGE within Stripe's 30s window, BEFORE doing any database work.
// If you do slow DB work first and time out, Stripe assumes failure and retries.
res.status(200).json({ received: true });
// 3) HAND OFF to a queue. Now the retries are yours to control, not Stripe's.
await webhookQueue.add({ id: event.id, type: event.type, data: event.data.object });
});
Three things to say out loud: signature verification is the security boundary (skip it and your endpoint is a "confirm any booking for free" button); express.raw, not express.json, because the signature is computed over the raw bytes; and respond 200 first, process after, so a slow database never turns into a storm of Stripe retries.
2) Idempotency: at-least-once delivery, exactly-once effects
Stripe delivers webhooks at-least-once, and retries for up to 24 hours if you do not 2xx in time. So the same payment_intent.succeeded will sometimes arrive twice. Confirming a booking twice must be a no-op, not a double-charge of seats or a second receipt. Two layers guarantee that:
async function confirmBooking(bookingId) {
const client = await db.pool.connect();
try {
await client.query('BEGIN');
// FOR UPDATE serializes two concurrent deliveries of the SAME event:
// the second one blocks here until the first commits, then sees CONFIRMED and exits.
const { rows } = await client.query(
`SELECT status, ticket_ids, user_id, payment_id
FROM bookings WHERE booking_id = $1 FOR UPDATE`,
[bookingId]
);
const booking = rows[0];
if (booking.status === 'CONFIRMED') { // duplicate webhook โ idempotent no-op
await client.query('ROLLBACK');
return;
}
if (booking.status === 'EXPIRED') { // paid, but the hold ran out โ see deep dive 4
await client.query('ROLLBACK');
await stripe.refunds.create({ payment_intent: booking.payment_id });
return;
}
// Happy path: PAYMENT_PROCESSING โ CONFIRMED, in one atomic transaction.
await client.query(
`UPDATE tickets SET status = 'BOOKED', user_id = $1, booking_id = $2
WHERE ticket_id = ANY($3) AND status = 'AVAILABLE'`, // AVAILABLE guard = last-line oversell defense
[booking.user_id, bookingId, booking.ticket_ids]
);
await client.query(
`UPDATE bookings SET status = 'CONFIRMED', expires_at = NULL WHERE booking_id = $1`,
[bookingId]
);
await client.query('COMMIT');
// Releasing Redis holds and emailing the receipt happen AFTER commit, on the non-critical path.
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
The two layers, and why you need both:
processed_webhook_events(the event-id ledger) stops you from re-running an event you already finished. Check it before processing, insert after, withON CONFLICT DO NOTHING.SELECT ... FOR UPDATEon the booking row serializes two deliveries racing at the same moment, before either has written the ledger. The second waits, then findsCONFIRMEDand no-ops.
The line that lands: "Idempotency is everything with webhooks. Delivery is at-least-once, so my effects have to be exactly-once. I get there with an event-id ledger plus a row lock, and every state transition is conditional on the current state."
3) The two ordering rules that protect the money
Two orderings in this flow are not stylistic, they each prevent a specific way to lose or double-charge money.
Rule 1: write PAYMENT_PROCESSING to your DB before you call Stripe.
// Conditional UPDATE is also an optimistic lock: of two racing "Pay" clicks,
// only one flips IN_PROGRESS โ PAYMENT_PROCESSING; the other gets rowCount 0 and is rejected.
const updated = await db.query(
`UPDATE bookings SET status = 'PAYMENT_PROCESSING'
WHERE booking_id = $1 AND status = 'IN_PROGRESS' AND expires_at > NOW()
RETURNING total_amount`,
[bookingId]
);
if (updated.rowCount === 0) throw new Error('BOOKING_NOT_PAYABLE'); // expired, gone, or already in flight
// Only NOW call Stripe. confirm:true attempts the charge in this single call.
const pi = await stripe.paymentIntents.create(
{ amount: Math.round(updated.rows[0].total_amount * 100), currency: 'usd',
payment_method: paymentMethodId, confirm: true,
metadata: { booking_id: bookingId } },
{ idempotencyKey: bookingId } // a retried create returns the SAME PaymentIntent, never a 2nd charge
);
await db.query(`UPDATE bookings SET payment_id = $1 WHERE booking_id = $2`, [pi.id, bookingId]);
If you called Stripe first and your DB write then failed, you would have a charged customer with no record that a charge is in flight, the worst state in the system. Writing PAYMENT_PROCESSING first means every failure is recoverable: if Stripe never gets called, the customer was never charged; if Stripe was called, the booking carries a payment_id you can reconcile against.
Rule 2: respond 200 to the webhook before you process it. Covered above, the cost of getting it wrong is Stripe timing out, retrying, and multiplying your load at exactly the worst moment.
4) The expiry race: paid, but the reservation is gone
The nastiest edge case, and a favourite follow-up. The hold is ~10 minutes. A user pays at minute 9:58, Stripe takes a few seconds, and meanwhile the cleanup job marks the booking EXPIRED and releases the seats to someone else. Then payment_intent.succeeded arrives for a booking that no longer owns its seats.
You must not confirm it (the seats may already belong to another buyer, that is how you oversell). Instead, the webhook handler detects status = 'EXPIRED' and immediately refunds the charge, then emails an apology. That is the EXPIRED branch in the confirmBooking code above. Calling out this race before the interviewer does is one of the strongest senior signals in the whole question.
5) Reconciliation: the backstop when webhooks go missing
Webhooks are reliable, not infallible. Stripe gives up after 24 hours of retries; your endpoint could be down for a deploy; a bug could drop an event. You never want a customer's money in limbo because one HTTP call was lost. The backstop is a reconciliation job:
// Runs every few minutes. Any booking stuck mid-flight is checked against Stripe directly.
async function reconcileStuckPayments() {
const stuck = await db.query(
`SELECT booking_id, payment_id FROM bookings
WHERE status = 'PAYMENT_PROCESSING' AND created_at < NOW() - INTERVAL '15 minutes'`
);
for (const { booking_id, payment_id } of stuck.rows) {
const pi = await stripe.paymentIntents.retrieve(payment_id); // ask Stripe for ground truth
if (pi.status === 'succeeded') await confirmBooking(booking_id);
else await failBooking(booking_id);
}
}
Because payment_id lives on every booking, Stripe is always the ground truth you can query. The webhook is the fast path; reconciliation is the guarantee. Saying "the webhook is an optimization over polling Stripe, and reconciliation is the safety net under both" shows you understand why the design is resilient, not just how it works.
6) Scope discipline: what you deliberately do not build
Finally, the meta-skill the question is really measuring. When the interviewer nods toward seat reservations, give the one-paragraph version and stop: "Holds live in Redis, not Postgres, a SET NX EX lock per seat with a 10-minute TTL, plus an atomic Lua script so two buyers cannot grab the same seat. The database tickets table only ever holds AVAILABLE or BOOKED. I am happy to go deeper, but it is a separate question from the payment webhook, so I will park it unless you want to dig in." That sentence proves you could design the reservation engine, and that you have the judgment not to, which is exactly the seniority signal Stripe is buying.
Conclusion
Notice what this answer was, and was not. There was no seat-map sharding, no Elasticsearch, no CDN, no 500k-reads-per-second hand-waving. Instead you scoped an intentionally vague prompt down to its real target, modelled the booking and payment cleanly, and then went deep where the money actually moves: a signature-verified webhook, exactly-once effects over at-least-once delivery, two ordering rules, the expiry race, and a reconciliation backstop. That combination, tight scope plus relentless correctness under failure, is precisely the engineer Stripe is trying to hire.
If this is the level of depth you want for the rest of your Stripe loop, that is exactly what the rest of our Stripe track is built for: the coding rounds, the live debugging and integration rounds that Stripe is famous for, and more system design problems drawn from the same "one realistic slice, done exactly right" playbook. Start from the Stripe prep guide, and walk in ready to scope, then go deep.