DEV Community

Cover image for Your Stripe Webhook Is Not Your Entitlement System
Joshua
Joshua

Posted on Originally published at hafenpixel.de

Your Stripe Webhook Is Not Your Entitlement System

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 tax, Firebase Authentication for identity, Cloud Functions for the event handling, Firestore for the state the app actually reads. Here is the part that took the longest, and it wasn't the Checkout.


A payment is not an entitlement

LaizyNote has three plans — Free, Solo 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 only one

The mistake I see most often in SaaS codebases: five different modules each interpreting subscription.status slightly differently. The pricing screen says Plus, the backup job reads the raw tier field and says Free.

So there is exactly one function that answers "which plan is in force":

// Stripe statuses that keep the paid plan in force.
// past_due and unpaid stay in deliberately: Stripe is still retrying,
// so access should not drop on the first failed payment.
const ENTITLED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
const TIER_ORDER = { free: 0, solo: 1, plus: 2 }

// Base tier: the stored plan, but only if the Stripe status supports it.
function getBaseTier(sub) {
  const stored = normalizeTier(sub?.tier)
  const hasStripeSub = typeof sub?.stripeSubscriptionId === 'string'
    && sub.stripeSubscriptionId.trim().length > 0

  return hasStripeSub && !ENTITLED_STATUSES.has(sub?.status)
    ? 'free'
    : stored
}

// Bonus access sits on top as its own layer and expires purely by time —
// nobody has to touch the document for that.
function getEffectiveTier(sub, now = new Date()) {
  const baseTier = getBaseTier(sub)
  if (!isBonusAccessActive(sub, now)) return baseTier

  const bonusTier = normalizeTier(sub?.bonusAccessTier)
  // A bonus lifts the plan, but never lowers it.
  return TIER_ORDER[bonusTier] > TIER_ORDER[baseTier] ? bonusTier : baseTier
}
Enter fullscreen mode Exit fullscreen mode

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 cron job has to write "bonus over" into a document. The function compares against now. Nothing to forget, nothing to backfill.

A bonus lifts, never lowers. TIER_ORDER[bonusTier] > TIER_ORDER[baseTier] — one comparison that prevents handing a paying Plus customer a "free Solo month" and quietly downgrading them.


Stripe is the source of truth. It is not your database.

Webhook processing translates Stripe objects into a compact Firestore document: 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.

Events LaizyNote handles:

Event What it does
checkout.session.completed maps the purchase to the Firebase user, activates the plan
customer.subscription.updated / .deleted reconciles plan changes and cancellations
invoice.paid records a successful payment
invoice.payment_failed flags the problem for the UI

The frontend is UX, not a security boundary

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 valuable operations check again, on the server:

  • manual backups require Solo
  • automatic backups and automation rules require Plus
  • for team workspaces, entitlement follows the workspace owner, not whichever member happens to be calling

That last one is easy to get wrong. If every invited member inherits the workspace plan, a Free user collaborating in someone else's Plus team suddenly has Plus features in their own private space.

For quantity limits there are usage counters, plus a nightly audit that recounts the actual contacts, notes, tasks, projects and workspaces and corrects drift. Keeps the write path fast without recounting every document on every action.


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. An already-processed purchase must not trigger a second plan change or duplicate credits. Separately purchased AI credits store their Stripe Session ID as a unique processing key.

Something has to reconcile. A scheduled Cloud Function walks every stored Stripe subscription each night and compares it against Stripe. A second job handles expired cancellations and bonus grants, then the limit audit runs. 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 + Cloud Functions WooCommerce Subscriptions
Fits a standalone app with its own user accounts a product where WordPress is already the platform
User management already in place a second user world to keep in sync
Subscription logic you build it largely covered by the plugin
Edge cases (bonus, limits, teams) free to model 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 Firebase 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

  1. Derive entitlement, never store it as the truth. One function, one decision point. Everything else reads from it.
  2. Don't cut off access on the first failed payment. past_due and unpaid mean Stripe is still working on it.
  3. Model temporary grants as their own layer with an expiry timestamp — and make sure they can only lift a plan, never lower it.
  4. Assume every webhook will be delivered twice and one will be missed. Idempotency keys plus a scheduled reconciliation job.
  5. 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)