Build a utility bill payment integration by looking up the customer account with the utility provider, displaying the outstanding amount, collecting payment through Paystack via M-Pesa or card, then forwarding the payment to the utility company via Paystack Transfers to their paybill or bank account. Generate a receipt for the customer. For recurring payments, store the account number and automate monthly collection and forwarding.
Kenyans already pay utility bills. KPLC has paybill 888880. Nairobi Water has its own. Every internet provider has a payment channel. So why build another layer?
Because the current experience is fragmented. A typical Nairobi household pays:
That is five separate payment flows, five separate sets of account numbers to remember, and no consolidated view of what you paid this month. A utility bill payment platform solves this by consolidating all utility payments into one interface, one payment history, and optionally, one automated payment run each month.
The value proposition for different users:
The technical model is straightforward: collect payment from the customer via Paystack (M-Pesa, card, or Pesalink), then forward that payment to the utility company via Paystack Transfers to their paybill number or bank account.
The data model needs to support multiple utility types, each with its own account number format and billing cycle.
const createUtilityProvidersTable = `
CREATE TABLE utility_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL, -- 'electricity', 'water', 'internet', 'security'
paybill_number VARCHAR(20),
bank_code VARCHAR(20),
bank_account VARCHAR(30),
paystack_recipient_code VARCHAR(50), -- For automated forwarding
account_number_format VARCHAR(100), -- Regex pattern for validation
account_number_label VARCHAR(50), -- 'Meter Number', 'Account Number', etc.
billing_cycle VARCHAR(20), -- 'monthly', 'prepaid', 'quarterly'
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
`;
const createCustomerAccountsTable = `
CREATE TABLE customer_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
utility_provider_id UUID REFERENCES utility_providers(id),
account_number VARCHAR(50) NOT NULL,
account_name VARCHAR(200),
autopay_enabled BOOLEAN DEFAULT false,
autopay_day INT, -- Day of month (1-28) for autopay
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, utility_provider_id, account_number)
);
`;
const createBillsTable = `
CREATE TABLE utility_bills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_account_id UUID REFERENCES customer_accounts(id),
billing_period VARCHAR(20), -- '2026-07', '2026-Q3'
amount_due INT NOT NULL,
due_date DATE,
status VARCHAR(20) DEFAULT 'unpaid', -- 'unpaid', 'paid', 'overdue', 'partial'
paid_amount INT DEFAULT 0,
payment_reference VARCHAR(100),
forward_reference VARCHAR(100), -- Transfer reference to utility
forward_status VARCHAR(20), -- 'pending', 'sent', 'confirmed', 'failed'
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Seed utility providers
const providers = [
{
name: 'Kenya Power (KPLC)',
type: 'electricity',
paybill_number: '888880',
account_number_format: '^[0-9]{7,13}$',
account_number_label: 'Meter Number',
billing_cycle: 'monthly',
},
{
name: 'Nairobi Water',
type: 'water',
paybill_number: '444400',
account_number_format: '^[0-9]{6,12}$',
account_number_label: 'Account Number',
billing_cycle: 'monthly',
},
{
name: 'Safaricom Home Fibre',
type: 'internet',
paybill_number: '100200',
account_number_format: '^[0-9A-Z]{8,15}$',
account_number_label: 'Account Number',
billing_cycle: 'monthly',
},
];
The account_number_format field uses regex to validate that the customer entered a plausible account number before you try to look it up or accept a payment. A KPLC meter number is 7 to 13 digits. If someone types 4 digits, reject it before making an API call. This small validation step saves you from processing payments against invalid accounts.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Before collecting a payment, the customer needs to see what they owe. The bill lookup flow depends on the utility provider:
// Bill lookup endpoint
app.post('/api/bills/lookup', async (req, res) => {
const { providerId, accountNumber } = req.body;
const provider = await db.query(
'SELECT * FROM utility_providers WHERE id = $1',
[providerId]
);
// Validate account number format
const regex = new RegExp(provider[0].account_number_format);
if (!regex.test(accountNumber)) {
return res.status(400).json({
error: `Invalid ${provider[0].account_number_label}. Expected format: ${provider[0].account_number_format}`,
});
}
let billData;
switch (provider[0].type) {
case 'electricity':
billData = await lookupKPLCBill(accountNumber);
break;
case 'water':
billData = await lookupWaterBill(accountNumber, provider[0].name);
break;
case 'internet':
billData = await lookupInternetBill(accountNumber, provider[0].name);
break;
default:
// Fallback: ask the customer to enter the amount
billData = { accountNumber, amount: null, requiresManualEntry: true };
}
res.json({
provider: provider[0].name,
accountNumber,
accountName: billData.accountName || null,
amountDue: billData.amount,
dueDate: billData.dueDate || null,
requiresManualEntry: billData.requiresManualEntry || false,
});
});
// KPLC lookup (example - may require partnership)
async function lookupKPLCBill(meterNumber) {
// If you have API access through a KPLC partnership:
// const response = await fetch(`https://kplc-api.example.com/balance?meter=${meterNumber}`);
// return response.json();
// Fallback: check our cached bill records
const cachedBill = await db.query(
`SELECT * FROM utility_bills ub
JOIN customer_accounts ca ON ub.customer_account_id = ca.id
WHERE ca.account_number = $1
AND ub.status = 'unpaid'
ORDER BY ub.created_at DESC
LIMIT 1`,
[meterNumber]
);
if (cachedBill.length > 0) {
return {
accountNumber: meterNumber,
amount: cachedBill[0].amount_due,
dueDate: cachedBill[0].due_date,
};
}
// No cached data, ask for manual entry
return { accountNumber: meterNumber, amount: null, requiresManualEntry: true };
}
The honest reality: bill lookup in Kenya is hard. Most utility companies do not expose APIs. If you are building a serious multi-utility platform, you will need to establish partnerships with each utility provider for API access or data sharing. In the meantime, manual amount entry is a legitimate fallback. The customer knows what they owe from their paper bill or SMS notification.
The payment flow has two steps: collect from the customer, then forward to the utility. This is the core of the platform.
// Collect payment from customer
app.post('/api/bills/:billId/pay', async (req, res) => {
const { billId } = req.params;
const bill = await db.query(
`SELECT ub.*, ca.account_number, ca.user_id,
up.name as provider_name, up.type as provider_type
FROM utility_bills ub
JOIN customer_accounts ca ON ub.customer_account_id = ca.id
JOIN utility_providers up ON ca.utility_provider_id = up.id
WHERE ub.id = $1`,
[billId]
);
const user = await db.query('SELECT * FROM users WHERE id = $1', [bill[0].user_id]);
const reference = `UTL-${bill[0].provider_type.toUpperCase().slice(0, 3)}-${bill[0].account_number}-${Date.now()}`;
const response = 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[0].email,
amount: bill[0].amount_due,
currency: 'KES',
reference,
mobile_money: {
phone: user[0].phone,
provider: 'mpesa',
},
metadata: {
bill_id: bill[0].id,
account_number: bill[0].account_number,
provider_name: bill[0].provider_name,
provider_type: bill[0].provider_type,
billing_period: bill[0].billing_period,
custom_fields: [
{ display_name: 'Utility', variable_name: 'utility', value: bill[0].provider_name },
{ display_name: 'Account', variable_name: 'account', value: bill[0].account_number },
{ display_name: 'Period', variable_name: 'period', value: bill[0].billing_period },
],
},
}),
});
await db.insert('payments', {
reference,
bill_id: billId,
amount: bill[0].amount_due,
status: 'pending',
});
res.json({ status: 'pending', reference, message: 'Check your phone for the M-Pesa prompt' });
});
After the payment is confirmed via webhook, forward it to the utility:
// In webhook handler, after confirming customer payment:
async function forwardToUtility(billId) {
const bill = await db.query(
`SELECT ub.*, up.paybill_number, up.paystack_recipient_code,
ca.account_number
FROM utility_bills ub
JOIN customer_accounts ca ON ub.customer_account_id = ca.id
JOIN utility_providers up ON ca.utility_provider_id = up.id
WHERE ub.id = $1`,
[billId]
);
// Ensure the utility provider has a transfer recipient set up
let recipientCode = bill[0].paystack_recipient_code;
if (!recipientCode) {
// Create a recipient for the utility's paybill
const recipientRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'mobile_money',
name: bill[0].provider_name || 'Utility Provider',
account_number: bill[0].paybill_number,
bank_code: 'MPESA',
currency: 'KES',
}),
});
const recipient = await recipientRes.json();
recipientCode = recipient.data.recipient_code;
await db.query(
'UPDATE utility_providers SET paystack_recipient_code = $1 WHERE paybill_number = $2',
[recipientCode, bill[0].paybill_number]
);
}
// Forward the payment
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: bill[0].amount_due,
recipient: recipientCode,
reason: `Utility payment: ${bill[0].account_number}`,
}),
});
const transfer = await transferRes.json();
await db.query(
'UPDATE utility_bills SET forward_reference = $1, forward_status = $2 WHERE id = $3',
[transfer.data.transfer_code, 'sent', billId]
);
}
A critical point: the amount you forward to the utility is the full bill amount. Your margin (if your platform charges a convenience fee) comes from a separate fee you charge the customer on top of the bill amount, or from a commission arrangement with the utility provider. Do not deduct your fee from the utility payment, or the customer's account will show a partial payment at the utility company.
For details on Paystack Transfers to paybill numbers, see Paystack Transfers to Paybill and Till Numbers.
Receipts for utility payments serve a specific purpose: proof that the customer paid. When a customer disputes a bill with KPLC and says "I already paid," the receipt from your platform is their evidence.
async function generateUtilityReceipt(paymentData) {
const { reference, providerName, accountNumber, amount, billingPeriod, forwardReference } = paymentData;
const receipt = {
receiptNumber: reference,
date: new Date().toISOString(),
platformName: process.env.PLATFORM_NAME,
// Utility details
utilityProvider: providerName,
accountNumber: accountNumber,
billingPeriod: billingPeriod,
// Payment details
amountPaid: amount,
paymentMethod: 'M-Pesa',
forwardReference: forwardReference,
// Status
paymentStatus: 'Confirmed',
forwardStatus: 'Payment forwarded to utility provider',
};
return await createReceiptPDF(receipt);
}
The receipt must include:
Send the receipt via SMS with a download link. An SMS that says "KPLC payment of KES 3,500 for meter 12345678 confirmed. Receipt: UTL-ELE-12345678-1721500000" gives the customer immediate proof without needing to open an app.
Recurring payments are where a utility platform earns its value. Instead of the customer remembering to pay KPLC, water, and internet separately each month, they set it up once and it happens automatically.
The challenge with M-Pesa recurring payments is that M-Pesa does not support automatic direct debits like cards do. Each payment requires the customer to authorize via STK push and enter their PIN. So "autopay" for M-Pesa means "we send you the STK push on the scheduled day, and you just need to enter your PIN."
// Scheduled job: process autopay for all eligible accounts
async function processAutopay() {
const today = new Date().getDate(); // Day of month (1-31)
const autopayAccounts = await db.query(
`SELECT ca.*, up.name as provider_name, u.phone, u.email
FROM customer_accounts ca
JOIN utility_providers up ON ca.utility_provider_id = up.id
JOIN users u ON ca.user_id = u.id
WHERE ca.autopay_enabled = true
AND ca.autopay_day = $1`,
[today]
);
for (const account of autopayAccounts) {
// Find the unpaid bill for this account
const bill = await db.query(
`SELECT * FROM utility_bills
WHERE customer_account_id = $1
AND status = 'unpaid'
ORDER BY created_at DESC
LIMIT 1`,
[account.id]
);
if (bill.length === 0) continue; // No bill to pay
// Send SMS notification first
const amountKES = (bill[0].amount_due / 100).toLocaleString();
await sendSMS(
account.phone,
`Autopay: Your ${account.provider_name} bill of KES ${amountKES} (account ${account.account_number}) will be charged via M-Pesa shortly. Make sure you have sufficient funds.`
);
// Wait a few seconds for the SMS to arrive, then send STK push
// In production, use a delayed queue rather than setTimeout
await delay(5000);
// Initiate the M-Pesa charge
const reference = `AUTO-${account.account_number}-${Date.now()}`;
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: account.email,
amount: bill[0].amount_due,
currency: 'KES',
reference,
mobile_money: {
phone: account.phone,
provider: 'mpesa',
},
metadata: {
bill_id: bill[0].id,
account_number: account.account_number,
provider_name: account.provider_name,
autopay: true,
},
}),
});
await db.insert('payments', {
reference,
bill_id: bill[0].id,
amount: bill[0].amount_due,
status: 'pending',
});
}
}
Important: always notify the customer before initiating an autopay charge. The SMS should arrive before the STK push. If a customer gets an unexpected STK push from a shortcode they do not recognize, they will decline it. But if they got an SMS ten seconds earlier saying "Your KPLC autopay of KES 3,500 is coming through," they will enter their PIN.
Handle the case where the customer declines or the charge times out. The autopay is not guaranteed to succeed. Log the failure, send a follow-up SMS ("Your KPLC autopay did not go through. Pay manually at [link]."), and do not retry without the customer's knowledge.
A multi-utility platform lets the customer link all their utility accounts and manage payments from one dashboard. The design needs to handle the differences between utility types while presenting a unified experience.
The dashboard view for a customer might show:
// API endpoint: get all bills for a user
app.get('/api/users/:userId/bills', async (req, res) => {
const { userId } = req.params;
const bills = await db.query(
`SELECT ub.*, ca.account_number, up.name as provider_name,
up.type as provider_type
FROM utility_bills ub
JOIN customer_accounts ca ON ub.customer_account_id = ca.id
JOIN utility_providers up ON ca.utility_provider_id = up.id
WHERE ca.user_id = $1
ORDER BY ub.due_date ASC`,
[userId]
);
const summary = {
totalDue: bills.filter(b => b.status === 'unpaid').reduce((sum, b) => sum + b.amount_due, 0),
overdue: bills.filter(b => b.status === 'overdue').length,
upcoming: bills.filter(b => b.status === 'unpaid' && new Date(b.due_date) > new Date()).length,
};
res.json({ bills, summary });
});
// Pay all outstanding bills in one go
app.post('/api/users/:userId/pay-all', async (req, res) => {
const { userId } = req.params;
const unpaidBills = await db.query(
`SELECT ub.*, ca.account_number, up.name as provider_name
FROM utility_bills ub
JOIN customer_accounts ca ON ub.customer_account_id = ca.id
JOIN utility_providers up ON ca.utility_provider_id = up.id
WHERE ca.user_id = $1 AND ub.status = 'unpaid'
ORDER BY ub.due_date ASC`,
[userId]
);
const totalAmount = unpaidBills.reduce((sum, b) => sum + b.amount_due, 0);
// Collect the total amount as one M-Pesa charge
// On success, the webhook handler splits and forwards to each utility
const reference = `BATCH-${userId.slice(0, 8)}-${Date.now()}`;
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
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[0].email,
amount: totalAmount,
currency: 'KES',
reference,
mobile_money: {
phone: user[0].phone,
provider: 'mpesa',
},
metadata: {
user_id: userId,
payment_type: 'batch',
bill_ids: unpaidBills.map(b => b.id),
bill_details: unpaidBills.map(b => ({
id: b.id,
provider: b.provider_name,
account: b.account_number,
amount: b.amount_due,
})),
},
}),
});
res.json({
status: 'pending',
reference,
totalAmount,
billCount: unpaidBills.length,
message: 'Check your phone for the M-Pesa prompt',
});
});
The "pay all bills" feature is a strong selling point. Instead of five separate M-Pesa transactions, the customer does one. Your webhook handler receives the batch payment and initiates separate Paystack Transfers to each utility. The customer sees one debit on their M-Pesa. Each utility receives its correct amount.
One caution: M-Pesa has per-transaction limits. If the total of all bills exceeds the limit, the customer cannot pay all at once. Handle this gracefully by falling back to individual payments or splitting into chunks that fit within M-Pesa limits.
Before you build a utility payment platform, consider why someone would use it instead of paying directly via M-Pesa paybill. Every KPLC customer already knows to dial *234# or go to M-Pesa and pay paybill 888880. Your platform adds a step in the middle.
A utility payment platform is worth building when it offers value that direct paybill does not:
If you cannot offer at least two or three of these benefits, the customer has no reason to switch from direct paybill. Be honest about this in your product planning. The technology is the easy part. The value proposition is what determines whether anyone actually uses it.
There is also a regulatory consideration. If your platform collects money from customers and holds it before forwarding to utilities, you may be operating as a payment intermediary. The CBK regulations have specific rules about this. Using Paystack as your licensed payment service provider helps, but you should understand the rules that apply to your specific model.
KPLC electricity in Kenya is either postpaid (get a monthly bill) or prepaid (buy tokens). The prepaid model is increasingly common. The customer buys tokens via M-Pesa paybill 888880, enters their meter number, and receives a token number via SMS that they enter into their meter.
If your platform handles KPLC prepaid, you need a way to get the token back to the customer after payment. This requires integration with the token vending system, which is controlled by KPLC. Without an official partnership, you cannot vend tokens.
Options:
The forwarding approach (pay via your platform, KPLC sends the token) is the most practical for most developers. You add value through consolidation, history, and autopay without needing to integrate with the token vending infrastructure.
A utility payment platform combines collection, transfers, scheduling, and multi-provider integration. It is a solid intermediate project that touches many aspects of full-stack engineering.
The McTaba M-Pesa Integration course (KES 9,999) covers the payment collection and transfer patterns at the core of this system. The full fee applies as credit toward the 26-week bootcamp.
The 26-week Full-Stack Software and AI Engineering bootcamp (KES 120,000) covers the complete engineering picture: databases, API design, scheduled jobs, deployment, and building systems that run reliably in production.
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