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.
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.
SaaS Starter (WorkOS) and Billing (Stripe): checkout and portal wiring on the Hono backend, a signature-verified webhook route covering 18 allow-listed events, plan and billing screens on the React 19 frontend, and a revenue widget in the admin app.
Free to start. Works in the AI tool you already use.
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.
ts
// libs/stripe/webhook.tsexportasyncfunctionprocessStripeEvent(event:Stripe.Event):Promise<void>{if(!isAllowedEvent(event.type))return;const{customer:customerId}=event.data.objectas{customer:string};if(typeofcustomerId!=="string"){thrownewError(`[STRIPE HOOK] Customer ID is not a string. Event type: ${event.type}`);}awaitsyncStripeDataToKV(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
01
The event arrives
Signature verified, then the type is checked against the allow-list. Anything not on it is ignored rather than half-handled.
02
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.
03
State is re-read from Stripe
One subscription lookup for that customer, asking what is true right now instead of what changed.
04
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
Source of truth
Stripe. Subscription status, price, period bounds, and the card on file are read from their API, never mirrored into a schema of ours.
Stripe
Cache
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.
Valkey3 keys per org
Your database
Nothing. No subscription table, no migration when Stripe adds a field, and no reconciliation job to write, monitor, and eventually discover has been failing.
0 tables
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.