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 call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
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.
1. The Subscription Architecture
At its foundation, your subscription system needs these key elements:
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:
I'll use Stripe in examples, as it's widely adopted and has an excellent developer experience.
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:
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);
}
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]);
}
}
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:
With these pieces in place, you'll have a subscription management system that can grow with your business while maintaining reliability for your customers.
Explore the top 3 key use cases for seamless subscription management in your web app.
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.
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.
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.
From startups to enterprises and everything in between, see for yourself our incredible impact.
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.Â