To build SaaS billing for Kenya with Paystack, create Plans with KES amounts, then subscribe customers via the Paystack Subscriptions API. Cards support automatic recurring charges. M-Pesa does not support auto-debit, so the first M-Pesa payment captures an authorization you cannot reuse for subsequent charges. For M-Pesa subscribers, you need to trigger a fresh STK push each billing cycle and handle the case where the customer does not complete it.
SaaS billing in Kenya has a wrinkle that does not exist in markets where everyone pays with cards. Cards support automatic recurring charges. M-Pesa does not. Since most of your Kenyan customers will pay with M-Pesa, you need two billing paths running in parallel.
Here is the architecture:
The Paystack Subscriptions API handles most of the lifecycle for card-paying customers. For M-Pesa customers, Paystack's subscription system creates invoices but cannot charge automatically. You are responsible for triggering each charge and handling failures.
You need your own subscription tracking alongside Paystack's records. Paystack is the payment processor, not your billing database. You need to know who is on which plan, when they are due, what their payment method is, and how many failed charges they have accumulated.
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'KES',
interval VARCHAR(20) NOT NULL, -- 'monthly', 'quarterly', 'annually'
paystack_plan_code VARCHAR(100),
features JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT TRUE,
trial_days INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) NOT NULL,
plan_id UUID REFERENCES plans(id) NOT NULL,
status VARCHAR(30) DEFAULT 'active',
-- status values: trialing, active, past_due, cancelled, expired
payment_method VARCHAR(20), -- 'card' or 'mobile_money'
paystack_subscription_code VARCHAR(100),
paystack_email_token VARCHAR(100),
paystack_customer_code VARCHAR(100),
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_end TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
failed_charge_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE billing_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID REFERENCES subscriptions(id),
event_type VARCHAR(50) NOT NULL,
-- event types: charge_success, charge_failed, plan_changed, cancelled, trial_started, trial_ended
amount_cents INTEGER,
paystack_reference VARCHAR(255),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
Key design decisions:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create your plans in Paystack so the Subscriptions API can reference them. You can do this through the dashboard or the API. Using the API is better because you can version-control your plan definitions.
// Create plans on application startup or via a setup script
async function createPaystackPlan(plan) {
const res = await fetch('https://api.paystack.co/plan', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: plan.name,
amount: plan.amountCents, // e.g., 299900 for KES 2,999
interval: plan.interval, // 'monthly', 'quarterly', 'annually'
currency: 'KES',
description: plan.description,
}),
});
const data = await res.json();
return data.data.plan_code; // e.g., 'PLN_xxxxxxxxxxxxx'
}
// Example plans
const plans = [
{ name: 'Starter', amountCents: 99900, interval: 'monthly', description: 'For individuals' },
{ name: 'Growth', amountCents: 299900, interval: 'monthly', description: 'For small teams' },
{ name: 'Business', amountCents: 999900, interval: 'monthly', description: 'For growing companies' },
];
A few things to watch for:
When a customer chooses a plan, you need to collect the first payment and create the subscription. The flow differs by payment method.
Card payment (the simpler path):
// POST /api/subscribe
app.post('/api/subscribe', async (req, res) => {
const { planSlug } = req.body;
const user = req.user;
const plan = await db.query('SELECT * FROM plans WHERE slug = $1', [planSlug]);
if (!plan.rows[0]) return res.status(404).json({ error: 'Plan not found' });
// Initialize a transaction that creates a subscription on success
const paystackRes = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: user.email,
amount: plan.rows[0].amount_cents,
currency: 'KES',
plan: plan.rows[0].paystack_plan_code,
channels: ['card'],
callback_url: `${process.env.APP_URL}/billing/callback`,
metadata: {
user_id: user.id,
plan_id: plan.rows[0].id,
},
}),
});
const data = await paystackRes.json();
res.json({ authorization_url: data.data.authorization_url });
});
When you pass the plan parameter to Transaction Initialize, Paystack automatically creates a subscription after the first charge succeeds. The card authorization is stored, and Paystack charges it on each billing cycle without your involvement.
M-Pesa payment (the harder path):
You cannot pass a plan code with M-Pesa Charge API and expect auto-recurring billing. M-Pesa does not support authorization tokens that can be recharged. So the approach is:
// POST /api/subscribe/mpesa
app.post('/api/subscribe/mpesa', async (req, res) => {
const { planSlug, phone } = req.body;
const user = req.user;
const plan = await db.query('SELECT * FROM plans WHERE slug = $1', [planSlug]);
if (!plan.rows[0]) return res.status(404).json({ error: 'Plan not found' });
// Charge for the first period
const chargeRes = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: user.email,
amount: plan.rows[0].amount_cents,
currency: 'KES',
mobile_money: { phone, provider: 'mpesa' },
metadata: {
user_id: user.id,
plan_id: plan.rows[0].id,
subscription_type: 'first_charge',
},
}),
});
const chargeData = await chargeRes.json();
res.json({
status: chargeData.data.status,
reference: chargeData.data.reference,
display_text: chargeData.data.display_text,
});
});
// In your webhook handler, when the first M-Pesa charge succeeds:
async function handleFirstMpesaCharge(data) {
const { user_id, plan_id } = data.metadata;
const plan = await db.query('SELECT * FROM plans WHERE id = $1', [plan_id]);
const intervalDays = plan.rows[0].interval === 'monthly' ? 30
: plan.rows[0].interval === 'quarterly' ? 90 : 365;
const now = new Date();
const periodEnd = new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000);
await db.query(
`INSERT INTO subscriptions
(user_id, plan_id, status, payment_method, current_period_start, current_period_end)
VALUES ($1, $2, 'active', 'mobile_money', $3, $4)`,
[user_id, plan_id, now, periodEnd]
);
}
This is more work than the card path. But it is the reality of SaaS billing in a market where most customers pay with mobile money.
For card subscriptions, Paystack handles renewals automatically. You listen for invoice.create and charge.success webhooks to keep your database in sync.
For M-Pesa subscriptions, you need a billing engine. This is a scheduled job that runs daily and processes due renewals.
// Run daily via cron or a scheduled cloud function
async function processMpesaRenewals() {
// Find M-Pesa subscriptions due for renewal
const due = await db.query(
`SELECT s.*, p.amount_cents, p.interval, u.email, u.phone
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
JOIN users u ON s.user_id = u.id
WHERE s.payment_method = 'mobile_money'
AND s.status = 'active'
AND s.current_period_end <= NOW()
AND s.failed_charge_count < 3`
);
for (const sub of due.rows) {
try {
const chargeRes = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: sub.email,
amount: sub.amount_cents,
currency: 'KES',
mobile_money: { phone: sub.phone, provider: 'mpesa' },
metadata: {
subscription_id: sub.id,
subscription_type: 'renewal',
},
}),
});
const chargeData = await chargeRes.json();
await db.query(
`INSERT INTO billing_events (subscription_id, event_type, amount_cents, paystack_reference)
VALUES ($1, 'renewal_initiated', $2, $3)`,
[sub.id, sub.amount_cents, chargeData.data?.reference]
);
} catch (err) {
console.error('Renewal charge failed for subscription:', sub.id, err);
}
}
}
// In your webhook handler, when a renewal charge succeeds:
async function handleRenewalSuccess(data) {
const { subscription_id } = data.metadata;
const sub = await db.query('SELECT * FROM subscriptions WHERE id = $1', [subscription_id]);
const plan = await db.query('SELECT * FROM plans WHERE id = $1', [sub.rows[0].plan_id]);
const intervalDays = plan.rows[0].interval === 'monthly' ? 30
: plan.rows[0].interval === 'quarterly' ? 90 : 365;
const newPeriodEnd = new Date(Date.now() + intervalDays * 24 * 60 * 60 * 1000);
await db.query(
`UPDATE subscriptions
SET current_period_start = NOW(),
current_period_end = $1,
failed_charge_count = 0,
status = 'active',
updated_at = NOW()
WHERE id = $2`,
[newPeriodEnd, subscription_id]
);
}
The timing of the STK push matters. If your cron runs at 3 AM, the customer gets an STK push at 3 AM. That is a bad experience. Consider running the billing engine during business hours (8 AM to 6 PM EAT) so the customer is likely to have their phone nearby and can enter their PIN promptly. Some teams send an SMS reminder an hour before triggering the charge: "Your subscription renews today. Keep your phone handy for the M-Pesa prompt."
Dunning is the process of retrying failed payments and communicating with the customer about them. In Kenya, where M-Pesa charges can fail because the customer has no float, their phone is off, or they just ignore the STK push, dunning is not a nice-to-have. It is core infrastructure.
Here is a practical dunning schedule:
| Day | Action | Subscription Status |
|---|---|---|
| Day 0 | First charge attempt | active |
| Day 1 | SMS reminder + second attempt | past_due |
| Day 3 | Email + third attempt | past_due |
| Day 5 | Final SMS warning | past_due |
| Day 7 | Account downgraded to free tier | cancelled |
// In your webhook handler for failed charges
async function handleChargeFailed(data) {
const { subscription_id } = data.metadata;
if (!subscription_id) return;
const result = await db.query(
`UPDATE subscriptions
SET failed_charge_count = failed_charge_count + 1,
status = CASE
WHEN failed_charge_count + 1 >= 3 THEN 'cancelled'
ELSE 'past_due'
END,
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[subscription_id]
);
const sub = result.rows[0];
if (!sub) return;
if (sub.status === 'past_due') {
// Send SMS: "Your subscription payment failed. Please ensure you have sufficient M-Pesa balance."
await sendDunningSMS(sub.user_id, sub.failed_charge_count);
}
if (sub.status === 'cancelled') {
// Downgrade to free tier
await downgradeToFreeTier(sub.user_id);
await sendCancellationNotice(sub.user_id);
}
}
// Schedule retries based on failed_charge_count
async function scheduleDunningRetry(subscriptionId, failCount) {
const delayHours = failCount === 1 ? 24 : failCount === 2 ? 72 : null;
if (!delayHours) return; // No more retries after 3 failures
// Schedule the retry (using your job queue, cron, or setTimeout for simplicity)
await scheduleJob('retry_mpesa_charge', { subscriptionId }, delayHours * 60 * 60 * 1000);
}
One thing to consider: give the customer a way to pay manually. Add a "Pay Now" button on their billing page that triggers a fresh M-Pesa STK push or offers a card payment option. Some customers want to clear the overdue payment on their own terms rather than waiting for the next automatic retry.
For card subscriptions, Paystack handles some retry logic on its own. You still need to listen for invoice.payment_failed events and update your database accordingly. But the retry scheduling is Paystack's responsibility.
Customers change plans. A startup on your Starter plan lands a big contract and needs the Business plan. A company downsizing moves from Business to Starter. Your billing system needs to handle both.
Upgrades with proration:
app.post('/api/subscription/change-plan', async (req, res) => {
const { newPlanSlug } = req.body;
const user = req.user;
const sub = await db.query(
"SELECT * FROM subscriptions WHERE user_id = $1 AND status IN ('active', 'past_due')",
[user.id]
);
if (!sub.rows[0]) return res.status(404).json({ error: 'No active subscription' });
const currentPlan = await db.query('SELECT * FROM plans WHERE id = $1', [sub.rows[0].plan_id]);
const newPlan = await db.query('SELECT * FROM plans WHERE slug = $1', [newPlanSlug]);
if (!newPlan.rows[0]) return res.status(404).json({ error: 'Plan not found' });
// Calculate proration
const now = new Date();
const periodStart = new Date(sub.rows[0].current_period_start);
const periodEnd = new Date(sub.rows[0].current_period_end);
const totalDays = Math.ceil((periodEnd - periodStart) / (1000 * 60 * 60 * 24));
const usedDays = Math.ceil((now - periodStart) / (1000 * 60 * 60 * 24));
const remainingDays = totalDays - usedDays;
const dailyRateOld = currentPlan.rows[0].amount_cents / totalDays;
const dailyRateNew = newPlan.rows[0].amount_cents / totalDays;
const creditRemaining = Math.floor(dailyRateOld * remainingDays);
const costRemaining = Math.floor(dailyRateNew * remainingDays);
const prorationAmount = Math.max(0, costRemaining - creditRemaining);
if (prorationAmount > 0) {
// Charge the difference
// ... (same charge flow as subscription creation)
}
// Update the subscription
await db.query(
`UPDATE subscriptions SET plan_id = $1, updated_at = NOW() WHERE id = $2`,
[newPlan.rows[0].id, sub.rows[0].id]
);
res.json({
message: 'Plan updated',
proration_charged: prorationAmount,
new_plan: newPlan.rows[0].name,
});
});
Downgrades are simpler. Change the plan in your database and apply the new plan at the next billing cycle. The customer keeps their current plan features until the period they already paid for ends. Do not issue a refund for the difference unless your policy explicitly offers it.
If the customer is on a Paystack-managed card subscription, you need to cancel the existing subscription and create a new one on the new plan. Use the Paystack Subscription API's disable endpoint, then initialize a new subscription. This creates a gap, so handle it carefully in your database.
In markets like the US, SaaS products often require a credit card to start a trial. In Kenya, this does not work well. Most of your potential customers do not have a credit card, and asking for M-Pesa authorization before they have tried the product creates friction that kills conversion.
The better approach for Kenya: offer a no-payment-required trial.
app.post('/api/trial/start', async (req, res) => {
const { planSlug } = req.body;
const user = req.user;
// Check if user already had a trial
const existing = await db.query(
'SELECT id FROM subscriptions WHERE user_id = $1',
[user.id]
);
if (existing.rows[0]) {
return res.status(400).json({ error: 'Trial already used' });
}
const plan = await db.query('SELECT * FROM plans WHERE slug = $1', [planSlug]);
const trialDays = plan.rows[0].trial_days || 14;
const now = new Date();
const trialEnd = new Date(now.getTime() + trialDays * 24 * 60 * 60 * 1000);
await db.query(
`INSERT INTO subscriptions
(user_id, plan_id, status, current_period_start, current_period_end, trial_end)
VALUES ($1, $2, 'trialing', $3, $4, $4)`,
[user.id, plan.rows[0].id, now, trialEnd]
);
res.json({
status: 'trialing',
trial_ends: trialEnd.toISOString(),
days_remaining: trialDays,
});
});
When the trial ends, your billing engine finds it and prompts the customer to subscribe:
async function processExpiringTrials() {
const expiring = await db.query(
`SELECT s.*, u.email, u.phone, p.name as plan_name
FROM subscriptions s
JOIN users u ON s.user_id = u.id
JOIN plans p ON s.plan_id = p.id
WHERE s.status = 'trialing' AND s.trial_end <= NOW()`
);
for (const sub of expiring.rows) {
// Send SMS and email: "Your trial has ended. Subscribe to keep using [plan name]."
await sendTrialEndNotice(sub.user_id, sub.plan_name);
// Downgrade to free tier
await db.query(
"UPDATE subscriptions SET status = 'expired', updated_at = NOW() WHERE id = $1",
[sub.id]
);
}
}
Some teams send a reminder 3 days before the trial ends and again on the day it ends. The reminder should include a direct link to the billing page where the customer can subscribe with one tap.
Your billing page is where customers manage their subscription. It needs to show the current plan, next billing date, payment method, billing history, and actions (upgrade, downgrade, cancel, update payment method).
Build an API endpoint that returns everything the billing page needs:
app.get('/api/billing', async (req, res) => {
const user = req.user;
const sub = await db.query(
`SELECT s.*, p.name as plan_name, p.amount_cents, p.interval, p.features
FROM subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.user_id = $1
ORDER BY s.created_at DESC LIMIT 1`,
[user.id]
);
const events = await db.query(
`SELECT * FROM billing_events
WHERE subscription_id = $1
ORDER BY created_at DESC LIMIT 20`,
[sub.rows[0]?.id]
);
const plans = await db.query(
'SELECT id, name, slug, amount_cents, interval, features FROM plans WHERE is_active = TRUE ORDER BY amount_cents'
);
res.json({
subscription: sub.rows[0] ? {
plan: sub.rows[0].plan_name,
status: sub.rows[0].status,
amount: sub.rows[0].amount_cents,
interval: sub.rows[0].interval,
payment_method: sub.rows[0].payment_method,
current_period_end: sub.rows[0].current_period_end,
trial_end: sub.rows[0].trial_end,
features: sub.rows[0].features,
} : null,
billing_history: events.rows.map(e => ({
date: e.created_at,
type: e.event_type,
amount: e.amount_cents,
reference: e.paystack_reference,
})),
available_plans: plans.rows,
});
});
On the frontend, the billing page should prominently show:
Cron job reliability. Your billing engine runs as a cron job. If it fails or skips a day, customers do not get charged and your revenue drops. Use a monitored cron service. If you are on Vercel, use Vercel Cron. On Railway or Render, use their scheduled jobs. On a VPS, use systemd timers or cron with monitoring via Healthchecks.io or a similar service.
Idempotency in billing. If your cron job runs twice in the same day (server restart, deployment overlap), it should not double-charge customers. Check whether a charge was already initiated for the current billing period before creating a new one.
Webhook reliability. Paystack retries failed webhook deliveries. Make sure your webhook handler is idempotent. Processing the same event twice should not create duplicate billing records or double-charge the customer.
Test the full cycle. Before launching, test the entire subscription lifecycle in Paystack test mode: creation, first charge, renewal, failed charge, dunning, plan change, cancellation. Test with both card and M-Pesa flows. Test what happens when the webhook arrives before the redirect callback. Test what happens when it arrives after.
Currency consistency. Everything in your system should be in KES cents. Do not mix KES and USD. Do not store some amounts in shillings and others in cents. Pick one (cents) and stick with it everywhere. Convert to human-readable format (KES 2,999) only at the display layer.
For the full Paystack Kenya overview, see Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question. For webhook handling patterns, see Paystack Webhooks: Complete Engineering Guide.
The McTaba 26-week bootcamp (KES 120,000) covers SaaS billing as part of the product engineering module. You build a real subscription product with real payments, real dunning, and real customers.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course