---
title: "Stripe Checkout without webhooks: a pull-based design | Once Books"
description: "How we confirm Stripe Checkout payments with no webhook: session reuse, a unique checkout key, strict verification and a 10-minute reconciliation sweep."
source: "https://www.oncebooks.com/blog/stripe-checkout-without-webhooks"
---

1. [Home](https://www.oncebooks.com/)
2. [Blog](https://www.oncebooks.com/blog)
3. Stripe Checkout without webhooks: a pull-based design

Blog

# Stripe Checkout without webhooks: a pull-based design

[The Once Books team](https://www.oncebooks.com/authors/team) · 8 October 2026 · 8 min read

- [engineering](https://www.oncebooks.com/blog/tag/engineering)
- [payments](https://www.oncebooks.com/blog/tag/payments)

You can run Stripe Checkout without webhooks if you store each session's id before the redirect and re-ask Stripe about it whenever you need the answer. That's how Once Books sells its $9.99 digital picture book today. A purchase becomes paid in exactly one function, `confirmPurchase`, and three paths call it: the buyer's return from Stripe, the buyer's next click on "Unlock", and a worker sweep that runs every 10 minutes. No public endpoint receives events from Stripe. Below is how the pieces fit, the double-charge guards we needed once there was no webhook to lean on, and what the design costs us.

## Why did we choose pull-based confirmation over webhooks?

We chose pull-based confirmation because every fact a webhook would have told us is already sitting in the Stripe API, keyed by a session id we write to our database before the buyer leaves. A purchase row starts as `PENDING`, the session is created, and its id is stored on the row. From then on, anything that wants to know whether the book was paid fetches that session and checks it.

That gave us three concrete wins:

- **No public webhook endpoint to secure.** There's no signing secret to rotate and no event to forge, because we never act on anything Stripe pushes.
- **One code path.** The return page, the unlock self-heal and the reconciliation sweep all run the same `confirmPurchase`. A bug fixed there is fixed everywhere.
- **Local development is the production flow.** A developer with a Stripe test key pays with a test card and goes through the same confirmation as a real buyer. There's no free-unlock bypass in a running app.

The trade-off is latency and read traffic, which we cover at the end.

## What does confirmPurchase verify before a book unlocks?

`confirmPurchase` refuses to mark a purchase paid unless four checks pass against the live session: livemode, metadata, amount and currency. The client's ids are hints. The server always fetches the session by the id it stored itself.

In order, it checks that:

1. **The session is settled.** Stripe reports `paid`, or `no_payment_required` for a fully discounted order. An expired unpaid session retires the row to `EXPIRED`.
2. **Livemode matches our key.** A test-mode payment can never unlock a book against the live gateway, and the reverse is refused too.
3. **The metadata names this purchase and this book.** A session minted for a different purchase can't be replayed onto this one.
4. **The pre-tax subtotal and the currency match our own price.** We compare the subtotal, not the total, because tax varies by the buyer's address and a promotion code only moves the total. A discount can't be used to confirm at a forged price.

Only then does the status flip, and the flip is guarded so it happens once:

```
const flipped = await prisma.bookPurchase.updateMany({
  where: { id: purchase.id, status: PURCHASE_STATUS.PENDING },
  data: {
    status: PURCHASE_STATUS.PAID,
    paidAt: new Date(),
    checkoutKey: null,
    stripePaymentIntentId: session.paymentIntentId,
  },
});
if (flipped.count === 0) {
  // A double return, or unlock racing the redirect, got here first.
  return { state: CHECKOUT_RETURN_STATE.ALREADY_PAID, firstConfirmation: false };
}
```

Whichever caller wins the flip is the first confirmation. It unlocks the book, releases the held pages to render, and records the purchase event. Every other caller gets `ALREADY_PAID` and does nothing, which is why a conversion can't be counted twice.

## How does one Stripe customer per buyer survive a race?

Each buyer gets one Stripe customer, created on their first checkout and stored on their user row. Every later session attaches to it, so a repeat buyer has one receipt trail and one place to refund from.

The race is two first checkouts at the same moment. Both see no stored customer and both ask Stripe to create one. The create call carries an idempotency key built from the user id, so Stripe returns the same customer to both requests. Then a guarded update claims it:

```
const claimed = await prisma.user.updateMany({
  where: { id: userId, stripeCustomerId: null },
  data: { stripeCustomerId: customer.id },
});
```

The loser's customer id is the winner's, so nothing is orphaned. Session creation is idempotency-keyed per purchase too, and the gateway retries a 429, a 5xx or a network failure up to 3 attempts with backoff and a 20-second timeout. So a replay is safe.

## How do we stop a buyer paying twice for the same book?

Two guards stop a double charge, and we needed both. The first is session reuse. Before minting a session, `findReusableSession` looks for this buyer's own `PENDING` purchase of the same book, tier and quantity, asks Stripe whether that session is still `open` and unpaid, and hands back its URL instead of a new one. A double click or a second tab lands on the same checkout.

Reuse alone had a gap, found in our September payment review. It only matches rows whose session id is already stored, and the id is stored after the round trip to Stripe. Two truly simultaneous requests both saw nothing to reuse and both minted a payable session.

The second guard closes that gap in the database itself, where two writers can't both win. Each `PENDING` row carries a `checkoutKey` with a unique index:

```
const checkoutKey = `${bookId}:${tier.id}:${quantity}:${buyerUserId}`;
purchase = await prisma.bookPurchase.create({
  data: { /* book, workspace, buyer, tier, price */ status: PURCHASE_STATUS.PENDING, checkoutKey },
});
```

The second insert fails on the unique constraint. The loser then retries the reuse lookup 6 times, 300 ms apart, to join the winner's session. If the winner still hasn't stored its session id, the API answers a retryable `409 checkout_in_progress`. The key is set to null when a purchase is paid, expires or fails, so a later reorder isn't blocked.

The key includes the buyer, so two members of one workspace check out independently. Confirmation stays workspace-wide: any member's paid session unlocks the shared book.

## How should a client handle the 409?

The client has to retry the 409 and must never treat it as unlocked. In the studio, the unlock button runs a small shared helper that retries `checkout_in_progress` up to 4 times, 800 ms apart, until the API returns the checkout URL the winner minted. A different 409 on the book path means another member already unlocked it, which the button treats as success. Getting this wrong in either direction is costly: treat the 409 as success and a buyer sees an unlocked book that isn't; treat it as a failure and they press again and race a second time.

## What catches a buyer who pays and never comes back?

A reconciliation sweep on the worker's maintenance queue catches them. The return page and the unlock self-heal both need the buyer to come back, so a closed tab or a delayed payment method would otherwise leave money captured and the book never delivered.

Every 10 minutes the sweep takes up to 100 `PENDING` purchases older than 10 minutes, oldest first, and runs the same `confirmPurchase` on each. Because the flip is guarded, a sweep racing a late return is a no-op. A few details keep it working at scale:

- **Poison rows are retired.** A session that fails verification is moved to `FAILED` at once. A row that stays unpaid for 144 sweeps (about 24 hours) is retired too, so dead rows can't fill every batch.
- **It stops early.** A 5-minute time budget, a stop after 5 consecutive Stripe failures, and a shutdown check keep one run from overlapping the next.
- **It reports the backlog.** Each run counts the live `PENDING` backlog and the age of the oldest row, and a non-zero count of rows that failed verification logs a warning.

## How are refunds and disputes reconciled without a webhook?

A second worker job handles them, once an hour. It lists refunds and disputes created in the last 7 days from the Stripe API. For each succeeded refund, a guarded update flips the matching purchase from `PURCHASE_STATUS.PAID` to `PURCHASE_STATUS.REFUNDED` and re-locks the book, unless another paid order still covers it. Re-processing the same week of refunds every hour costs nothing, because the update only matches rows that are still paid.

Disputes are never revoked automatically, since a dispute can be won. Each one is logged as a warning for a person to answer before its deadline.

## What does pull-based confirmation cost us?

It costs latency, Stripe read traffic, and a few edges a webhook would handle better. We think it's the right trade at our size, but here it is plainly:

|  | Pull-based (what we run) | With webhooks |
| --- | --- | --- |
| Buyer returns normally | Confirmed on return, typically within a few seconds of polling | Same, or slightly sooner |
| Buyer pays and never returns | Confirmed by the sweep, usually within about 20 minutes | Seconds |
| Refund issued in the dashboard | Book re-locked within about an hour | Seconds |
| Dispute opened | Flagged within about an hour | Seconds |
| Stripe reads | A poll per return, plus sweep re-checks of abandoned sessions | Mostly pushed to you |
| Public attack surface | None for payments | A signed endpoint to secure |
| Code paths that mark a purchase paid | One | One, if the handler reuses the same function |

The polling cost is real but bounded. The return page polls at most 4 times, with gaps growing from 2.5 to 7 seconds. A session expires unpaid after 1 hour, so an abandoned checkout is re-verified only a handful of times before it's retired to `EXPIRED`. The refund job reads one page of up to 100 refunds, which covers our volume today and will need pagination if that stops being true.

If we ever need faster physical fulfilment, a webhook handler would call `confirmPurchase` again, and nothing else would change. The pricing it verifies against lives in one contract, the same one behind our [pricing page](https://www.oncebooks.com/pricing). What the buyer is paying for, a book made from an Avatar in four steps, is laid out on [how it works](https://www.oncebooks.com/how-it-works), and the hardest engineering behind it, keeping one child recognisable on every page, is in [the four locks behind a consistent AI picture book](https://www.oncebooks.com/blog/how-the-consistency-engine-works).

## Questions parents ask

**Can you use Stripe Checkout without webhooks?**

Yes, if you store the Checkout Session id before redirecting the buyer and re-verify that session against the Stripe API whenever you need to know whether it was paid. Once Books confirms on the buyer's return, on their next unlock click, and in a scheduled sweep every 10 minutes, all through one idempotent confirmation function.

**How do you confirm a Stripe Checkout payment without a webhook?**

Fetch the session by the id you stored, never by an id the browser sends. Check that it's paid, that its livemode matches your key, that its metadata names this purchase and this book, and that the pre-tax subtotal and currency match your own price. Then flip the purchase from pending to paid with a guarded update so a second confirmation is a no-op.

**What happens if a buyer pays and closes the tab before the success page?**

At Once Books the purchase is still confirmed. If the buyer comes back, the unlock button re-checks their open sessions with Stripe first. If they never come back, a worker job re-verifies every pending purchase older than 10 minutes on a 10-minute cycle and confirms the paid ones.

**How do you handle Stripe refunds and disputes without webhooks?**

With a second scheduled job. Once an hour it lists the last 7 days of refunds and disputes from the Stripe API. A succeeded refund marks the purchase refunded and re-locks the book; a dispute is flagged for a person to answer before its deadline, and never revoked automatically.

## Related reading

- [What actually makes a maze hard (we measured it)](https://www.oncebooks.com/blog/what-makes-a-maze-hard) — Size is not difficulty. We graded a maze generator easy to evil, measured every page, and found one of our three difficulty signals ran backwards.
- [How we make sure every sudoku has one solution](https://www.oncebooks.com/blog/how-we-guarantee-one-solution-sudoku) — How our sudoku generator works: fill a grid, remove clues in pairs, and re-solve after every removal so each board has one answer. Real code and timings.
- [The cast and the sets: the art that holds a book together](https://www.oncebooks.com/blog/the-cast-and-the-sets) — The hero’s face is the famous consistency problem. The friend, the teacher and every classroom are the harder one. Here’s the reference art behind it.

## Keep reading

- [Answers](https://www.oncebooks.com/answers) — One sharply-scoped question per page, cost, safety, timing, printing, answered in a couple of sentences before the detail.
- [Glossary](https://www.oncebooks.com/glossary) — Plain definitions of the vocabulary: the Character Sheet, the Consistency Engine, style profiles, drift.
- [Help centre](https://www.oncebooks.com/help) — Task-shaped instructions for making, printing and sharing a book once you have started one.

[← All posts](https://www.oncebooks.com/blog)
