Stripe Checkout returns success. The money moved. Job done, right?
That is the moment the actual product logic begins. Which plan does this user own right now? Which features may they use? What happens when the card fails next month, when they cancel mid-period, or when a webhook shows up nine minutes late?
I built billing for LaizyNote myself — Stripe for payments, invoices and retries, Supabase Auth for identity, Edge Functions for the event handling, and Postgres for the state the app actually reads, including the access rules. Here is the part that took the longest, and it wasn't the Checkout.
Update (October 2026): LaizyNote moved from Firebase to Supabase in September 2026. This post describes the current architecture.
A payment is not an entitlement
LaizyNote has two plans — Free and Plus — plus monthly and yearly billing, coupons, referral bonuses, time-limited access grants and separately purchased AI credits. Behind a simple pricing table sits a state machine.
The rule that kept me out of trouble: the application never trusts a button, a redirect or a URL parameter. A user can abandon Checkout, open it twice, or already own a subscription. A webhook can arrive late. A payment can fail after the first successful activation.
The stored plan has to be derived from verified Stripe data, and only from that.
One function decides, and it lives in the database
The mistake I see most often in SaaS codebases: five different modules each interpreting subscription.status slightly differently. The pricing screen says Plus, some other code path reads the raw tier column and says Free.
So there is exactly one function that answers "which plan is in force" — and it is a Postgres function, so every query and every access rule sees the same answer, whether the call comes from the app, an Edge Function or a scheduled job:
-- Simplified.
create function app.effective_tier(p_user_id uuid) returns text
language sql stable security definer
as $$
with base as (
select
case
-- past_due and unpaid stay entitled on purpose: Stripe is still retrying.
when coalesce(trim(stripe_subscription_id), '') <> ''
and status not in ('active', 'trialing', 'past_due', 'unpaid')
then 'free'
else app.normalize_tier(tier)
end as tier,
bonus_access_tier, bonus_access_expires_at
from public.subscriptions
where user_id = p_user_id
)
select coalesce((
select case
-- A bonus lifts the plan, never lowers it - and expires by timestamp.
when bonus_access_expires_at > now()
and app.tier_order(bonus_access_tier) > app.tier_order(tier)
then app.normalize_tier(bonus_access_tier)
else tier
end from base
), 'free');
$$;
Three decisions worth stealing:
past_due and unpaid stay entitled. Stripe is still retrying the card. Yanking features the moment a payment method expires punishes your most forgiving customers for a bank's timeout. The payment problem gets flagged in the UI instead.
Bonus access is a separate layer that expires by timestamp. No job has to write "bonus over" into a row. The function compares against now(). Nothing to forget, nothing to backfill.
A bonus lifts, never lowers. One comparison that prevents handing a paying Plus customer a promotional grant and quietly downgrading them.
Stripe is the source of truth. It is not your database.
Webhook processing translates Stripe objects into a compact row in subscriptions: plan, billing cycle, current period, cancel-at-period-end, failed-payment flags.
That projection matters. Without it, every page navigation would hit the Stripe API — latency and rate limits included. With it, the plan loads alongside the rest of the account data, and the underlying Stripe subscription and price stay traceable.
The webhook Edge Function verifies the signature first, then doesn't trust the single event: it re-reads all of the customer's subscriptions from Stripe. If more than one turns out to be entitled, it raises an integrity alert instead of silently picking one.
| Event | What it does |
|---|---|
checkout.session.completed |
maps the purchase to the user account, activates the plan |
customer.subscription.created / .updated / .deleted
|
reconciles sign-ups, plan changes and cancellations |
invoice.paid |
records a successful payment |
invoice.payment_failed |
flags the problem for the UI |
One migration detail worth mentioning: older subscriptions still carry the former Firebase user ID in their Stripe metadata. A separate customer-mapping table plus a fallback lookup keeps them assigned correctly — no customer had to resubscribe.
The gate lives in Postgres, not in the frontend
The app hides features that don't belong to your plan and stops you creating content past a Free limit. Good for comprehension. Worthless as protection — anyone can call the API directly.
So the gate sits in the database. Every table has Row Level Security, and the insert policy checks the plan quota in the same breath as ownership:
create policy "notes: insert within quota"
on public.notes for insert to authenticated
with check (
case
when workspace_id is not null then
app.is_workspace_member(workspace_id)
and app.within_team_content_limit(workspace_id, 'notes')
else
owner_id = (select app.current_user_id())
and app.within_personal_content_limit('notes')
end
);
Limits live in their own table; usage counters are maintained by triggers inside the same transaction. That made the old nightly "recount and fix drift" audit unnecessary.
Where an action is more than a row, the Edge Function checks the effective plan: automatic backups need Plus, automation rules are capped at three on Free, and AI usage is booked by a single database function that checks the monthly quota and deducts credits atomically — two concurrent requests can't spend the same balance.
For team workspaces, entitlement follows the workspace owner, read through a dedicated security-definer function. If every invited member inherited the workspace plan, a Free user collaborating in someone else's Plus team would suddenly have Plus features in their own private space.
Webhooks are not enough
Webhooks are the fastest way to learn about a Stripe change. They are not a consistency guarantee. Events get delayed, retried, or delivered twice.
Two things follow from that:
Processing must be idempotent. Every event is recorded by ID in a stripe_webhook_events table before it is processed. A repeat is either already done (answer 200 immediately) or still in progress (answer 409 so Stripe retries later); a stuck run releases its lock after 15 minutes. Separately purchased AI credits additionally use the Stripe Session ID as a unique booking key.
Something has to reconcile. A pg_cron job calls a reconcile Edge Function every night. It replays the same logic as the webhook against Stripe and downgrades subscriptions that no longer exist there. A second job handles expired cancellations and bonus grants. Webhooks give you speed; the scheduled pass gives you correctness.
Would I use WooCommerce instead? Sometimes.
I have also built subscription billing on WooCommerce Subscriptions, for a standalone calculation tool with a WordPress backend. It was the right call there.
| Stripe + Supabase | WooCommerce Subscriptions | |
|---|---|---|
| Fits | a standalone app with its own user accounts | a product where WordPress is already the platform |
| User management | already in place (Supabase Auth) | a second user world to keep in sync |
| Subscription logic | you build it: webhooks, plan logic in SQL, reconciliation | largely covered by the plugin |
| Edge cases (bonus, limits, teams) | free to model, directly in access rules | bound to the plugin data model |
| Upfront effort | high | low |
| Ongoing maintenance | your own code, no plugin updates | plugin, theme and WordPress updates |
LaizyNote is the product. Vue and Supabase already provide the app, the accounts and the data model. Bolting on WordPress purely for billing would have created a second user and data domain to reconcile forever.
Key Takeaways
- Derive entitlement, never store it as the truth. One function, one decision point — ideally in the database, so nothing can bypass it.
-
Don't cut off access on the first failed payment.
past_dueandunpaidmean Stripe is still working on it. - Model temporary grants as their own layer with an expiry timestamp — and make sure they can only lift a plan, never lower it.
- Assume every webhook will be delivered twice and one will be missed. Event dedup plus a scheduled reconciliation job.
- The hard part isn't Checkout. It's the transitions — paid to overdue, active to cancelled, Plus back to Free without deleting anyone's data.
Full technical write-up on hafenpixel.de. If you want the wider story of building this thing solo, I wrote about 16 months of LaizyNote too. Questions welcome in the comments.
Top comments (0)