Building a SaaS Billing Page on Paystack
A SaaS billing page needs: a pricing page with plan cards linking to Paystack checkout, a billing dashboard showing the current plan and next charge date, an invoice history table, a card management section, and upgrade/downgrade buttons. Use the Paystack Subscriptions API for fixed-price plans. Build your backend APIs to fetch subscription status, list invoices, and handle plan changes.
Setting Up Your Paystack Plans
Create Paystack plans for every pricing option you offer. Each unique combination of price and interval is a separate plan.
var axios = require('axios');
var headers = {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
};
var PLANS = [
{ name: 'Basic Monthly', amount: 200000, interval: 'monthly', tier: 'basic' },
{ name: 'Basic Annual', amount: 2000000, interval: 'annually', tier: 'basic' },
{ name: 'Pro Monthly', amount: 500000, interval: 'monthly', tier: 'pro' },
{ name: 'Pro Annual', amount: 5000000, interval: 'annually', tier: 'pro' },
];
async function setupPlans() {
for (var i = 0; i < PLANS.length; i++) {
var plan = PLANS[i];
var response = await axios.post(
'https://api.paystack.co/plan',
{
name: plan.name,
amount: plan.amount,
interval: plan.interval,
currency: 'NGN',
send_invoices: false,
send_sms: false,
},
{ headers: headers }
);
console.log(plan.name + ': ' + response.data.data.plan_code);
// Store the plan_code in your config or database
}
}
Store plan codes in your application config or a database table. You need them when initializing checkout and when creating subscriptions via the API.
This article is part of the subscriptions and recurring billing guide.
The Pricing Page
The pricing page displays your plans and links to checkout. Build the plan data from your config, not from the Paystack API, so the page loads instantly.
// Backend API: GET /api/billing/plans
router.get('/api/billing/plans', function(req, res) {
var plans = [
{
tier: 'basic',
name: 'Basic',
features: ['5 projects', '10 GB storage', 'Email support'],
monthly: { amount: 2000, planCode: 'PLN_basic_monthly_xxx' },
annual: { amount: 20000, planCode: 'PLN_basic_annual_xxx', savings: '17%' },
},
{
tier: 'pro',
name: 'Pro',
features: ['Unlimited projects', '100 GB storage', 'Priority support', 'API access'],
monthly: { amount: 5000, planCode: 'PLN_pro_monthly_xxx' },
annual: { amount: 50000, planCode: 'PLN_pro_annual_xxx', savings: '17%' },
},
];
// If the user is logged in, include their current plan
if (req.user) {
var currentPlan = getCurrentPlanForUser(req.user.id);
res.json({ plans: plans, currentPlan: currentPlan });
} else {
res.json({ plans: plans, currentPlan: null });
}
});
// Backend API: POST /api/billing/subscribe
router.post('/api/billing/subscribe', async function(req, res) {
var planCode = req.body.planCode;
var email = req.user.email;
var response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: email,
amount: getPlanAmount(planCode),
plan: planCode,
callback_url: 'https://yoursite.com/billing/callback',
},
{ headers: headers }
);
res.json({ checkoutUrl: response.data.data.authorization_url });
});
For the annual vs monthly toggle and the logic around interval switching, see annual vs monthly billing logic.
The Billing Dashboard
After subscribing, customers need a billing dashboard showing their current state. Build this from your local database, not from live Paystack API calls. Your database is faster and always available.
// Backend API: GET /api/billing/status
router.get('/api/billing/status', async function(req, res) {
var userId = req.user.id;
var billing = await db.query(
'SELECT ba.status, ba.current_period_end, ba.cancelled_at, ba.access_until, bp.name as plan_name, bp.base_amount, bp.billing_interval, pm.card_last4, pm.card_type, pm.exp_month, pm.exp_year FROM billing_accounts ba JOIN billing_plans bp ON ba.plan_id = bp.id LEFT JOIN payment_methods pm ON ba.payment_method_id = pm.id WHERE ba.user_id = $1',
[userId]
);
if (billing.rows.length === 0) {
return res.json({ hasSubscription: false });
}
var row = billing.rows[0];
res.json({
hasSubscription: true,
plan: row.plan_name,
amount: row.base_amount,
interval: row.billing_interval,
status: row.status,
nextChargeDate: row.current_period_end,
card: row.card_last4 ? row.card_type + ' ending in ' + row.card_last4 : null,
cardExpiry: row.exp_month ? row.exp_month + '/' + row.exp_year : null,
cancelledAt: row.cancelled_at,
accessUntil: row.access_until,
});
});
The dashboard should display:
- Current plan name and price
- Billing status (Active, Past Due, Cancelling)
- Next charge date
- Card on file with expiry
- Actions: Change Plan, Update Card, Cancel Subscription
Invoice History
// Backend API: GET /api/billing/invoices
router.get('/api/billing/invoices', async function(req, res) {
var userId = req.user.id;
var page = parseInt(req.query.page) || 1;
var limit = 10;
var offset = (page - 1) * limit;
var invoices = await db.query(
'SELECT i.invoice_number, i.amount, i.currency, i.status, i.period_start, i.period_end, i.paid_at, i.paystack_reference FROM invoices i JOIN billing_accounts ba ON i.billing_account_id = ba.id WHERE ba.user_id = $1 ORDER BY i.created_at DESC LIMIT $2 OFFSET $3',
[userId, limit, offset]
);
var total = await db.query(
'SELECT COUNT(*) as count FROM invoices i JOIN billing_accounts ba ON i.billing_account_id = ba.id WHERE ba.user_id = $1',
[userId]
);
res.json({
invoices: invoices.rows.map(function(row) {
return {
number: row.invoice_number,
amount: row.amount,
currency: row.currency,
status: row.status,
period: row.period_start + ' to ' + row.period_end,
paidAt: row.paid_at,
reference: row.paystack_reference,
};
}),
pagination: {
page: page,
total: parseInt(total.rows[0].count),
totalPages: Math.ceil(parseInt(total.rows[0].count) / limit),
},
});
});
Each invoice row should show: invoice number, billing period, amount, status (paid, failed, pending), and a link to view details. For paid invoices, include a "Download Receipt" link if you generate PDF receipts.
The Plan Change Flow
Let customers upgrade or downgrade from the billing dashboard.
// Backend API: POST /api/billing/change-plan
router.post('/api/billing/change-plan', async function(req, res) {
var userId = req.user.id;
var newPlanCode = req.body.planCode;
var currentBilling = await getCurrentBilling(userId);
var newPlan = await getPlanByCode(newPlanCode);
if (!currentBilling) {
return res.status(400).json({ error: 'No active subscription to change' });
}
var currentPlan = await getPlan(currentBilling.plan_id);
if (newPlan.base_amount > currentPlan.base_amount) {
// Upgrade: charge prorated difference
var proration = calculateProration({
oldPlanAmount: currentPlan.base_amount,
newPlanAmount: newPlan.base_amount,
periodStart: currentBilling.current_period_start,
periodEnd: currentBilling.current_period_end,
changeDate: new Date(),
});
res.json({
type: 'upgrade',
currentPlan: currentPlan.name,
newPlan: newPlan.name,
proratedCharge: proration.proratedAmount,
daysRemaining: proration.daysRemaining,
confirmUrl: '/api/billing/confirm-change',
});
} else {
// Downgrade: apply at end of period
res.json({
type: 'downgrade',
currentPlan: currentPlan.name,
newPlan: newPlan.name,
effectiveDate: currentBilling.current_period_end,
message: 'Your plan will change to ' + newPlan.name + ' at the end of your current billing period.',
confirmUrl: '/api/billing/confirm-change',
});
}
});
Show the customer a confirmation screen before processing the change. For upgrades, show the prorated charge amount. For downgrades, show the effective date. For the proration math and execution, see proration and plan upgrades.
Card Management Section
// Backend API: GET /api/billing/cards
router.get('/api/billing/cards', async function(req, res) {
var userId = req.user.id;
var cards = await db.query(
'SELECT id, card_last4, card_type, card_bank, exp_month, exp_year, is_default FROM payment_methods WHERE user_id = $1 AND is_reusable = true AND deactivated_at IS NULL ORDER BY is_default DESC, created_at DESC',
[userId]
);
res.json({
cards: cards.rows.map(function(card) {
var now = new Date();
var expired = card.exp_year < now.getFullYear() ||
(card.exp_year === now.getFullYear() && card.exp_month < now.getMonth() + 1);
return {
id: card.id,
display: card.card_type + ' ending in ' + card.card_last4,
bank: card.card_bank,
expiry: card.exp_month + '/' + card.exp_year,
isDefault: card.is_default,
isExpired: expired,
};
}),
});
});
// Backend API: POST /api/billing/add-card
router.post('/api/billing/add-card', async function(req, res) {
var response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: req.user.email,
amount: 5000,
callback_url: 'https://yoursite.com/billing/card-added',
metadata: { purpose: 'add_card', user_id: req.user.id },
},
{ headers: headers }
);
res.json({ checkoutUrl: response.data.data.authorization_url });
});
// Backend API: POST /api/billing/set-default-card
router.post('/api/billing/set-default-card', async function(req, res) {
var cardId = req.body.cardId;
var userId = req.user.id;
// Remove default from all cards
await db.query(
'UPDATE payment_methods SET is_default = false WHERE user_id = $1',
[userId]
);
// Set the new default
await db.query(
'UPDATE payment_methods SET is_default = true WHERE id = $1 AND user_id = $2',
[cardId, userId]
);
res.json({ success: true });
});
The card management section lets customers see their saved cards, add new ones, set a default, and remove old ones. Always show the expiry status so customers can proactively update expiring cards. See handling card expiry.
The Cancellation Flow
// Backend API: POST /api/billing/cancel
router.post('/api/billing/cancel', async function(req, res) {
var userId = req.user.id;
var reason = req.body.reason;
var feedback = req.body.feedback;
var billing = await getCurrentBilling(userId);
if (!billing || billing.status === 'cancelled') {
return res.status(400).json({ error: 'No active subscription to cancel' });
}
// Record the reason
await db.query(
'INSERT INTO cancellation_records (user_id, reason, feedback, created_at) VALUES ($1, $2, $3, NOW())',
[userId, reason, feedback]
);
// Disable on Paystack
await axios.post(
'https://api.paystack.co/subscription/disable',
{
code: billing.paystack_subscription_code,
token: billing.paystack_email_token,
},
{ headers: headers }
);
// Mark as cancelling (keeps access until period end)
await db.query(
'UPDATE billing_accounts SET status = $1, cancelled_at = NOW(), access_until = $2, updated_at = NOW() WHERE user_id = $3',
['cancelling', billing.current_period_end, userId]
);
res.json({
status: 'cancelling',
accessUntil: billing.current_period_end,
message: 'Your subscription has been cancelled. You will have access until ' + formatDate(billing.current_period_end) + '.',
});
});
Before the final cancellation, consider showing a retention offer based on the cancellation reason. "Too expensive" could trigger a discount offer. "Just need a break" could suggest pausing. See cancelling and pausing subscriptions for more on retention strategies.
Key Takeaways
- ✓Create one Paystack plan per pricing tier per billing interval. A product with Basic and Pro tiers, each available monthly and annually, needs four plans.
- ✓The pricing page links to Paystack checkout with the plan code. After checkout, the webhook creates the subscription in your database.
- ✓The billing dashboard shows current plan, status, next charge date, card on file, and invoice history. All from your local database, not live API calls.
- ✓Upgrade and downgrade flows cancel the old subscription and create a new one. Handle proration for mid-cycle changes.
- ✓The card management section shows saved cards and lets the customer set a default or add a new one.
- ✓Build your billing APIs to return billing state from your database. Only call Paystack API for actions (subscribe, cancel, charge), not for reads.
Frequently Asked Questions
- Should I call the Paystack API on every billing page load?
- No. Read billing state from your own database. Only call the Paystack API when performing actions like subscribing, cancelling, or charging. This makes your billing page fast and reduces dependency on Paystack uptime.
- How do I handle the callback after Paystack checkout?
- Paystack redirects to your callback_url with a reference parameter. Verify the transaction using GET /transaction/verify/:reference. If successful, store the authorization, create the billing account in your database, and redirect the user to their billing dashboard.
- How many Paystack plans do I need for a SaaS product?
- One plan per unique price-interval combination. If you offer Basic and Pro tiers, each with monthly and annual billing, that is four plans. If you add a Team tier later, that is two more plans (monthly and annual).
- Can I show prices in the customer's local currency on the pricing page?
- You can display prices in any currency, but the Paystack plan must be in a currency Paystack supports for that country. If your plan is in NGN, display the NGN price. For multi-currency support, create separate plans per currency.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon