1. Introduction: Why SaaS Billing Architecture Matters
Every early-stage SaaS builder eventually discovers that billing is not an isolated module—it’s a structural decision that affects your product, your onboarding, your upgrade flows, customer support, and even your growth experiments. Poor planning at this stage leads to expensive rewrites, migration headaches, broken customer experiences, and entire feature sets that you can't easily change later.
Technical founders often jump straight into “just integrate Stripe Checkout” or “just use a hosted billing portal,” but the moment you introduce upgrade/downgrade logic, usage limits, freemium tiers, trials, or addons, you discover the real truth: billing interacts with almost every layer of your platform.
A clean billing architecture ensures several things:
- You can change the pricing model without rewriting your app.
- You can introduce new plans or merge existing ones with minimal database churn.
- Your customer’s entitlement logic (what they can do) stays predictable and testable.
- You can build admin tools that show clear subscription states.
- You can safely scale to more customers, more products, and more usage dimensions.
Early-stage startups often move fast and bolt on features as they go. But billing is one area where early design pays lasting dividends. A well-structured plan + limits + Stripe integration allows you to iterate fast while keeping your billing system dependable and compliant. It also ensures you can introduce things like:
- Addons
- Overages
- Usage-based pricing
- Annual plans
- Grace periods
- Data retention rules
- Platform-level feature toggles
Because all of it sits on a foundation that makes sense.
This article sets out to give you a practical blueprint for structuring your plans, modeling usage limits, and keeping Stripe perfectly in sync—without letting Stripe dictate your product’s logic. Everything is explained from the perspective of teams building new SaaS platforms with real constraints: shipping fast, building correctly, and avoiding future rework.
2. Core Concepts You Must Understand First
Before touching Stripe’s dashboard or designing your database tables, you need a clear mental model of how SaaS billing and entitlement systems actually work. The biggest mistake early founders make is mixing up Stripe’s constructs with their own product’s logic.
I've written a more in-depth article on this topic if you'd like to read more about it. But basically...
Stripe has Products, Prices, and Subscriptions. Your SaaS has Plans, Features, and Usage Limits.
These are not interchangeable.
To build a durable system, you have to understand the role of each component:
Product (Stripe)
A category of thing you’re selling. For most SaaS apps, this is simply “YourApp Subscription.” You typically need one–three products total. Products define nothing about your features.
Price (Stripe)
A specific billing configuration inside a product. Examples:
- $24/month
- $69/month
- $690/year
- $10/month per addon
Prices tell Stripe how to charge. They do not tell your app what the customer can do.
Subscription (Stripe)
The billing contract between a customer (your organization) and your service. It contains:
- the active price
- billing cycle
- trial status
- period dates
- prorations
- payment state
Again: this is only billing metadata.
Why You Never Want Feature Access Stored in External Systems
Many early-stage developers assume they can offload plan capabilities to Stripe, Clerk, Supabase Auth, or another external service. You absolutely do not want to do this. The reason is simple:
Your entitlement logic must be internal, reliable, and immediately queryable.
Here’s why storing feature access anywhere external becomes a serious liability:
1. External systems are not designed for feature entitlements
Stripe can tell you which price a subscription is on. It cannot tell you:
- how many users are allowed
- whether sentiment scoring is enabled
- how many keywords are included
- how often to poll LinkedIn
- what data retention rules apply
- whether CSV export is unlocked
- how many addons to merge into limits
SaaS plans change often. Stripe products do not keep up with your product experiments.
2. Relying on external systems makes your app brittle
If your entitlement data lives in Stripe:
- You must call Stripe's API on every request (or build a caching layer)
- Any webhook failure introduces inconsistent state.
- Cold starts become slower.
- Stripe outages become your outages. (however unlikely)
- You can’t run local/offline QA with correct entitlement logic.
Your core product should never depend on a third-party billing uptime guarantee.
3. External auth providers shouldn’t know your business rules
Some dev teams store plan features in Clerk metadata, Auth0 app metadata, Firebase custom claims, etc. This becomes immediately painful because:
- You lose transactional updates (DB + Clerk must stay in sync).
- Race conditions happen during upgrades.
- Multi-addon logic becomes almost impossible to maintain.
- You cannot easily compute derived limits (e.g., base limit + addon boosts).
- You lock yourself into the identity provider’s data model.
Your identity provider should authenticate.
Your app should authorize based on internal state.
4. You need auditability and versioning
Feature flags evolve. Plans evolve. Addons get introduced or removed. You need:
- migrations
- audit logs
- historic period logic
- reproducible plan definitions
- reliable backups
None of these should depend on Stripe or any external system.
5. Your engineering team needs a single source of truth
Developers should always be able to answer:
“What can this organization do right now?”
In one DB query.
Not by:
- Calling Stripe
- Waiting for webhooks
- Combining metadata from three external providers
- Recomputing state at request time
When your SaaS hits even 20–50 customers, externalizing this logic becomes an operational burden.
The Clean Mental Model for Every SaaS Billing System
To keep your architecture sane, separate concerns:
Stripe handles:
- Money
- Invoices
- Trials (optional)
- Renewals
- Proration
- Cancellations
- Payment statuses
Your database handles:
- Plan configuration
- Usage limits
- Feature flags
- Entitlements
- Addon quantities
- Actual enforcement
- Access control
- Upgrade/downgrade logic
- Event triggers inside your system
This division ensures you can modify your SaaS product logic without touching Stripe, and modify billing logic without rewriting your feature code.
It gives you iteration speed, reliability, and full control—everything an early-stage SaaS team needs.
3. How to Model Plans in Your Database
Most early-stage SaaS products start with a handful of plans—usually something like Free, Basic, Pro, and maybe an Enterprise tier. Over time, those plans evolve. Limits increase, features change, addons appear, and sometimes entire pricing models shift. If you don’t store plan definitions in your own database, you lose all ability to iterate safely.
I'm going to use the set up I have for SnitchFeed to illustrate throughout this article. Bear in mind, this structure might evolve over time, but I have found this to be a solid way of doing things.
Your subscriptionPlans table is the internal contract for what each plan allows. It should be explicit, readable, and versionable. Stripe shouldn’t define your plan capabilities—your database should.
A clean plan table typically includes:
- A stable local ID
- A human-readable name
- A Stripe price or price IDs
- Usage limits
- Feature flags
- Interval information
- Metadata like creation date and status
Below is a simplified conceptual structure (derived from your actual schema but formatted for clarity):
subscriptionPlans
- id (PK)
- name text
- description text
- price int
- stripePriceId text
- interval varchar -- e.g., "month", "year"
- keywordLimit int
- usersLimit int
- listenersLimit int
- linkedinInterval int -- polling interval (minutes)
- twitterInterval int
- matchesLimit int
- data_retention_months int
- platforms jsonb -- ["linkedin", "twitter", "reddit", ...]
- ai_tagging bool
- relevance_scoring bool
- sentiment_scoring bool
- csv_exports bool
- bookmarks bool
- active bool -- soft-flag to hide deprecated plans
- createdDate timestamp
If someone asks: “What does the Basic plan allow?”, your app should answer by querying a single row in this table.
Why this structure works:
- You can add new limits/features without touching Stripe.
- Downgrades and upgrades become simple: update planId → recalc limits.
- Your UI can respond to plan capabilities directly from your DB.
- Feature flags live where you can control them, version them, migrate them.
- Entitlements stay internal and deterministic.
Avoid storing plan definitions in environment variables or config files—they don’t scale, they don’t update cleanly, and they’re not queryable in real time.
If you want feature flags per organization, you can store overrides in your organization table and make a simple adjustment to your entitlements logic.
The database is the authoritative definition of every plan your app supports.
4. How to Model Subscriptions
A subscription represents an active relationship between an organization and your SaaS product. This is where your internal view of the customer must remain tightly synced with Stripe’s billing state—but never dependent on it.
In a multi-tenant SaaS, subscriptions belong to organizations, not users. A single organization may have multiple user accounts, but should always have one canonical subscription record.
A clean internal subscription record includes:
subscriptions
- id (PK)
- organizationId int -- FK
- stripeSubscriptionId text
- planId int -- FK to subscriptionPlans
- status enum -- "active", "trialing", "past_due", etc.
- currentPeriodStartsDate timestamp
- currentPeriodEndsDate timestamp
- next_billing_date timestamp
- canceledDate timestamp -- if applicable
- createdDate timestamp
- lastUpdatedDate timestamp
These fields mirror Stripe’s subscription metadata, but the key is: your app always uses your database record for logic, not Stripe’s API response.
Stripe webhooks should update this record, but your app should never block on Stripe or call Stripe every time it needs to authorize something. Your database row = the subscription state you trust.
You basically never need to call Stripe API except for creating checkouts or updating subscriptions.
Why replicate Stripe dates locally?
- You need to know when to reset usage counters (e.g., monthly keyword usage).
- You need to lock accounts post-expiry even if a webhook fails.
- You often need to show billing state inside your dashboard.
- Your background workers need stable timestamps to operate on.
Stripe subscriptions contain vital period information, but you want that information local, structured, and queryable at high speed.
Common mistakes early founders make:
- Not storing period timestamps
- Trying to re-query Stripe on every upgrade
- Not storing canceledDate or next_billing_date
- Allowing the frontend to compute entitlement logic client-side
- Assuming Stripe webhook order is always consistent
Good architecture avoids these pitfalls entirely by keeping a clean, self-contained subscription table.
5. How to Handle Addons Cleanly
Addons give your pricing model flexibility without exploding your number of plans. Many SaaS products start with simple tiered plans, but inevitably you reach the point where users want:
- Extra keywords
- More users
- Higher API quotas
- Faster refresh intervals
- Additional integrations
- Premium features (e.g., API access)
If you force these into new plans, you quickly end up with a combinatorial mess: “Pro + 10 keywords” “Pro + 50 keywords” “Pro + Faster LinkedIn + 20 keywords” etc.
Instead, treat addon capabilities as first-class citizens in your billing architecture.
Your addons table provides static definitions:
addons
- id
- name
- description
- price
- stripePriceId
- type -- e.g., "keywords limit", "listeners limit", "platform refresh"
- interval -- e.g., "month"
- limit -- e.g., +10 keywords
- refreshPlatform -- e.g., "linkedin"
- refreshInterval -- e.g., override LinkedIn polling interval
Then you store the subscription-level addon activations separately:
addonSubscriptions
- id
- organizationId
- subscriptionId
- addonId
- quantity
- interval
- createdDate
- currentPeriodEndsDate
- lastUpdatedDate
- canceledDate
Why this structure works:
- You can compute effective limits by adding plan limits + addon limits.
- You can allow stacking of addons (quantity > 1).
- You can enable or disable addons independently of plan changes.
- You can gate features behind addons (e.g., additional keywords, API access).
- You can introduce new addons without altering plan definitions.
Addons allow you to expand ARPU without fracturing your pricing model.
Stripe-side mapping:
Each addon is typically a separate Product, with one or more Prices. You do not create more Stripe products for every tier of an addon. The quantity on the subscription controls the customer’s units.
Example:
A user buys:
- Pro Plan → 75 base keywords
- Extra Keywords Addon x2 → +20
Your effective limit calculation:
75 + (2 * 10) = 95 keywords
This is computed internally, not from Stripe.
6. Stripe-Side Setup: Recommended Product/Price Structure
Stripe gives you an enormous amount of flexibility—but that flexibility can become a trap for early-stage teams. The cleanest Stripe setup is always the one that maps neatly to your internal data model while staying easy to maintain.
Your internal plan structure shouldn’t mirror Stripe 1:1. Stripe should only know what it needs to know: what to charge, how often, and for what. Everything else lives inside your database.
The Golden Rule
Stripe handles billing; your app handles entitlements. Do not encode product logic inside Stripe Products or Prices.
The Recommended Stripe Model for Early-Stage SaaS
1. One "Subscription" Product for your main SaaS
You only need a single Stripe Product for your core subscription, e.g.:
- Product: “SnitchFeed Subscription”
Then create multiple Prices, one per billing interval + plan tier. Example:
- Price: “Basic Monthly — $24/month”
- Price: “Pro Monthly — $69/month”
- Price: “Basic Annual — $240/year”
- Price: “Pro Annual — $690/year”
Every one of these prices should map to exactly one row in your subscriptionPlans table via stripePriceId.
2. One Product per Addon
If you have addons, create one Stripe Product per addon category:
-
Product: “Extra Keywords Addon”
- Price: $10/month
-
Product: “Faster LinkedIn Refresh”
- Price: $15/month
-
Product: “Additional User Seats”
- Price: $5/month per seat
Stripe's "quantity" field on a subscription item allows clean stacking. You never want four separate products that represent 10, 20, 50, 100 keyword addons. Just use one product and vary the quantity.
What Not To Do
Many founders do one of these (all are bad patterns):
- Create a Stripe Product for each plan (I have been, and am guilty of doing this)
- Encode feature flags inside Stripe metadata
- Use multiple Prices to represent derived plan capabilities
- Create dozens of separate Stripe products for every limit level
- Try to compute entitlements from Stripe’s API response
These all break down once you introduce combos of addons, custom plans, or enterprise overrides.
Keep Stripe simple, predictable, and dedicated to billing. Let your database—not Stripe—define your SaaS.
7. Syncing Your DB with Stripe: Webhooks + Pull Sync
The moment you accept your first subscription, your backend’s job is to stay in perfect sync with Stripe without relying on Stripe for real-time authorization. The model to use here is Webhook-First, Pull-Second.
Webhook-First: The Primary Sync Mechanism
These Stripe events must be handled:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedcheckout.session.completed
Every one of these events should lead to an update in your local subscriptions (and addonSubscriptions) tables.
A typical webhook flow updates:
- status
- stripeSubscriptionId
- planId
- currentPeriodStartsDate
- currentPeriodEndsDate
- next_billing_date
- canceledDate
- addon quantities
After updating, always set:
lastUpdatedDate = new Date()
This ensures auditability and allows admins to debug subscription issues quickly.
Why You Also Need a Nightly Pull Sync
Even if your webhooks are perfect, Stripe event delivery is not guaranteed. Webhooks can:
- arrive in the wrong order
- fail to deliver
- retry late
- get dropped entirely
- be misconfigured during deploys
- conflict with local migrations
A nightly (or hourly) “pull sync” job eliminates all of this. Use it to:
- Fetch the latest subscription state from Stripe
- Confirm your DB matches reality
- Correct any missing updates
- Resync addon quantities
- Validate billing periods
This job is crucial once you hit dozens of customers. For early-stage SaaS teams, it prevents billing drift and customer complaints.
8. How to Enforce Usage Limits
Usage limits must be enforced inside your application—not in Stripe and not in your frontend. You own the logic, and it must come from your plan row + addon rows + your own computations.
Internal Enforcement, Not External Restriction
Every API call, process, or UI-level check should retrieve a single authoritative entitlement object. Something like:
const effectiveLimits = await getEffectiveLimits(organizationId)
This function computes:
- limits from the active plan
- plus/minus limits from addon subscriptions
- any enterprise overrides
- any experimental flags (e.g., beta features)
This produces the single source of truth for what an organization can do.
Why Stripe Cannot Enforce Limits
Stripe:
-
can bill for units
-
cannot determine how your product works
-
does not know your user model
-
does not know what a “keyword,” “match,” or “refresh interval” is
-
cannot calculate cross-plan logic like:
- “Basic: 15 keywords + Addon x2 = 35 keywords”
- “Pro includes sentiment analysis unless turned off by admin override”
- “Enterprise plans ignore Twitter limits”
Usage enforcement belongs in your backend, not in Stripe, Clerk, or your frontend.
Types of Limits You Typically Enforce
Most SaaS products enforce combinations of these:
- Count-based limits e.g. number of keywords, number of users, number of monitored URLs
- Rate-based limits e.g. polling intervals, API request speed, refresh frequency
- Retention limits e.g. retain 3 months of data, or 12, or unlimited
- Feature toggles e.g. AI tagging enabled/disabled, CSV export allowed/denied
- Concurrency limits e.g. number of running listeners, number of simultaneous jobs
Your database must house all of these. Stripe should know nothing about them.
Example of Effective Limit Calculation
Take this scenario:
- Plan: Basic (15 LinkedIn keywords)
- Addon: Extra Keywords x2 (each adds +10)
Result:
const keywordLimit = effectiveLimit.keywords = 15 + (2 * 10) = 35
Your backend should return this in a reliable structure—something your frontend, background workers, and admin UI can all depend on.
9. Mapping Stripe Subscription Statuses to Your App
Stripe’s subscription statuses don’t directly translate into your SaaS’s entitlement states. Stripe is concerned with billing outcomes; your platform is concerned with access. These two concerns overlap but aren’t identical. If you try to treat Stripe’s status as your access model, you’ll end up locking out happy customers, extending access accidentally, or breaking your grace periods.
The key is to define a mapping layer that interprets Stripe status into product behavior.
Stripe Status → Your App Meaning → Action
| Stripe Status | Meaning (Stripe-side) | Meaning (App-side) | What Your App Should Do |
|---|---|---|---|
| active | Payment is up to date | Customer is fully paid | Full access |
| trialing | Stripe trial in progress | Customer has temporary full access | Allow access but track trial end |
| past_due | Payment issue; grace period | Customer likely still expects access | Keep access but warn / escalate |
| unpaid | Payment failed repeatedly | Customer is now delinquent | Lock premium features, prompt payment |
| canceled | Subscription canceled | Access allowed until period end | Lock access after period end |
| incomplete / incomplete_expired | Checkout not completed | No subscription ever formed | Do not grant access |
Your internal subscriptions.status should match Stripe’s status, but your enforcement logic should operate on your own rules, such as:
- Always allow access until
currentPeriodEndsDate. - Give a 3–5 day grace period after “past_due.”
- Allow manual overrides for customer support.
Stripe cannot make these decisions for you.
Why Grace Periods Matter
If you immediately lock a customer out when Stripe says “past_due,” you will:
- create support tickets
- kill goodwill
- increase churn
- frustrate loyal customers who just had a card error
Most successful SaaS platforms maintain:
- full access until
currentPeriodEndsDate, - a short buffer after that (e.g., 48 hours),
- then a controlled downgrade or lock.
Your DB should store any grace period timestamps explicitly.
10. Correct Handling of Trials
Trials seem simple at first glance, but they introduce subtle complexity when paired with upgrades, cancellations, and add-ons. Don’t let Stripe’s “trial” option be your only trial system. You should always model trials internally in your DB so that you can:
- customize trial logic
- show countdown timers
- enable trial-specific onboarding flows
- handle “expired → reactivated with trial” cases
- test trials locally without Stripe involvement
Why Internal Trials Are Better
Stripe trials work for basic demo flows, but:
- Trialing users don’t always start paying immediately
- You may want extended trials for certain customers
- Some teams want trial extensions via admin controls
- Some products offer feature-limited trials
- Your product may need “soft” trials (e.g., limit-based) instead of time-based
Stripe’s trial settings are too rigid.
Instead, store trials inside your DB:
trial_start timestamp
trial_end timestamp
trial_plan int
converted bool
Your backend decides whether the user is in a trial state—not Stripe. Stripe only handles billing at the checkout moment.
Trial Flows You Must Handle
1. Trial → Paid
User upgrades before trial end.
- Set plan immediately.
- Update Stripe subscription.
- Continue period normally.
2. Trial → Expired → No Payment
User never pays.
- Lock access when
trial_endpasses. - Offer reactivation CTA.
3. Trial → Expired → Paid Later
User pays a week late.
- Start a fresh billing cycle.
- Unlock access immediately.
4. Trial → Upgrade (mid-trial)
User moves from Basic to Pro while trialing.
- Keep existing trial timeline.
- Update planId internally.
- Update Stripe subscription’s price.
The trial timeline should be tied to your DB, not Stripe.
11. Upgrade/Downgrade Logic
Upgrades and downgrades are where billing systems become fragile if not designed well. The key is consistency:
- Stripe handles proration
- Your DB handles feature changes
- Your app handles access changes
- Webhooks sync everything together
How to Handle Upgrades
Upgrades should happen immediately:
- Update Stripe subscription to the new price
- Store the new
planIdin your DB - Recalculate limits (plan + addons)
- Apply new features instantly
- Store the Stripe
current_period_startandcurrent_period_end
Stripe prorates automatically unless explicitly disabled.
Upgrades should be synchronous: user upgrades → instant access. Your internal state should reflect the upgrade even before the webhook arrives.
How to Handle Downgrades
Downgrades usually apply on the next billing cycle. Stripe supports this with subscription schedule changes, but you don’t have to use them.
The simple approach:
- User requests downgrade
- Update Stripe to change the price effective next cycle
- Do not change internal
planIdyet - Mark a DB flag like:
pending_downgrade_to_plan_id
When the next billing cycle starts (via webhook or pull sync):
- Set
planIdto the downgraded plan - Recalculate limits
- Remove features that no longer apply
- Remove any illegal addon quantities (e.g., Pro-only addons)
This avoids mid-cycle feature loss.
Cancellations
When a user cancels:
- Stripe sets
cancel_at_period_end = true - You store
canceledDate - Access remains until period end
- You lock access only after
currentPeriodEndsDate
Stripe shouldn’t decide your access cutoff—the DB does.
12. Common Mistakes New SaaS Developers Make
After helping and observing many early-stage teams, these patterns emerge repeatedly. Avoiding them will save months of rework.
1. Using Stripe as the source of truth for features
The #1 mistake. Stripe knows billing, not product logic.
2. Letting the frontend compute entitlements
Never trust the frontend for authorization. This leads to security issues and feature misuse.
3. Hardcoding plan logic in code instead of the DB
You will eventually need to:
- increase limits
- add new features
- create grandfathered plans
- roll out betas
Hardcoded plans make all of this painful.
4. Proliferating Stripe Products for every plan variation
You end up with:
- Basic
- Basic v2
- Basic 2023
- Basic 2024
- Pro 14-day trial
- Pro 30-day trial
- Pro with addon included
- Pro Black Friday bundle
Nightmare.
5. Not storing period start/end dates
Without these:
- You can’t reset usage
- You can’t expire access
- You can’t sync billing
- You can’t troubleshoot issues
6. Forgetting to sync addon quantities
Addons drift over time if not revalidated periodically.
7. No nightly pull sync job
Even perfect webhook handling needs reconciliation.
_(that said, I'm very guilty of this. Pick your battles, as they say...)
8. Failing to implement downgrade schedules
Instant downgrades break user expectations and lead to support complaints.
9. Ignoring grace periods
Locking users out immediately reduces revenue and increases churn.
10. Mixing identity provider metadata with billing data
This creates tight coupling, sync bugs, and brittle behavior.
I hope this guide was helpful! I definitely see myself coming back to it early next year.