Skip to content
Building a Robust Multi-Tenant Subscription System with Node.js and Stripe
SaaS Development & Product Building

Building a Robust Multi-Tenant Subscription System with Node.js and Stripe

9 min read
Node.jsStripeSaaSMulti-tenancySubscriptions

Learn to architect and implement a scalable multi-tenant subscription platform using Node.js and Stripe. This guide tackles complex billing logic, ensuring secure tenant isolation and automated revenue management for your SaaS.

Introduction & The Problem

When building a Software as a Service (SaaS) application, managing subscriptions across multiple tenants presents a unique set of challenges. Beyond simply accepting payments, a robust subscription system must handle tenant isolation, plan upgrades/downgrades, trial periods, cancellations, prorations, and tax calculations, all while maintaining high availability and data integrity. Incorrect billing logic can lead to significant revenue loss, customer dissatisfaction, and legal complications. Many startups initially hardcode simple payment flows, but this quickly becomes a maintenance nightmare as the business scales and pricing models evolve. The core problem is to create a flexible, secure, and automated billing infrastructure that allows your SaaS to grow without continuous, costly re-engineering.

The Solution Concept & Architecture

Our solution leverages a Node.js backend for its non-blocking I/O model, making it ideal for high-throughput API services, and integrates with Stripe, the industry-leading payment processing platform. Stripe handles the complexities of payment gateways, PCI compliance, and subscription lifecycle management, allowing us to focus on our core business logic. For multi-tenancy, we'll employ a shared database schema approach, where each data record includes a tenantId to ensure logical data separation. This offers a good balance between cost-efficiency and operational simplicity for many growing SaaS applications.

Architectural Components:

  • Node.js & Express.js: Our API layer, handling incoming requests from the frontend and orchestrating interactions with Stripe and the database.
  • PostgreSQL (or similar relational DB): Stores tenant information, product/plan details, and local subscription states, linking to Stripe's customer and subscription IDs.
  • Stripe API: Manages customers, products, prices, subscriptions, invoices, and webhooks for all payment-related operations.
  • Stripe Webhooks: Critical for receiving real-time updates from Stripe about subscription status changes, successful payments, failed payments, etc., enabling our backend to react autonomously.

The flow typically involves a user on a specific tenant signing up or upgrading, triggering an API call to our Node.js backend. The backend interacts with Stripe to create a customer and a subscription, then records the relevant Stripe IDs in our local database. Stripe webhooks then keep our database synchronized with the actual state of subscriptions on Stripe.

Step-by-Step Implementation

1. Project Setup & Dependencies

First, initialize your Node.js project and install necessary dependencies:

# Initialize project
mkdir saas-billing-service
cd saas-billing-service
npm init -y

# Install dependencies
npm install express stripe pg dotenv

Create a .env file for your Stripe API keys:


STRIPE_SECRET_KEY=sk_test_YOUR_STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_STRIPE_WEBHOOK_SECRET
DATABASE_URL=postgres://user:password@host:port/database

2. Database Schema (Simplified)

We'll need tables for tenants, products, plans (prices), and subscriptions. The tenant_id column is crucial for multi-tenancy.

-- tenants table
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    stripe_customer_id VARCHAR(255) UNIQUE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- products table (e.g., 'Pro Plan', 'Enterprise Plan')
CREATE TABLE products (
    id VARCHAR(255) PRIMARY KEY, -- Stripe Product ID
    name VARCHAR(255) NOT NULL,
    description TEXT
);

-- plans table (e.g., 'Pro Monthly', 'Pro Yearly')
CREATE TABLE plans (
    id VARCHAR(255) PRIMARY KEY, -- Stripe Price ID
    product_id VARCHAR(255) REFERENCES products(id),
    nickname VARCHAR(255) NOT NULL,
    interval VARCHAR(50), -- 'month', 'year'
    amount INTEGER, -- in cents
    currency VARCHAR(3)
);

-- subscriptions table
CREATE TABLE subscriptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID REFERENCES tenants(id),
    stripe_subscription_id VARCHAR(255) UNIQUE NOT NULL,
    stripe_price_id VARCHAR(255) REFERENCES plans(id),
    current_period_start TIMESTAMP WITH TIME ZONE,
    current_period_end TIMESTAMP WITH TIME ZONE,
    status VARCHAR(50) NOT NULL, -- 'active', 'canceled', 'past_due', etc.
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

3. Core Server Setup (server.js)


require('dotenv').config();
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { Pool } = require('pg');

const app = express();
const port = process.env.PORT || 3000;

// Database Pool Setup
const pool = new Pool({
    connectionString: process.env.DATABASE_URL,
});

// Middleware to parse JSON for non-webhook routes
app.use((req, res, next) => {
    if (req.originalUrl === '/webhook') {
        next(); // Skip JSON parsing for webhooks
    } else {
        express.json()(req, res, next);
    }
});

// Example: Create a new tenant and Stripe customer
app.post('/api/tenant', async (req, res) => {
    const { name, email } = req.body;
    try {
        // 1. Create Stripe Customer
        const customer = await stripe.customers.create({
            email: email,
            name: name,
        });

        // 2. Insert tenant into our DB
        const result = await pool.query(
            'INSERT INTO tenants (name, email, stripe_customer_id) VALUES ($1, $2, $3) RETURNING *',
            [name, email, customer.id]
        );
        res.status(201).json(result.rows[0]);
    } catch (error) {
        console.error('Error creating tenant:', error.message);
        res.status(500).json({ error: 'Failed to create tenant' });
    }
});

// Example: Subscribe a tenant to a plan
app.post('/api/subscribe', async (req, res) => {
    const { tenantId, priceId } = req.body; // priceId comes from Stripe Price object

    try {
        // 1. Get tenant's Stripe Customer ID
        const tenantResult = await pool.query(
            'SELECT stripe_customer_id FROM tenants WHERE id = $1',
            [tenantId]
        );
        if (tenantResult.rows.length === 0) {
            return res.status(404).json({ error: 'Tenant not found' });
        }
        const stripeCustomerId = tenantResult.rows[0].stripe_customer_id;

        // 2. Create Stripe Subscription
        const subscription = await stripe.subscriptions.create({
            customer: stripeCustomerId,
            items: [{
                price: priceId,
            }],
            payment_behavior: 'default_incomplete',
            expand: ['latest_invoice.payment_intent'],
        });

        // 3. Update our DB with subscription details
        await pool.query(
            `INSERT INTO subscriptions (
                tenant_id, stripe_subscription_id, stripe_price_id, 
                current_period_start, current_period_end, status
            ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (stripe_subscription_id) 
              DO UPDATE SET 
                stripe_price_id = EXCLUDED.stripe_price_id, 
                current_period_start = EXCLUDED.current_period_start, 
                current_period_end = EXCLUDED.current_period_end, 
                status = EXCLUDED.status, updated_at = NOW()
            `,
            [
                tenantId,
                subscription.id,
                priceId,
                new Date(subscription.current_period_start * 1000),
                new Date(subscription.current_period_end * 1000),
                subscription.status,
            ]
        );

        // Respond with client_secret if a payment is required (e.g., initial setup)
        const clientSecret = subscription.latest_invoice.payment_intent ? 
                             subscription.latest_invoice.payment_intent.client_secret : null;
        res.json({ subscriptionId: subscription.id, clientSecret: clientSecret, status: subscription.status });

    } catch (error) {
        console.error('Error subscribing tenant:', error.message);
        res.status(500).json({ error: 'Failed to create subscription' });
    }
});

// Start the server
app.listen(port, () => {
    console.log(`SaaS billing service listening at http://localhost:${port}`);
});

4. Stripe Webhook Handler (server.js - continued)

This is crucial for keeping your database in sync with Stripe's subscription state.

// ... (previous code)

app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

    try {
        event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
    } catch (err) {
        console.error(`Webhook Error: ${err.message}`);
        return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Handle the event
    switch (event.type) {
        case 'customer.subscription.updated':
        case 'customer.subscription.created':
        case 'customer.subscription.deleted':
            const subscription = event.data.object;
            console.log(`Subscription ${subscription.id} ${subscription.status}`);
            try {
                // Find the tenant associated with this Stripe customer
                const tenantResult = await pool.query(
                    'SELECT id FROM tenants WHERE stripe_customer_id = $1',
                    [subscription.customer]
                );
                if (tenantResult.rows.length === 0) {
                    console.warn(`Tenant not found for Stripe Customer ID: ${subscription.customer}`);
                    break; // Or handle as an error
                }
                const tenantId = tenantResult.rows[0].id;

                await pool.query(
                    `INSERT INTO subscriptions (
                        tenant_id, stripe_subscription_id, stripe_price_id, 
                        current_period_start, current_period_end, status
                    ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (stripe_subscription_id) 
                      DO UPDATE SET 
                        stripe_price_id = EXCLUDED.stripe_price_id, 
                        current_period_start = EXCLUDED.current_period_start, 
                        current_period_end = EXCLUDED.current_period_end, 
                        status = EXCLUDED.status, updated_at = NOW()
                    `,
                    [
                        tenantId,
                        subscription.id,
                        subscription.items.data[0].price.id, // Assuming single price per subscription
                        new Date(subscription.current_period_start * 1000),
                        new Date(subscription.current_period_end * 1000),
                        subscription.status,
                    ]
                );
                console.log(`Subscription ${subscription.id} status updated in DB to ${subscription.status}`);
            } catch (dbError) {
                console.error(`Database error processing subscription webhook for ${subscription.id}: ${dbError.message}`);
            }
            break;
        case 'invoice.payment_succeeded':
            const invoice = event.data.object;
            console.log(`Invoice ${invoice.id} paid successfully.`);
            // You might want to update usage-based billing records or trigger other actions here
            break;
        case 'invoice.payment_failed':
            const failedInvoice = event.data.object;
            console.warn(`Invoice ${failedInvoice.id} failed payment.`);
            // Notify tenant, retry payment, or downgrade subscription
            break;
        // ... handle other event types
        default:
            console.log(`Unhandled event type ${event.type}`);
    }

    res.json({ received: true });
});

// ... (rest of the server setup)

Optimization & Best Practices

  1. Webhook Security: Always verify Stripe webhook signatures. This prevents malicious actors from sending fake events to your endpoint. Stripe provides a helper function stripe.webhooks.constructEvent for this.
  2. Idempotency: Use idempotency keys when making API requests to Stripe that modify resources (e.g., creating a customer, a subscription). This ensures that if the same request is sent multiple times due to network issues, it's processed only once.
  3. Robust Error Handling: Implement comprehensive try-catch blocks and logging for all Stripe API calls and database operations. Network failures or unexpected Stripe responses must be handled gracefully.
  4. Asynchronous Processing: For complex webhook logic, consider offloading processing to a message queue (e.g., RabbitMQ, Kafka) or a background job system (e.g., BullMQ for Node.js). This keeps your webhook endpoint responsive and prevents timeouts.
  5. Tenant-Aware Context: For every request, ensure that the tenantId is properly authenticated and authorized. All database queries must include a WHERE tenant_id = current_tenant_id clause to prevent data leakage between tenants.
  6. Testing: Thoroughly test your integration in Stripe's test mode. Use the Stripe CLI to simulate webhook events, including successes, failures, and edge cases.
  7. Graceful Cancellations: When a user cancels, don't immediately delete their data. Mark the subscription as canceled_at_period_end on Stripe, and update your database to reflect the subscription remaining active until the end of the current billing cycle.

Business Impact & ROI

Implementing a well-designed multi-tenant subscription system with Node.js and Stripe offers significant business advantages and a high return on investment:
  • Accelerated Time-to-Market: By leveraging Stripe's robust API, development teams can quickly integrate complex billing features, reducing the time and cost associated with building payment infrastructure from scratch.
  • Predictable Revenue Streams: Automated recurring billing ensures consistent cash flow, making financial forecasting more accurate and reliable for CEOs and business owners.
  • Reduced Operational Overhead: Stripe handles PCI compliance, fraud detection, and invoice generation, significantly reducing the administrative burden and legal risks for your team.
  • Enhanced Customer Experience: Seamless subscription management, easy upgrades/downgrades, and clear billing statements lead to higher customer satisfaction and reduced churn.
  • Scalability & Flexibility: The architecture supports an increasing number of tenants and diverse pricing models (tiered, usage-based) without requiring major refactoring, ensuring the system can evolve with your business needs.
  • Data-Driven Insights: Centralized subscription data allows for better analytics on customer lifetime value, churn rates, and plan popularity, informing product and marketing strategies.
This investment translates directly into sustained growth, reduced operational costs, and a more agile response to market demands.

Conclusion

Building a multi-tenant subscription system is a foundational component for any successful SaaS business. By combining the power of Node.js for backend logic with Stripe's comprehensive payment platform, developers can create a highly scalable, secure, and automated billing solution. This architecture not only streamlines development but also provides significant business value through predictable revenue, reduced operational complexity, and the flexibility to adapt to evolving market demands. Mastering this integration is a critical step towards engineering a resilient and profitable SaaS product.
Muhammad Tahir logo

Muhammad Tahir

Building web & mobile apps since 2021. Passionate about clean code and real-world impact.