Building a Kenyan SaaS Subscription Billing System
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.
Architecture Overview
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:
- Plan management. You create plans in Paystack (or in your own database) with KES pricing and billing intervals.
- Subscription creation. When a customer subscribes, you collect their first payment. If they pay by card, Paystack stores the authorization and charges it automatically each cycle. If they pay by M-Pesa, you record the subscription in your database and trigger manual charges each cycle.
- Billing engine. A cron job (or scheduled function) runs daily. It finds subscriptions due for renewal, checks whether Paystack is handling the renewal automatically (cards) or whether you need to trigger it (M-Pesa), and acts accordingly.
- Dunning system. When a charge fails, the dunning flow sends reminders and retries. After a configurable number of failures, the system downgrades or suspends the account.
- Webhooks. Paystack sends subscription.create, invoice.create, invoice.payment_failed, charge.success, and subscription.disable events. These drive your subscription state machine.
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.
Data Model
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:
- payment_method on the subscription tells your billing engine whether to let Paystack auto-renew (card) or to trigger a manual charge (mobile_money).
- failed_charge_count drives your dunning logic. After N failures, the status moves to "past_due" and eventually "cancelled."
- billing_events gives you an audit trail of every charge, failure, and plan change. This is essential for customer support and financial reporting.
- paystack_email_token is needed to manage the subscription through Paystack's API (cancel, update, etc.).
Creating Plans with KES Pricing
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:
- Amount is in cents. KES 2,999 = 299900 cents. Double-check your zeros.
- Annual plans should offer a discount. Kenyan SaaS buyers are price-sensitive. A 10-month-for-12 annual price is standard. Create separate Paystack plans for monthly and annual intervals.
- Do not delete plans that have active subscribers. Create a new plan and migrate customers instead. Paystack does not allow deleting plans with active subscriptions.
- Store the plan_code in your database. You need it to create subscriptions and reference the plan later.
Subscription Creation and First Payment
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:
- Charge the customer for the first month using the Charge API.
- Create the subscription record in your own database with payment_method = 'mobile_money'.
- When the billing cycle ends, trigger a new M-Pesa charge yourself.
// 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.
The Recurring Billing Engine
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 for Failed Charges
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.
Plan Upgrades and Downgrades
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.
Free Trials Without Payment Upfront
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.
Building the Billing Page
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:
- The current plan name and what it includes.
- The next billing date in a format Kenyan users understand (e.g., "20th August 2026," not "2026-08-20").
- The payment method. If it is M-Pesa, show the last 4 digits of the phone number. If it is a card, show the card brand and last 4 digits.
- A clear "Change Plan" button that shows available plans with pricing.
- A "Cancel Subscription" option. Do not hide it. Kenyan consumer protection rules and good business practice both require making cancellation easy.
- Billing history with downloadable receipts. If your business is VAT-registered, each receipt should be an eTIMS-compliant invoice.
Deployment Notes
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.
Key Takeaways
- ✓Paystack Plans and Subscriptions API handles recurring billing. Create a plan with the amount in KES cents and an interval (monthly, quarterly, annually). Subscribe customers to plans.
- ✓Card subscriptions auto-renew. Paystack charges the card automatically on each billing cycle. This is the straightforward path for recurring revenue.
- ✓M-Pesa subscriptions are the hard part. M-Pesa does not support direct debit. Each billing cycle, you need to trigger a fresh STK push and hope the customer completes it. Build a dunning flow for when they do not.
- ✓Dunning is not optional for Kenyan SaaS. Failed charges happen often with mobile money. Build a retry schedule: try immediately, retry after 24 hours, send an SMS reminder, try again after 48 hours, then downgrade the account.
- ✓Plan upgrades should prorate. If a customer upgrades mid-cycle, charge them the difference for the remaining days. Paystack does not do this automatically, so you need to calculate it yourself.
- ✓Free trials should not require payment upfront for the Kenyan market. Many potential customers do not have a card and are reluctant to authorize M-Pesa before trying the product.
Frequently Asked Questions
- Can Paystack automatically charge M-Pesa for recurring subscriptions?
- No. M-Pesa does not support automatic direct debit the way cards do. Each billing cycle, you need to trigger a new STK push and the customer must enter their PIN. Paystack creates invoices for M-Pesa subscriptions, but the actual charge requires customer action each time. Your system needs to handle the case where the customer does not complete the payment.
- How do I handle customers who want to switch from M-Pesa to card billing?
- Create a new Paystack subscription on the same plan using the card payment flow. Once the card subscription is active, cancel the M-Pesa billing entry in your database. Update the payment_method field on the subscription record. The next renewal will be handled automatically by Paystack via the card authorization.
- Should I use Paystack Plans or manage plans entirely in my own database?
- Use both. Create plans in Paystack so you can use the Subscriptions API for card customers. But also store plan details in your own database because you need them for M-Pesa billing, proration calculations, feature gating, and your billing page UI. Your database is the source of truth for what the customer has access to. Paystack is the payment processor.
- What happens if a customer cancels mid-billing-cycle?
- Standard practice is to let them keep access until the end of the current period they already paid for. Do not issue a prorated refund unless your terms explicitly promise one. Set the subscription status to "cancelled" and the cancelled_at timestamp. Your access control logic should grant access until current_period_end.
- How do I generate invoices for VAT-registered SaaS businesses in Kenya?
- KRA requires eTIMS-compliant electronic tax invoices. Trigger invoice generation in the same webhook handler that processes successful charges. Include the customer name, KRA PIN (if B2B), amount, VAT breakdown, and the eTIMS-required fields. See the KRA eTIMS article for the technical integration details.
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