How to Implement Stripe Subscription Billing in Multi-Tenant SaaS
Implement Stripe subscription billing in multi-tenant SaaS correctly: tenant-scoped customers, idempotent webhooks, plan gates, and the Customer Portal.
Stripe subscription billing looks straightforward until you add multi-tenancy. In a single-user app the mapping is one-to-one: one user, one Stripe Customer, one subscription. In a multi-tenant B2B SaaS product the billing relationship belongs to the organization, not the individual. Multiple users within the same tenant share one subscription. Getting this wrong at the data model level produces bugs that compound: duplicate Stripe Customers per organization, broken upgrade flows, Customer Portal sessions that surface one tenant's invoices to another, and webhook handlers that provision access to the wrong account.
The second layer of complexity is state synchronization. Stripe is the authoritative source of subscription state; your application database is a synchronized mirror. When they drift apart — because a webhook failed to deliver, a handler threw an unhandled exception, or a deployment was down at the moment an event fired — users land in the wrong access tier. Preventing this requires idempotent webhook handlers with event deduplication, not just a database write inside a try/catch.
This guide covers the correct data model for Stripe in multi-tenant SaaS, which Stripe-hosted features to use versus what to build yourself, the webhook events every B2B SaaS must handle reliably, and how to enforce billing tiers at the API and database layer. For the broader platform architecture this billing system lives inside, see the guide on multi-tenant SaaS architecture and tenant isolation. For usage-based or metered pricing — a different model — see how to implement usage-based billing in SaaS.
The Stripe Customer-Tenant Mapping Problem
The most consequential decision in a multi-tenant Stripe integration is where the Stripe Customer lives. The answer is one Stripe Customer per tenant, not per user. The Stripe Customer represents the billing relationship: the account that gets invoiced, whose card is charged, and whose subscription appears in the Customer Portal. In B2B SaaS that relationship belongs to the organization, not the employee who clicks Upgrade.
Store stripe_customer_id as a non-nullable column on your tenants table, not the users table. Create the Stripe Customer lazily on first checkout or eagerly at tenant provisioning — either works. The constraint is that all Stripe interactions for a given tenant use the same customer ID. If you create a new Stripe Customer each time a different user initiates checkout for the same tenant, you end up with multiple subscription records in Stripe for the same organization, broken invoice history in the Customer Portal, and webhook events arriving for multiple customer IDs that all claim to represent the same tenant.
- →On your tenants table, add: stripe_customer_id (text, unique), plan_tier (enum: free | starter | growth | enterprise), subscription_status (text), and subscription_id (text). These columns are your local cache of Stripe state and are updated exclusively by webhook handlers — never by direct application writes.
- →When creating the Stripe Customer, pass your internal tenant ID in metadata: { tenant_id: 'your-internal-id' }. This is how webhook handlers identify which tenant an event belongs to when checkout.session.completed or customer.subscription.updated arrives — the Stripe event payload carries customer ID, not your internal tenant ID.
- →Never store stripe_customer_id on the users table in a B2B SaaS product. If you are migrating from a per-user model, identify the canonical Stripe Customer per tenant (typically the earliest-created one), update all subscription references, archive duplicates in Stripe, and patch your database in a transaction.
Products, Prices, and Plan Tiers in Stripe
Stripe models your pricing structure across three objects. A Product represents a plan tier (Starter, Growth, Enterprise). A Price represents a specific billing configuration for that product: amount, currency, interval, and recurring versus one-time. A Subscription ties a Customer to a Price and generates invoices automatically on renewal.
- →Create one Product per plan tier. Create one Price per (currency, billing interval, amount) combination within that product. Monthly and annual billing on the same tier are two Prices on the same Product, not two Products.
- →Never edit an active Price. Stripe treats Prices as append-only once subscriptions exist on them. To change pricing, create a new Price object and migrate existing subscriptions to it with a proration preview. Editing an active Price silently changes invoice descriptions for all current subscribers.
- →Store the Stripe Price ID alongside your plan tier constants in application config. When a user selects a plan, pass the Price ID directly to Checkout or the subscriptions API. Your application should never recalculate amounts — Stripe charges the amount defined on the Price.
- →Sync plan_tier back to your tenant record on every subscription lifecycle event. The plan_tier column is what your API reads for feature gates. Keeping it accurate is the job of your webhook handlers, not a live Stripe API call on the request path.
Stripe Checkout or Custom Payment Integration?
Stripe Checkout is a Stripe-hosted payment page that handles PCI compliance, 3D Secure authentication, multiple payment methods, and the legal requirements for price and tax display. For most B2B SaaS products, Checkout is the right default. Build a custom integration with Stripe Elements only when you need usage-based proration calculated and displayed before payment, or a deeply embedded upgrade modal within your app UI. Checkout handles 90 percent of B2B SaaS billing requirements at a fraction of the engineering cost.
- →Create Checkout Sessions server-side with the Price ID and the existing Stripe Customer ID for the tenant (pass customer: stripe_customer_id — Stripe creates a new Customer if you omit this, which is exactly the duplicate-customer problem). Pass metadata: { tenant_id: 'your-internal-id' } so your webhook handler can identify the tenant.
- →Set a return_url (Stripe's newer API) or success_url and cancel_url. Do not provision access from the success redirect — provision it from the checkout.session.completed webhook event. Users can reach the success URL without completing payment; the webhook fires only on confirmed payment.
- →For seat-based billing, pass quantity dynamically at Checkout Session creation, set to the number of active seats being purchased. For trials, set subscription_data.trial_period_days at session creation rather than adding a trial to an existing subscription post-creation.
The Webhook Events Every Multi-Tenant SaaS Must Handle
Your webhook endpoint is how Stripe notifies your application that subscription state has changed. It must handle a specific set of events reliably, verify the Stripe signature on every incoming request, and be idempotent — processing the same event twice must produce the same database state as processing it once.
- →checkout.session.completed: the customer completed Checkout. Retrieve the full session object from Stripe using the session ID (never trust client-posted data). Extract tenant_id from session.metadata. Set subscription_status to active and update plan_tier on the matching tenant record.
- →customer.subscription.updated: fires on plan changes, trial conversions, quantity changes, cancellation scheduling, and status transitions. The payload includes previous_attributes. Update plan_tier, subscription_status, quantity, and cancel_at on the tenant record based on the new values.
- →invoice.paid: fires on successful initial payment and every renewal. Use it to restore access after a dunning cycle — a tenant whose status was past_due should return to full access when invoice.paid fires, not only when a subscription.updated arrives.
- →invoice.payment_failed: a payment attempt failed. Start a grace period — do not immediately revoke access. Stripe retries automatically; downgrade or suspend only when the subscription transitions to past_due or unpaid via a subsequent subscription.updated event.
- →customer.subscription.deleted: subscription fully cancelled. Set subscription_status to cancelled and plan_tier to your free tier on the matching tenant. Fires on both immediate cancellation and at the end of a scheduled cancellation period.
Building Idempotent Webhook Handlers That Do Not Lose Events
Stripe delivers webhooks with at-least-once semantics: the same event may arrive more than once if your endpoint did not return a 2xx response before the delivery timeout. Idempotent handlers are not a nice-to-have — they are required for a billing integration that does not create ghost subscriptions, double-provision access, or corrupt plan tiers.
- →Verify the Stripe webhook signature on every request: const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET). Use the raw request buffer before JSON parsing — use express.raw() or the equivalent. Verifying the signature prevents spoofed events from modifying subscription state.
- →Deduplicate by Stripe event ID. Maintain a stripe_webhook_events table. Before processing any event, check for a row with event.id. If found, return 200 immediately without reprocessing. Insert the event ID before your database writes, not after — a partial write followed by a retry is safer than a double-processed event.
- →Return 200 within 30 seconds. Stripe retries delivery if the endpoint does not respond in time. For slow processing, queue the work and return 200 immediately. The queue worker processes the event asynchronously and can take as long as needed.
- →After unplanned downtime, audit failed deliveries in the Stripe Dashboard and trigger resends for any event that affects subscription state. Pair this with a daily reconciliation job that reads subscription state from Stripe directly and corrects any drift in your tenant records.
Enforcing Billing Tiers at the API and Database Layer
Billing tier enforcement is where subscription state integrates with your application authorization system. The plan_tier column on the tenant table — updated by webhook handlers — is what your API reads for feature gates. Enforcement must happen at the API layer, not client-side. Your RBAC implementation and billing tier gates are separate systems that compose: RBAC controls what role a user holds within their tenant; the billing tier controls what capabilities that tenant has purchased.
- →At the service layer, check plan_tier before executing business logic. A request to create the 11th workspace when Starter allows 10 should be rejected with a 403 and a message naming the plan limit and the upgrade path. Read from your tenant record, not from Stripe — Stripe is not in the critical path of every API request.
- →Enforce at the database layer as a backstop. Postgres check constraints or CTEs can enforce hard limits at the data layer independently of application logic. This catches bugs where the service-layer check was bypassed and is especially useful for seat counts, workspace quotas, and per-tenant API call limits.
- →Handle the provisioning gap. Between checkout completion and first webhook delivery, a tenant's subscription_status may be pending. Decide explicitly how your application behaves in this window — typically: grant the higher tier for up to 60 seconds, then fall back to the prior tier if no webhook has arrived.
Using the Stripe Customer Portal for Self-Service Billing
The Stripe Customer Portal is a Stripe-hosted UI where tenants can upgrade, downgrade, cancel subscriptions, update payment methods, and download invoices. Building this yourself means handling every Stripe subscription mutation, currency formatting, invoice generation, and payment method vault management. The Customer Portal handles all of it and is PCI-compliant by default.
- →Generate portal sessions server-side: const session = await stripe.billingPortal.sessions.create({ customer: tenant.stripe_customer_id, return_url: 'https://yourapp.com/settings/billing' }). Return session.url to the client and redirect. Portal URLs are time-limited session URLs generated per-request — never expose a static URL.
- →Configure the Customer Portal in the Stripe Dashboard before enabling it: which plans users can switch between, whether cancellation is immediate or at period end, and whether to allow pause. Set the return_url to your billing settings page so tenants land back in your app.
- →Portal-driven plan changes trigger the same customer.subscription.updated events as API-driven changes. Your existing webhook handlers process them without additional code — the Portal is just another client of the Stripe API.
- →If your billing settings page must immediately reflect a portal change on return, fetch the subscription from Stripe on the return_url handler and update the tenant record directly. This is the one justified use of a synchronous Stripe read on a user-visible flow.
Frequently Asked Questions
How do you associate a Stripe customer with a tenant in multi-tenant SaaS?
Create one Stripe Customer per tenant and store stripe_customer_id on the tenants table. When any user within a tenant initiates checkout or a plan change, pass the existing customer ID to the Checkout Session or subscription API call. This keeps all billing history, invoices, and payment methods consolidated under one Customer record in Stripe, regardless of which user triggers the action.
What Stripe webhook events does a multi-tenant SaaS need to handle?
The minimum set is: checkout.session.completed (provision access after checkout), customer.subscription.updated (handle plan changes and all lifecycle transitions), invoice.paid (confirm renewal and restore access after dunning), invoice.payment_failed (start grace period), and customer.subscription.deleted (restrict access on cancellation). Each handler must be idempotent — the same event delivered twice should produce the same database state as delivered once.
How do you prevent billing state from drifting out of sync in multi-tenant SaaS?
Three practices keep Stripe and your database in sync. First, treat Stripe as authoritative and your database as a mirror — never write subscription state except through webhook handlers. Second, store Stripe event IDs and skip re-processing events already handled. Third, run a daily reconciliation job that fetches each active subscription from Stripe and compares it to your tenant record — any discrepancy triggers a corrective write and an alert. This catches the edge case where a webhook was delivered but the handler silently failed after returning 200.
How should you handle plan enforcement when Stripe webhooks fail to deliver?
Build two safety nets. First, a daily reconciliation job that reads subscription state from Stripe directly and corrects divergence in your database. Second, do not permanently revoke access based on the absence of a webhook — Stripe retries failed deliveries for up to 72 hours, and network conditions can delay delivery. Design your grace period model explicitly: decide how long a tenant retains access during a payment failure cycle, and enforce that decision in code rather than allowing undefined behavior to determine it.
Should each user have their own Stripe Customer in a B2B SaaS product?
No. In B2B SaaS, one Stripe Customer per tenant is correct. The billing relationship belongs to the organization that pays, not to individual employees. Per-user Customer records in a multi-tenant product produce duplicate subscriptions, broken Customer Portal history, and webhook handlers that cannot reliably identify which organization a Stripe event belongs to. Reserve per-user Stripe Customers for consumer or personal SaaS products where each individual user pays their own subscription.
How Belsoft Helps SaaS Teams Build Billing Right
Billing is among the highest-stakes infrastructure in a SaaS product. A bug that charges the wrong tenant, fails to revoke access after cancellation, or drops a webhook event during a deployment is a customer-trust problem before it is a technical one. Belsoft's SaaS and platform engineering service includes the complete billing layer: Stripe data model design, idempotent webhook infrastructure with retry and reconciliation logic, plan enforcement at the API and database layer, and Customer Portal configuration. We build billing as part of the complete multi-tenant architecture — not as an afterthought — so the plan tier integrates correctly with your RBAC system, your audit log, and your tenant provisioning flow from day one.
If you are inheriting a Stripe integration that shows signs of drift — wrong plan tiers after upgrades, duplicate Stripe Customers per tenant, webhook handlers that silently drop events — an audit and targeted migration is typically faster than a full rebuild. We have run this pattern on several SaaS products moving from early-stage to enterprise-ready. If you want to understand where your current billing integration stands and what it would take to make it production-grade, book a scoping call and we will walk through it with you.
“Stripe is the source of truth. Your database is a synchronized mirror. When they diverge, the customer notices before you do.”
Written by
Belal Hisham
Founder & Lead Engineer, Belsoft Solutions
More from the blog
Ready to partner?
Let's talk about your company.
30 minutes. No pitch. We talk through how you run today and where AI and automation would help.
Book a free audit
