How to Implement Subscription Billing in Your SaaS Product
For most SaaS products, the right way to implement subscription billing is to use a hosted billing provider such as Stripe Billing for the money movement, and build a thin layer of your own that listens to billing events and decides what each customer is allowed to do. Your database stores an entitlement per account (which plan, what status, what limits), the billing provider stays the source of truth for payments, and webhooks keep the two in sync.
The happy path takes days. The cases that take weeks are the ones that happen to real customers: a card fails, someone upgrades mid-cycle, a trial ends, a webhook arrives twice, an account is cancelled and comes back. This guide walks through a design that handles them from the start. It is the billing counterpart to our SaaS cost breakdown, where billing is one of the most underestimated line items.
Decide what you are building and what you are buying
Billing has three layers, and you should only build the last one.
- Payment processing. Cards, wallets, bank debits, fraud checks. Never build this.
- Subscription management. Plans, prices, invoices, proration, retries, tax, receipts, a customer self-service page. Buy this from a billing provider.
- Entitlements and product logic. What a customer on the Growth plan can actually do in your product. This is yours.
The main choice at layer 2 is between a payments platform with a billing product (Stripe Billing is the common one) and a merchant of record such as Paddle or Lemon Squeezy. With a merchant of record, the provider is the legal seller and handles sales tax and VAT on your behalf, which removes a lot of compliance work in exchange for less control and a different fee structure. With Stripe you stay the seller and can add tax calculation through Stripe Tax, but registration and filing responsibilities remain yours. Pick based on where you sell and how much tax overhead you want to carry. The rest of this guide uses Stripe terms, but the architecture applies to any provider.
Model your plans before you write code
Write down the pricing model in plain language first, because it decides the data model.
- Flat plans. Starter, Growth, Scale at fixed monthly prices. Simplest to build.
- Per-seat pricing. Price scales with team members. You must keep the seat count in sync with billing.
- Usage-based pricing. Price scales with consumption (API calls, documents processed, AI tokens). You need reliable metering. If the usage involves language models, model the unit cost first; see how to reduce LLM API costs.
- Hybrid. A base fee plus included usage plus overage. Most mature SaaS products end up here.
Then decide the rules customers will hit: Is there a free trial, and does it require a card? What happens at the end of a trial? Do plan changes take effect immediately or at renewal? Do you refund anything on cancellation? Settling these on paper takes an hour and prevents rewrites.
Attach billing to the account, not the user
In a B2B product the thing that pays is the tenant (the company or workspace), not an individual user. Store the billing customer ID on your tenant record, and carry your tenant ID into the billing provider as metadata so every event can be mapped back. This matters most if you are building on a shared-schema database; the tenancy decisions are covered in multi-tenant SaaS architecture and which model to choose.
The core flow
A reliable implementation has five pieces.
1. Checkout. Send the customer to a hosted checkout page rather than building your own payment form. It handles card collection, authentication challenges and many local payment methods, and keeps card data off your servers. When you create the checkout session, set your tenant ID in the subscription metadata so you can identify the account later.
2. Webhooks as the signal. Do not mark an account as paid because the browser returned to your success page. The billing provider tells your server what actually happened through webhooks. Treat them as the source of truth for subscription state.
3. An entitlements table. One row per tenant: current plan, subscription status, the date access runs until, and any limits. Your application checks this table, never the billing provider, when deciding whether to allow a feature. It keeps your product fast and independent of an external API.
4. A customer portal. Let customers update their card, download invoices, change plans and cancel without contacting you. Stripe provides a hosted customer portal; using it removes an entire category of support tickets and screens you would otherwise build.
5. Reconciliation. A scheduled job that compares your entitlements with the provider's subscription list and fixes drift. You will not need it often, but when a webhook is missed it is the thing that saves you.
Handling webhooks correctly
Most billing bugs live here. Four rules cover most of them.
- Verify the signature using the raw request body. If your framework parses the JSON before you verify, the signature check fails or, worse, gets disabled "to make it work."
- Expect duplicates and out-of-order delivery. The provider retries deliveries, and events are not guaranteed to arrive in order. Store each processed event ID with a unique constraint and ignore repeats.
- Respond quickly, process asynchronously. Return a success response immediately and do the real work in a background job. A slow handler causes timeouts, retries and duplicate load.
- Sync from the object, not from the event type. Instead of writing logic like "on payment failed, set status to past_due," fetch or read the current subscription and write its actual state. That makes your handlers idempotent and resilient to missed or reordered events.
A minimal Express handler that follows these rules:
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }), // raw body, required for signature check
async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"] as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return res.status(400).send("Invalid signature");
}
// Unique constraint on event.id: returns false if we have seen it before
const isNew = await recordEventId(event.id);
if (!isNew) return res.sendStatus(200);
await jobQueue.add("stripe-event", { eventId: event.id });
res.sendStatus(200);
}
);
And the worker that applies the state, written to be safe to run more than once:
async function syncSubscription(sub: Stripe.Subscription) {
const tenantId = sub.metadata.tenant_id; // set at checkout
const priceId = sub.items.data[0].price.id;
await db.entitlements.upsert({
tenantId,
plan: planForPrice(priceId), // your mapping of price IDs to plans
status: sub.status, // trialing, active, past_due, canceled, unpaid...
});
}
recordEventId, jobQueue, db and planForPrice stand in for your own code. The principle is what matters: verify, deduplicate, enqueue, and write state from the subscription itself.
The events worth handling at minimum are checkout completion, subscription created, updated and deleted, invoice paid, and invoice payment failed.
The edge cases that take the time
Failed payments. Cards expire and get declined constantly. Stripe can retry failed payments automatically, and its Smart Retries feature picks retry times using machine learning; you configure how many retries and over what period. Pair that with emails and an in-app banner. Then decide your policy for the grace period: do customers keep full access while a payment is past_due, get read-only access, or lose access after a set number of days? Write the rule down and encode it in your entitlement check, not scattered through the app.
Upgrades and downgrades. Changing plans mid-cycle triggers proration: the provider credits unused time and charges the difference. Choose whether to prorate immediately, at renewal, or not at all, and be consistent. Downgrades need extra care: if a customer on a 10-seat plan downgrades to a 3-seat plan, what happens to the other seven users?
Trials. Decide card-up-front or not. Card-up-front trials convert at a higher rate but start fewer; no-card trials start more but convert less. Either way, handle the trial-ending event with an email, and define what a trial-expired account can still see.
Cancellation and reactivation. Cancel at period end is the friendly default. Keep the customer's data for a defined retention period so reactivation works, and make sure a returning customer maps to the same tenant rather than creating a duplicate.
Tax and invoices. Rates and obligations depend on where your customers are. Either use a merchant of record or enable automated tax calculation, and collect the billing details your invoices need (company name, address, tax ID for B2B customers) at checkout.
Usage metering. Record usage events in your own database first, then report aggregates to the provider. If the provider has an outage or a report fails, your own data lets you correct it. Show customers their current usage in the app so an invoice is never a surprise.
Test billing like a feature, not an afterthought
Billing code that only runs on the happy path will fail in production. Use the provider's test mode and its tools for simulating time. Stripe's test clocks let you advance a test customer through trials, renewals and failures in minutes instead of waiting weeks. Build a small suite that covers:
- Sign-up, trial start and trial end with and without a payment method
- A successful renewal
- A failed renewal followed by a successful retry
- A failed renewal that exhausts retries
- An upgrade, a downgrade and a cancellation
- The same webhook delivered twice
- Two webhooks delivered in the wrong order
If you only automate two of these, make them the duplicate webhook and the failed-payment path.
Common mistakes
- Trusting the redirect. Granting access because the customer reached your success page instead of waiting for the webhook.
- Calling the billing API on every request. It is slow and creates a hard dependency. Read entitlements from your own database.
- Tying billing to users instead of accounts. It breaks the moment a second person joins the workspace.
- No grace-period policy. Support ends up deciding case by case, and customers notice.
- Hand-rolled proration or invoicing. Money logic you write yourself will have edge-case bugs. Use the provider's.
- Skipping reconciliation. Missed webhooks are rare, but the one time it happens a paying customer loses access or a cancelled one keeps it.
Bottom line
Buy subscription management, build entitlements, and keep them in sync with verified, idempotent webhooks. Decide your plans, trials and grace periods on paper before you code, attach billing to the tenant, and test the failure paths with simulated time. Done this way, billing is a few weeks of focused work that rarely needs attention again. Done ad hoc, it becomes the part of the product that quietly leaks revenue.
If you are planning a SaaS product and want help designing billing, entitlements and the surrounding architecture, RMJDG's team can review your pricing model and scope the implementation with you.
Services
Not sure where to start? Tell me what you want the product to do.
Related work

Teamlex AI: an AI SEO platform
An AI SEO platform for understanding search intent, analyzing competitors, and creating optimized content.

Parent AI Stories: personalized bedtime stories
A mobile product that helps parents create personalized bedtime stories for children in minutes.