Design a Payment Webhook for a Ticketing App (Eventbrite / Ticketmaster)

Difficulty: Senior+ Level ยท Stripe's rare but real system design round

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 full Eventbrite/Ticketmaster architecture: client app, load balancer and API gateway, a search service backed by Elasticsearch and CDC, an events service with a Redis event cache, a booking service that talks to Stripe and a Redis distributed lock, and a PostgreSQL database. The dashed box marks the only part this question is actually about: the booking and payment slice.

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.

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.succeeded or payment_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.

Functional Requirements

In scope (design these well)

  • Confirm a booking when Stripe reports the payment succeeded (tickets become BOOKED, booking becomes CONFIRMED, 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:

  1. 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?"
  2. expires_at is 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).
  3. processed_webhook_events is 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.

The payment and webhook flow, left to right: the browser tokenizes the card with Stripe.js, POSTs the token to the Booking Service, which writes PAYMENT_PROCESSING to Postgres then calls Stripe paymentIntents.create with confirm true; Stripe charges the card network and later POSTs a webhook back to the Booking Service, which verifies the signature, responds 200, and asynchronously confirms the booking in Postgres while the browser polls for the result.

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:

  1. Browser tokenizes the card with Stripe.js. Card details go straight to Stripe and come back as a paymentMethodId token. Your server never sees a card number (this is what keeps you at PCI SAQ-A, the easy compliance tier).
  2. Browser โ†’ Booking Service: POST /bookings/{id}/payment with that token.
  3. Booking Service โ†’ Postgres: flip the booking to PAYMENT_PROCESSING before talking to Stripe (ordering rule 1, deep dive 3).
  4. Booking Service โ†’ Stripe: paymentIntents.create({ confirm: true }). confirm: true means the charge is attempted in this one call. Stripe goes to the card network.
  5. Booking Service โ†’ Browser: return { clientSecret, status }. The server's part of this request is done. The browser starts polling GET /bookings/{id}.
  6. Stripe โ†” card network. Milliseconds to seconds. Your server is idle and uninvolved.
  7. Stripe โ†’ Booking Service: POST /webhooks/stripe. This is the authoritative result, and it may land on any instance of your service.
  8. Webhook handler: verify signature โ†’ respond 200 โ†’ confirm the booking (tickets BOOKED, booking CONFIRMED). The browser's next poll sees CONFIRMED and shows the success screen.

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 stateYour booking.statusWhat 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_PROCESSINGbrowser runs the 3DS modal, then keeps polling
processingPAYMENT_PROCESSINGwait for the webhook, do not confirm yet
succeeded โœ…CONFIRMEDwebhook confirms the booking, tickets become BOOKED
canceled / payment_failed โŒPAYMENT_FAILEDwebhook 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.

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, with ON CONFLICT DO NOTHING.
  • SELECT ... FOR UPDATE on the booking row serializes two deliveries racing at the same moment, before either has written the ledger. The second waits, then finds CONFIRMED and 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.

Was this page helpful?