/web-app-features

How to Add Subscription Management to Your Web App

Learn how to easily add subscription management to your web app with our step-by-step guide. Simplify billing and user control today!

Book a free  consultation
4.9
Clutch rating 🌟
600+
Happy partners
17+
Countries served
190+
Team members
Matt Graham, CEO of Rapid Developers

Book a call with an Expert

Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.

How to Add Subscription Management to Your Web App

Adding Subscription Management to Your Web App: A Developer's Guide

 

Introduction: Why Subscription Management Matters

 

Building a subscription system might seem straightforward at first glance—collect payments regularly, grant access to features—but the reality involves many moving parts that can quickly become complex. A well-designed subscription management system not only generates reliable revenue but also provides flexibility for your business model while delivering a seamless experience for users.

 

Core Components of a Subscription Management System

 

1. The Subscription Architecture

 

At its foundation, your subscription system needs these key elements:

 

  • A plans/tiers database structure to define what you're selling
  • A subscription tracking mechanism to manage user entitlements
  • A billing system for charging customers and handling payment methods
  • A customer portal where users can manage their subscriptions

 

Here's what a basic subscription data model might look like:

 

-- Plans table: Defines your product offerings
CREATE TABLE plans (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    price DECIMAL(10, 2) NOT NULL,
    billing_cycle ENUM('monthly', 'annual', 'quarterly') NOT NULL,
    features JSON, // Store feature flags as a JSON object for flexibility
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Subscriptions table: Tracks active subscriptions
CREATE TABLE subscriptions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    plan_id INT NOT NULL,
    status ENUM('active', 'canceled', 'past_due', 'trialing') NOT NULL,
    current_period_start TIMESTAMP NOT NULL,
    current_period_end TIMESTAMP NOT NULL,
    cancel_at_period_end BOOLEAN DEFAULT false,
    payment_provider_id VARCHAR(255), // External ID from your payment processor
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (plan_id) REFERENCES plans(id)
);

 

2. Choose the Right Payment Processor

 

Rather than building payment processing from scratch (which involves security compliance headaches), leverage an established payment processor. The top options include:

 

  • Stripe: Developer-friendly, extensive APIs, excellent documentation
  • Paddle: Handles VAT/sales tax complexities, good for global businesses
  • Chargebee: Robust subscription management with multiple payment gateway options
  • Recurly: Specialized in subscription analytics and reducing churn

 

I'll use Stripe in examples, as it's widely adopted and has an excellent developer experience.

 

Implementation Strategy: The Four Phases

 

Phase 1: Integrate with a Payment Processor

 

First, set up your Stripe account and configure products and prices:

 

// server.js - Node.js/Express example with Stripe
const express = require('express');
const stripe = require('stripe')('sk_test_your_key');
const app = express();

app.use(express.json());

// Create a Product in Stripe (typically done in admin panel or during setup)
async function createProduct() {
    const product = await stripe.products.create({
        name: 'Pro Plan',
        description: 'Access to all premium features',
    });
    
    // Create a Price for the Product
    const price = await stripe.prices.create({
        product: product.id,
        unit_amount: 1999, // $19.99
        currency: 'usd',
        recurring: {
            interval: 'month',
        },
    });
    
    return { product, price };
}

// Route to initiate subscription checkout
app.post('/create-checkout-session', async (req, res) => {
    const { priceId, userId } = req.body;
    
    const session = await stripe.checkout.sessions.create({
        payment_method_types: ['card'],
        line_items: [{
            price: priceId,
            quantity: 1,
        }],
        mode: 'subscription',
        success_url: `${YOUR_DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${YOUR_DOMAIN}/canceled`,
        client_reference_id: userId, // Store your user ID for reference
    });
    
    res.json({ url: session.url });
});

 

Phase 2: Set Up Webhook Handling

 

Webhooks are essential for subscription management as they allow your application to react to external events from your payment processor:

 

// webhook-handler.js
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
    const sig = req.headers['stripe-signature'];
    const endpointSecret = 'whsec_your_signing_secret';
    let event;
    
    try {
        event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
        return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    
    // Handle specific events
    switch (event.type) {
        case 'customer.subscription.created':
            const subscription = event.data.object;
            await activateSubscription(subscription);
            break;
        case 'customer.subscription.updated':
            // Handle changes to subscription
            break;
        case 'customer.subscription.deleted':
            // Handle cancellation
            break;
        case 'invoice.payment_failed':
            // Handle failed payment
            break;
    }
    
    res.status(200).send();
});

// Activate a user's subscription in your database
async function activateSubscription(stripeSubscription) {
    const userId = await getUserIdFromStripeCustomerId(stripeSubscription.customer);
    const planId = await getPlanIdFromStripePriceId(stripeSubscription.items.data[0].price.id);
    
    // Update your database
    await db.query(`
        INSERT INTO subscriptions (
            user_id, plan_id, status, 
            current_period_start, current_period_end,
            payment_provider_id
        ) VALUES (?, ?, ?, ?, ?, ?)
        ON DUPLICATE KEY UPDATE
            status = VALUES(status),
            current_period_start = VALUES(current_period_start),
            current_period_end = VALUES(current_period_end)
    `, [
        userId, 
        planId, 
        stripeSubscription.status,
        new Date(stripeSubscription.current_period_start * 1000),
        new Date(stripeSubscription.current_period_end * 1000),
        stripeSubscription.id
    ]);
    
    // Update user permissions based on new subscription
    await updateUserPermissions(userId, planId);
}

 

Phase 3: Implement Feature Access Control

 

Now you need a way to check if users have access to specific features based on their subscription:

 

// permission-middleware.js
const subscriptionMiddleware = async (req, res, next) => {
    const userId = req.user.id; // Assuming you have authentication middleware
    
    try {
        // Get the user's active subscription
        const subscription = await db.query(`
            SELECT s.*, p.features 
            FROM subscriptions s
            JOIN plans p ON s.plan_id = p.id
            WHERE s.user_id = ? AND s.status = 'active'
            AND s.current_period_end > NOW()
            ORDER BY s.created_at DESC LIMIT 1
        `, [userId]);
        
        if (!subscription.length) {
            req.subscription = null;
            req.features = {};
        } else {
            req.subscription = subscription[0];
            req.features = JSON.parse(subscription[0].features);
        }
        
        next();
    } catch (error) {
        console.error('Subscription check error:', error);
        return res.status(500).send('Server error checking subscription');
    }
};

// Example route that requires a specific feature
app.get('/api/advanced-analytics', subscriptionMiddleware, (req, res) => {
    if (!req.features.advancedAnalytics) {
        return res.status(403).json({ 
            error: 'Upgrade required',
            message: 'This feature requires a premium subscription'
        });
    }
    
    // Provide the feature to subscribed users
    res.json({ data: getAdvancedAnalytics() });
});

 

For your frontend, you'll want a clean way to conditionally render UI elements based on subscription status:

 

// FeatureGate.jsx (React component example)
const FeatureGate = ({ featureName, fallback, children }) => {
  const { features, isLoading } = useSubscription();
  
  if (isLoading) return <LoadingSpinner />;
  
  if (features && features[featureName]) {
    return children;
  }
  
  return fallback || <UpgradePrompt feature={featureName} />;
};

// Usage example
<FeatureGate featureName="advancedAnalytics">
  <AdvancedAnalyticsDashboard />
</FeatureGate>

 

Phase 4: Build the Customer Portal

 

Users need a way to manage their subscription. You can either build a custom portal or use Stripe's Customer Portal:

 

// customer-portal.js
app.post('/create-portal-session', async (req, res) => {
    const { userId } = req.body;
    
    // Get the Stripe customer ID for this user
    const user = await db.query('SELECT stripe_customer_id FROM users WHERE id = ?', [userId]);
    
    if (!user[0] || !user[0].stripe_customer_id) {
        return res.status(404).json({ error: 'No subscription found' });
    }
    
    // Create a portal session
    const session = await stripe.billingPortal.sessions.create({
        customer: user[0].stripe_customer_id,
        return_url: `${YOUR_DOMAIN}/account`,
    });
    
    res.json({ url: session.url });
});

 

If you're building a custom portal, you'll need screens for:

 

  • Viewing current subscription details and billing history
  • Upgrading or downgrading plans
  • Updating payment methods
  • Canceling subscriptions (with confirmation flows)

 

Advanced Considerations

 

Handling Trials and Free Tiers

 

// Creating a subscription with a trial period
app.post('/start-trial', async (req, res) => {
    const { userId, priceId } = req.body;
    
    // Get or create Stripe customer
    let user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    let customerId = user[0].stripe_customer_id;
    
    if (!customerId) {
        const customer = await stripe.customers.create({
            email: user[0].email,
            metadata: { userId: userId }
        });
        customerId = customer.id;
        await db.query('UPDATE users SET stripe_customer_id = ? WHERE id = ?', [customerId, userId]);
    }
    
    // Create subscription with trial
    const subscription = await stripe.subscriptions.create({
        customer: customerId,
        items: [{ price: priceId }],
        trial_period_days: 14,
    });
    
    // Update your database to track the trial
    await db.query(`
        INSERT INTO subscriptions (
            user_id, plan_id, status, 
            current_period_start, current_period_end,
            payment_provider_id
        ) VALUES (?, ?, ?, ?, ?, ?)
    `, [
        userId, 
        getPlanIdFromPriceId(priceId),
        'trialing',
        new Date(subscription.current_period_start * 1000),
        new Date(subscription.current_period_end * 1000),
        subscription.id
    ]);
    
    res.json({ success: true, trialEnds: subscription.trial_end });
});

 

Handling Upgrades, Downgrades and Prorations

 

When users switch between plans, you'll want to handle prorations:

 

// Upgrade/downgrade handler
app.post('/change-subscription', async (req, res) => {
    const { userId, newPriceId } = req.body;
    
    try {
        // Get the user's subscription
        const subscriptionData = await db.query(`
            SELECT payment_provider_id, plan_id 
            FROM subscriptions 
            WHERE user_id = ? AND status = 'active'
            LIMIT 1
        `, [userId]);
        
        if (!subscriptionData.length) {
            return res.status(404).json({ error: 'No active subscription found' });
        }
        
        const stripeSubscriptionId = subscriptionData[0].payment_provider_id;
        
        // Fetch the subscription from Stripe
        const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);
        
        // Update the subscription with the new price
        const updatedSubscription = await stripe.subscriptions.update(
            stripeSubscriptionId,
            {
                items: [{
                    id: subscription.items.data[0].id,
                    price: newPriceId,
                }],
                proration_behavior: 'create_prorations', // This handles prorations
            }
        );
        
        // Update your database
        await db.query(`
            UPDATE subscriptions 
            SET plan_id = ?, 
                updated_at = NOW() 
            WHERE payment_provider_id = ?
        `, [
            getPlanIdFromPriceId(newPriceId),
            stripeSubscriptionId
        ]);
        
        res.json({ success: true, subscription: updatedSubscription });
    } catch (error) {
        console.error('Subscription change error:', error);
        res.status(500).json({ error: 'Failed to update subscription' });
    }
});

 

Caching Subscription Status

 

For performance in high-traffic applications, consider caching subscription status:

 

// Caching example with Redis
const Redis = require('ioredis');
const redis = new Redis();

async function getUserFeatures(userId) {
    // Try to get from cache first
    const cacheKey = `user:${userId}:features`;
    const cachedFeatures = await redis.get(cacheKey);
    
    if (cachedFeatures) {
        return JSON.parse(cachedFeatures);
    }
    
    // If not in cache, fetch from database
    const subscription = await db.query(`
        SELECT s.*, p.features 
        FROM subscriptions s
        JOIN plans p ON s.plan_id = p.id
        WHERE s.user_id = ? AND s.status = 'active'
        AND s.current_period_end > NOW()
        ORDER BY s.created_at DESC LIMIT 1
    `, [userId]);
    
    let features = {};
    if (subscription.length) {
        features = JSON.parse(subscription[0].features);
        
        // Cache for 15 minutes (adjust as needed)
        await redis.set(cacheKey, JSON.stringify(features), 'EX', 900);
    }
    
    return features;
}

// Clear cache when subscription changes
function invalidateUserFeaturesCache(userId) {
    const cacheKey = `user:${userId}:features`;
    return redis.del(cacheKey);
}

 

Common Pitfalls to Avoid

 

1. Webhook Reliability Issues

 

Webhooks can fail, so implement a retry mechanism:

 

// In your webhook handler
app.post('/webhook', async (req, res) => {
    try {
        // Validate and process the webhook
        // ...
        
        // Respond quickly to acknowledge receipt
        res.status(200).send();
    } catch (error) {
        console.error('Webhook processing error:', error);
        
        // Log the event for retry
        await db.query(`
            INSERT INTO webhook_retries (
                event_id, event_type, event_data, error, attempts
            ) VALUES (?, ?, ?, ?, ?)
        `, [
            event.id,
            event.type,
            JSON.stringify(event.data),
            error.message,
            0
        ]);
        
        // Still return 200 to prevent Stripe from retrying
        // We'll handle retries ourselves
        res.status(200).send();
    }
});

// Separate retry job that runs periodically
async function retryFailedWebhooks() {
    const failedEvents = await db.query(`
        SELECT * FROM webhook_retries 
        WHERE attempts < 5 
        ORDER BY created_at ASC 
        LIMIT 10
    `);
    
    for (const event of failedEvents) {
        try {
            // Process the event
            await processWebhookEvent(JSON.parse(event.event_data), event.event_type);
            
            // If successful, delete from retry table
            await db.query('DELETE FROM webhook_retries WHERE id = ?', [event.id]);
        } catch (error) {
            // Update attempts count
            await db.query(`
                UPDATE webhook_retries 
                SET attempts = attempts + 1, 
                    last_attempt = NOW(),
                    last_error = ?
                WHERE id = ?
            `, [error.message, event.id]);
        }
    }
}

 

2. Synchronization Issues

 

Always treat your payment provider as the source of truth for subscription status:

 

// Periodic reconciliation job
async function reconcileSubscriptions() {
    // Get active subscriptions from your database
    const dbSubscriptions = await db.query(`
        SELECT user_id, payment_provider_id, status
        FROM subscriptions
        WHERE status IN ('active', 'trialing')
    `);
    
    for (const sub of dbSubscriptions) {
        try {
            // Verify with Stripe
            const stripeSub = await stripe.subscriptions.retrieve(sub.payment_provider_id);
            
            // If status doesn't match, update your database
            if (stripeSub.status !== sub.status) {
                await db.query(`
                    UPDATE subscriptions
                    SET status = ?,
                        current_period_end = ?,
                        updated_at = NOW()
                    WHERE payment_provider_id = ?
                `, [
                    stripeSub.status,
                    new Date(stripeSub.current_period_end * 1000),
                    sub.payment_provider_id
                ]);
                
                // Also update user permissions if needed
                if (['canceled', 'unpaid', 'past_due'].includes(stripeSub.status)) {
                    await updateUserPermissions(sub.user_id, null); // Remove premium access
                }
            }
        } catch (error) {
            // Handle case where subscription doesn't exist in Stripe
            if (error.code === 'resource_missing') {
                await db.query(`
                    UPDATE subscriptions
                    SET status = 'canceled',
                        updated_at = NOW()
                    WHERE payment_provider_id = ?
                `, [sub.payment_provider_id]);
                
                await updateUserPermissions(sub.user_id, null);
            }
            console.error(`Error reconciling subscription ${sub.payment_provider_id}:`, error);
        }
    }
}

 

3. Not Planning for Plan Changes

 

Your subscription plans will evolve over time. Design your system to handle this gracefully:

 

// Handling deprecated plans
CREATE TABLE plan_transitions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    old_plan_id INT NOT NULL,
    new_plan_id INT NOT NULL,
    effective_date TIMESTAMP NOT NULL,
    is_forced BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (old_plan_id) REFERENCES plans(id),
    FOREIGN KEY (new_plan_id) REFERENCES plans(id)
);

// Migration script example
async function migrateLegacyPlans() {
    const transition = await db.query(`
        SELECT * FROM plan_transitions
        WHERE effective_date <= NOW()
        AND is_processed = false
    `);
    
    for (const t of transition) {
        // Get all users on the old plan
        const users = await db.query(`
            SELECT user_id, payment_provider_id
            FROM subscriptions
            WHERE plan_id = ? AND status = 'active'
        `, [t.old_plan_id]);
        
        for (const user of users) {
            // Get the new price ID from your plan mapping
            const newPlan = await db.query(`
                SELECT stripe_price_id FROM plans WHERE id = ?
            `, [t.new_plan_id]);
            
            // Update in Stripe
            if (t.is_forced) {
                await stripe.subscriptions.update(user.payment_provider_id, {
                    items: [{
                        id: user.payment_provider_id.items.data[0].id,
                        price: newPlan[0].stripe_price_id
                    }],
                    proration_behavior: t.prorate ? 'create_prorations' : 'none'
                });
                
                // Update in your database
                await db.query(`
                    UPDATE subscriptions
                    SET plan_id = ?,
                        updated_at = NOW()
                    WHERE payment_provider_id = ?
                `, [t.new_plan_id, user.payment_provider_id]);
                
                // Notify the user
                await sendPlanChangeEmail(user.user_id, t.old_plan_id, t.new_plan_id);
            } else {
                // For optional migrations, notify users to upgrade
                await sendPlanUpgradeEmail(user.user_id, t.old_plan_id, t.new_plan_id);
            }
        }
        
        // Mark transition as processed
        await db.query(`
            UPDATE plan_transitions
            SET is_processed = true
            WHERE id = ?
        `, [t.id]);
    }
}

 

Conclusion: Making Subscription Management Sustainable

 

Building a subscription system is one of those features that starts simple but can quickly grow in complexity. The foundation you set today will determine how easily you can adapt to business changes tomorrow. A few final recommendations:

 

  • Document everything: Keep clear records of how your subscription logic works, especially edge cases and special handling
  • Implement robust monitoring: Set up alerts for subscription failures, churn events, and other critical metrics
  • Create admin tools: Build internal tools for customer support to easily view and modify subscription statuses
  • Test thoroughly: Create comprehensive tests for subscription flows, especially critical paths like trials ending and renewals

 

With these pieces in place, you'll have a subscription management system that can grow with your business while maintaining reliability for your customers.

Ship Subscription Management 10x Faster with RapidDev

Connect with our team to unlock the full potential of code solutions with a no-commitment consultation!

Book a Free Consultation

Top 3 Subscription Management Usecases

Explore the top 3 key use cases for seamless subscription management in your web app.

Recurring Revenue Optimization

A system for converting one-time customers into predictable monthly revenue streams with flexible billing cycles, automated renewals, and customizable subscription tiers. Reduces revenue volatility by 40-60% while increasing customer lifetime value.

Churn Prevention Intelligence

Proactive monitoring suite that identifies at-risk subscribers through payment failure patterns, usage decline, and engagement metrics. Enables targeted retention campaigns before cancellation occurs, typically recovering 15-30% of would-be churned customers through automated intervention.

Revenue Experimentation Platform

Infrastructure for rapidly testing different pricing models, discount strategies, and bundling options without engineering bottlenecks. Creates a continuous optimization feedback loop that typically yields 10-25% higher average revenue per user through iterative improvement cycles.


Recognized by the best

Trusted by 600+ businesses globally

From startups to enterprises and everything in between, see for yourself our incredible impact.

RapidDev was an exceptional project management organization and the best development collaborators I've had the pleasure of working with.

They do complex work on extremely fast timelines and effectively manage the testing and pre-launch process to deliver the best possible product. I'm extremely impressed with their execution ability.

Arkady
CPO, Praction
Working with Matt was comparable to having another co-founder on the team, but without the commitment or cost.

He has a strategic mindset and willing to change the scope of the project in real time based on the needs of the client. A true strategic thought partner!

Donald Muir
Co-Founder, Arc
RapidDev are 10/10, excellent communicators - the best I've ever encountered in the tech dev space.

They always go the extra mile, they genuinely care, they respond quickly, they're flexible, adaptable and their enthusiasm is amazing.

Mat Westergreen-Thorne
Co-CEO, Grantify
RapidDev is an excellent developer for custom-code solutions.

We’ve had great success since launching the platform in November 2023. In a few months, we’ve gained over 1,000 new active users. We’ve also secured several dozen bookings on the platform and seen about 70% new user month-over-month growth since the launch.

Emmanuel Brown
Co-Founder, Church Real Estate Marketplace
Matt’s dedication to executing our vision and his commitment to the project deadline were impressive. 

This was such a specific project, and Matt really delivered. We worked with a really fast turnaround, and he always delivered. The site was a perfect prop for us!

Samantha Fekete
Production Manager, Media Production Company
The pSEO strategy executed by RapidDev is clearly driving meaningful results.

Working with RapidDev has delivered measurable, year-over-year growth. Comparing the same period, clicks increased by 129%, impressions grew by 196%, and average position improved by 14.6%. Most importantly, qualified contact form submissions rose 350%, excluding spam.

Appreciation as well to Matt Graham for championing the collaboration!

Michael W. Hammond
Principal Owner, OCD Tech

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We’ll discuss your project and provide a custom quote at no cost.Â