Stack
React Stripe Boilerplate
The pricing table is the easy half. Stripe will send you the same event twice, out of order, and the handler that copes with that is the code you actually own.
Rendering a pricing table and redirecting to Stripe Checkout is usually simpler than maintaining the webhook handler. Webhook delivery introduces ordering, duplication, and retry behavior that the integration has to handle explicitly.
When you evaluate a react stripe boilerplate, review its webhook handler as well as the pricing UI. This page explains the handler, the reasoning behind it, and two deliberate trade-offs.
A common failure in event-driven integrations
The natural way to write the handler is to treat each event as an instruction. customer.subscription.updated arrives,
so patch the row. invoice.paid arrives, so flip the flag. Every event is a delta, and the handler applies deltas.
This approach becomes difficult when delivery does not match event order. Stripe delivers over HTTP, so events can arrive out of order, arrive twice, and be retried after your database has moved on. A handler built from deltas must account for those delivery patterns. If it does not, one customer can end up on the wrong plan without a clear application error.
Converge on current Stripe state
The billing pack does not read the event body for anything except the customer ID. It allow-lists the events it cares about, then re-reads that one customer's state from Stripe and overwrites the cache.
// libs/stripe/webhook.ts
export async function processStripeEvent(event: Stripe.Event): Promise<void> {
if (!isAllowedEvent(event.type)) return;
const { customer: customerId } = event.data.object as { customer: string };
if (typeof customerId !== "string") {
throw new Error(`[STRIPE HOOK] Customer ID is not a string. Event type: ${event.type}`);
}
await syncStripeDataToKV(customerId);
}syncStripeDataToKV calls stripe.subscriptions.list({ customer, limit: 1, status: "all" }) and writes the result to
one key. Replays converge instead of corrupting: the tenth delivery of a stale event produces the same state as the
first, because the state comes from Stripe, not from the event.
What happens when an event lands
The event arrives
Signature verified, then the type is checked against the allow-list. Anything not on it is ignored rather than half-handled.
The payload is discarded
Exactly one field is read out of it, the customer ID. Nothing else in the body is trusted, because the body might be old.
State is re-read from Stripe
One subscription lookup for that customer, asking what is true right now instead of what changed.
One key is overwritten
The answer replaces whatever was cached. Order and duplicates stop mattering, because nothing is being accumulated.
Replays converge
A delta-based handler has to account for delivery order and duplicates. Re-reading current state makes repeated deliveries converge.
Two consequences worth knowing before you adopt it. There is no processed-event ledger, so deduplication comes from
convergence rather than from remembering event IDs. The handler also throws on failure instead of returning 200. The
error middleware reports the failure and Stripe retries, so a temporary Valkey outage does not silently drop the update.
“Webhook handlers need to account for retries, duplicate delivery, and events that arrive out of order.”
18 events
allow-listed by name in ALLOWED_EVENTS, covering the checkout, subscription, invoice and payment_intent families. Anything else is ignored rather than half-handled
1 read
every event triggers exactly one subscription lookup for that customer, so ordering and duplicates stop mattering
0 tables
no subscription rows to migrate or backfill: Stripe stays the source of truth and Valkey holds the cache
Where subscription state lives
The pack stores no subscription rows in Postgres. The three storage tiers are:
Three tiers, and one of them is empty
Stripe. Subscription status, price, period bounds, and the card on file are read from their API, never mirrored into a schema of ours.
Three Valkey keys per organization: stripe:org:{orgId} stores the customer ID, stripe:customer:{customerId} stores subscription state, and stripe:receipts:{orgId} stores the receipt preference. The admin dashboard has a separate five-minute stats cache.
Nothing. No subscription table, no migration when Stripe adds a field, and no reconciliation job to write, monitor, and eventually discover has been failing.
The keys are namespaced by organization or Stripe customer. Subscription state is not copied into Postgres.
The upside is that there is no Postgres copy of subscription state to reconcile. The trade is that plan gating trusts
the cache completely: getSubscriptionStatus does a plain Valkey read with no live Stripe call, so a cache miss reads
as the free plan. Two paths keep it warm: the webhook and an eager sync on GET /checkout/success after the redirect.
There is no admin re-sync route in the pack, so if you flush Valkey in production, paying customers read as free until
their next webhook arrives. An admin re-sync endpoint can use the existing syncSubscription helper and is a useful
addition if you operate Valkey as a disposable cache.
Read that helper before you deploy if you run Valkey as a disposable cache with no persistence. Fail-to-free is the more conservative billing default, but it may not fit a product whose free tier cannot serve paying customers. Choose the fallback behavior based on your access and availability requirements.
Lifecycle cases that are already wired
| Case | What the code does |
|---|---|
| First paid plan | Stripe Checkout in mode: "subscription" |
| Paid to paid change | stripe.subscriptions.update in place, no redirect. An upgrade invoices the prorated difference now and only applies once that invoice clears |
| Downgrade to free, or cancel | Hands off to the Stripe Billing Portal so Stripe owns the cancel semantics |
| Admin revoking access | stripe.subscriptions.cancel, which ends it immediately rather than at period end |
| Successful invoice | Optional receipt on invoice.payment_succeeded, per organization opt-in, sent through Resend, whose account and bill are yours rather than ours |
| Revenue reporting | Admin widget computes MRR from active subscriptions, amortizing annual prices by twelve, plus a thirty day paid-invoice trend cached for five minutes |
The code delegates lifecycle choices to Stripe where the Billing Portal already supports them. Cancellation is the clearest case. Decisions such as immediate versus period-end cancellation remain in Stripe's maintained interface, so the application does not implement a separate cancellation flow.
What this pack does not do
No usage metering, no credit balances, and no one-time payments: checkout is subscriptions only. No dunning workflow
either, a failed payment refreshes the cache and Stripe's own retry emails take it from there. Invoice history is
queried live from Stripe rather than mirrored locally, and /billing is gated behind manage:billing, which only the
organization admin role holds.
A reusable webhook pattern
Every file mentioned above lands in your repository as source. Open libs/stripe/webhook.ts and
routes/checkout/index.ts to decide whether the implementation fits your billing model. If pricing requirements become
more complex, these are among the files you will modify.
The pattern also applies outside Stripe. When consuming events from a system you do not control, you can accumulate deltas or converge on a current snapshot. Accumulating can use fewer reads, but it requires explicit handling for ordering, duplication, and partial failures. Converging adds a read in exchange for simpler replay behavior.
Convergence is a practical default when the extra read is acceptable. For high-volume systems where that read becomes material, a delta-based approach may be worth the additional state-management work.
Below is the stack running, and what installs into your repository, so you can read the handler before deciding whether you trust it.
See it running
Templates are curated project starters built on this stack: a layout, feature packs, a custom theme, and bonus pages. The previews below are recordings of the real apps.
Two ways to install features
Same features underneath, different starting point. Either command resolves what the packs depend on, copies the source into your repository, and merges the Prisma schema.
Take a template
A landing page, a design system, a themed layout, and the features already wired into it. Rebrand it, put your product in the middle, ship.
npx @hype-stack/cli template$ hype-stack compose
Compose your own design
Your design and your choices, without rebuilding auth, billing, or notifications. Tick the packs you want and the CLI wires them into the open-source starter.
npx @hype-stack/cli composeInside the stack
What each pack gives you
Source code, not a dependency. Every pack lands in your repository across the surfaces the feature touches.
Authentication, organizations, roles, sessions, and a full admin app, powered by WorkOS.
- Email & social login
- Organizations & members
- Roles & permissions
- Admin app & dashboard
Questions, answered
More stacks
Turn your ideas into
Real applications.
Start free and own every line you ship. When you want more, one All-Access license unlocks every premium pack and template for a year.