Build a school fees portal by creating a student registry keyed by admission number, defining fee structures per class and term, then using Paystack to collect payments via M-Pesa and cards. Store the student ID, term, and fee type in Paystack metadata so every payment maps back to the right student and balance. Use webhooks to update balances in real time and generate receipts automatically.
Walk into any Kenyan school bursar's office during the first week of term and you will see the same thing: a queue of parents, a stack of M-Pesa confirmation messages on a phone, and a manual ledger book that somehow has to reconcile with the school bank account by Friday.
The problem is not that money is not coming in. Parents pay. The problem is tracking who paid what, for which student, for which fee category, and whether the balance is correct. A parent sends KES 15,000 to the school paybill. The confirmation says "John Kamau." But there are three John Kamaus in the school. And the parent only paid tuition, not boarding. Now the bursar has to call the parent to confirm.
A school fees portal solves this by tying every payment to a specific student admission number, a specific term, and a specific fee category. When the parent pays through the portal, there is no ambiguity. The system knows exactly who paid, how much, and for what.
Here is what a working school fees portal needs:
We will build each of these pieces. The payment layer uses Paystack, which handles M-Pesa, cards, and Pesalink through a single API.
The admission number is the anchor. Every fee, every payment, every receipt ties back to it. Your data model should reflect this from the start.
// Database schema (SQL)
// Students table
const createStudentsTable = `
CREATE TABLE students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admission_number VARCHAR(20) UNIQUE NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
class VARCHAR(20) NOT NULL, -- e.g., 'Form 2', 'Class 7'
stream VARCHAR(20), -- e.g., 'East', 'A'
boarding_status VARCHAR(20) NOT NULL, -- 'day' or 'boarding'
parent_name VARCHAR(200) NOT NULL,
parent_phone VARCHAR(15) NOT NULL, -- M-Pesa number for payments
parent_email VARCHAR(200),
status VARCHAR(20) DEFAULT 'active', -- 'active', 'graduated', 'transferred'
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Fee structures table
const createFeeStructuresTable = `
CREATE TABLE fee_structures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
academic_year INT NOT NULL, -- e.g., 2026
term INT NOT NULL, -- 1, 2, or 3
class VARCHAR(20) NOT NULL,
fee_type VARCHAR(50) NOT NULL, -- 'tuition', 'boarding', 'activity', 'transport'
amount INT NOT NULL, -- Amount in cents (KES)
UNIQUE(academic_year, term, class, fee_type)
);
`;
// Fee balances table (one row per student per fee per term)
const createFeeBalancesTable = `
CREATE TABLE fee_balances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id UUID REFERENCES students(id),
fee_structure_id UUID REFERENCES fee_structures(id),
total_amount INT NOT NULL,
paid_amount INT DEFAULT 0,
balance INT GENERATED ALWAYS AS (total_amount - paid_amount) STORED,
status VARCHAR(20) DEFAULT 'unpaid', -- 'unpaid', 'partial', 'paid'
UNIQUE(student_id, fee_structure_id)
);
`;
A few design decisions worth noting:
4500000. This avoids floating-point issues and matches what Paystack returns in webhooks.total_amount - paid_amount. You update paid_amount when payments come in.When a new term starts, you run a script that generates fee_balances rows for every active student based on their class and boarding status. This gives the bursar a clear picture of expected revenue before a single shilling comes in.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Kenyan school fees are never a single number. A typical secondary school might have this breakdown for Term 1:
| Fee Category | Boarding (KES) | Day Scholar (KES) |
|---|---|---|
| Tuition | 25,000 | 25,000 |
| Boarding | 18,000 | 0 |
| Activity Levy | 3,000 | 3,000 |
| Transport | 0 | 5,000 |
| Computer Lab | 2,000 | 2,000 |
| Total | 48,000 | 35,000 |
Your system needs to handle this granularity because parents pay in parts. A parent might send KES 25,000 and say "that is for tuition." Another parent sends KES 10,000 and expects it applied to whatever is most urgent. The school might have a policy on allocation order.
// API endpoint to create fee structures for a term
app.post('/api/fee-structures', async (req, res) => {
const { academicYear, term, structures } = req.body;
// structures: [{ class: 'Form 1', feeType: 'tuition', amount: 2500000 }, ...]
const values = structures.map(s => ({
academic_year: academicYear,
term,
class: s.class,
fee_type: s.feeType,
amount: s.amount, // Already in cents
}));
await db.insert('fee_structures', values);
// Generate fee balances for all active students in affected classes
const classes = [...new Set(structures.map(s => s.class))];
for (const className of classes) {
const students = await db.query(
'SELECT * FROM students WHERE class = $1 AND status = $2',
[className, 'active']
);
for (const student of students) {
const applicableFees = structures.filter(s => {
if (s.class !== className) return false;
// Day scholars do not pay boarding
if (s.feeType === 'boarding' && student.boarding_status === 'day') return false;
// Boarding students do not pay transport
if (s.feeType === 'transport' && student.boarding_status === 'boarding') return false;
return true;
});
for (const fee of applicableFees) {
const feeStructure = await db.query(
'SELECT id FROM fee_structures WHERE academic_year=$1 AND term=$2 AND class=$3 AND fee_type=$4',
[academicYear, term, className, fee.feeType]
);
await db.insert('fee_balances', {
student_id: student.id,
fee_structure_id: feeStructure[0].id,
total_amount: fee.amount,
paid_amount: 0,
status: 'unpaid',
});
}
}
}
res.json({ message: 'Fee structures created and balances generated' });
});
The allocation policy is a business decision the school admin makes. Some schools apply payments to tuition first, then boarding, then extras. Others let the parent specify. Your system should support both: a default allocation order and an optional override where the parent picks a category.
The parent payment flow works like this: the parent logs into the portal (or receives a payment link via SMS), sees their child's fee breakdown and balance, chooses an amount and fee category, and pays via M-Pesa or card.
The critical piece is the metadata. Every payment must carry enough information to update the right student's balance for the right fee category. Paystack metadata flows through the entire transaction lifecycle, from initialization through webhooks.
// Initialize a fee payment
app.post('/api/payments/initialize', async (req, res) => {
const { studentId, feeBalanceId, amount, paymentMethod } = req.body;
// Look up the student and fee balance
const student = await db.query('SELECT * FROM students WHERE id = $1', [studentId]);
const feeBalance = await db.query(
`SELECT fb.*, fs.fee_type, fs.term, fs.academic_year
FROM fee_balances fb
JOIN fee_structures fs ON fb.fee_structure_id = fs.id
WHERE fb.id = $1`,
[feeBalanceId]
);
if (amount > feeBalance[0].balance) {
return res.status(400).json({ error: 'Amount exceeds outstanding balance' });
}
// Generate a unique reference
const reference = `FEES-${student[0].admission_number}-T${feeBalance[0].term}-${Date.now()}`;
if (paymentMethod === 'mpesa') {
// Use Paystack Charge API for M-Pesa STK push
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: student[0].parent_email || `${student[0].admission_number}@school.portal`,
amount, // Already in cents
currency: 'KES',
reference,
mobile_money: {
phone: student[0].parent_phone,
provider: 'mpesa',
},
metadata: {
student_id: student[0].id,
admission_number: student[0].admission_number,
student_name: `${student[0].first_name} ${student[0].last_name}`,
fee_balance_id: feeBalance[0].id,
fee_type: feeBalance[0].fee_type,
term: feeBalance[0].term,
academic_year: feeBalance[0].academic_year,
class: student[0].class,
},
}),
});
const data = await response.json();
// Save the pending payment
await db.insert('payments', {
reference,
student_id: student[0].id,
fee_balance_id: feeBalance[0].id,
amount,
status: 'pending',
payment_method: 'mpesa',
});
return res.json({ status: 'pending', reference, message: 'Check your phone for the M-Pesa prompt' });
}
// For card payments, use Initialize Transaction (redirect or popup)
const response = 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: student[0].parent_email || `${student[0].admission_number}@school.portal`,
amount,
currency: 'KES',
reference,
callback_url: `${process.env.BASE_URL}/payment/verify?ref=${reference}`,
metadata: {
student_id: student[0].id,
admission_number: student[0].admission_number,
student_name: `${student[0].first_name} ${student[0].last_name}`,
fee_balance_id: feeBalance[0].id,
fee_type: feeBalance[0].fee_type,
term: feeBalance[0].term,
academic_year: feeBalance[0].academic_year,
class: student[0].class,
},
}),
});
const data = await response.json();
return res.json({ status: 'redirect', authorization_url: data.data.authorization_url });
});
Notice the metadata design. We include everything the webhook handler needs to update the correct balance without making additional database queries. The admission number is there for human readability when you review transactions in the Paystack dashboard. The fee_balance_id is what the code uses to update the right record.
The reference format (FEES-ADM001-T1-1721500000000) is also deliberate. When the bursary office sees this in a bank statement or Paystack export, they can immediately identify it as a fee payment for a specific student and term.
When a parent completes an M-Pesa payment or card transaction, Paystack sends a charge.success webhook to your server. This is where you update the fee balance, record the payment, and trigger the receipt.
const crypto = require('crypto');
app.post('/webhooks/paystack', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature first
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(req.body)
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
// Return 200 immediately, then process
res.status(200).send('OK');
const event = JSON.parse(req.body);
if (event.event === 'charge.success') {
const { reference, amount, currency, metadata } = event.data;
// Idempotency check: skip if already processed
const existing = await db.query(
'SELECT id FROM payments WHERE reference = $1 AND status = $2',
[reference, 'completed']
);
if (existing.length > 0) return;
// Verify amount and currency
if (currency !== 'KES') {
console.error('Unexpected currency:', currency, 'for reference:', reference);
return;
}
// Update the fee balance
await db.query(
`UPDATE fee_balances
SET paid_amount = paid_amount + $1,
status = CASE
WHEN paid_amount + $1 >= total_amount THEN 'paid'
ELSE 'partial'
END
WHERE id = $2`,
[amount, metadata.fee_balance_id]
);
// Update payment record
await db.query(
'UPDATE payments SET status = $1, completed_at = NOW() WHERE reference = $2',
['completed', reference]
);
// Generate receipt
await generateReceipt({
reference,
studentName: metadata.student_name,
admissionNumber: metadata.admission_number,
feeType: metadata.fee_type,
term: metadata.term,
amount,
class: metadata.class,
});
// Send SMS confirmation to parent
const student = await db.query('SELECT * FROM students WHERE id = $1', [metadata.student_id]);
await sendSMS(
student[0].parent_phone,
`School fees payment of KES ${(amount / 100).toLocaleString()} received for ${metadata.student_name} (${metadata.admission_number}). Fee type: ${metadata.fee_type}. Term ${metadata.term}. Receipt: ${reference}`
);
}
});
Key patterns in this handler:
express.raw() ensures the body is not modified before verification.Receipts are not optional in a school context. Parents need them to prove payment. The school needs them for auditing. And the bursary office will get a steady stream of "I paid but it does not show" complaints that are only resolved by showing the receipt.
const PDFDocument = require('pdfkit');
async function generateReceipt(paymentData) {
const {
reference, studentName, admissionNumber,
feeType, term, amount, className
} = paymentData;
const doc = new PDFDocument({ size: 'A5', margin: 40 });
const buffers = [];
doc.on('data', buffers.push.bind(buffers));
// School header
doc.fontSize(16).font('Helvetica-Bold')
.text('SCHOOL NAME', { align: 'center' });
doc.fontSize(10).font('Helvetica')
.text('P.O. Box XXXXX, Nairobi, Kenya', { align: 'center' });
doc.moveDown();
doc.text('OFFICIAL FEES RECEIPT', { align: 'center', underline: true });
doc.moveDown();
// Receipt details
doc.fontSize(10);
doc.text(`Receipt No: ${reference}`);
doc.text(`Date: ${new Date().toLocaleDateString('en-KE')}`);
doc.moveDown();
doc.text(`Student Name: ${studentName}`);
doc.text(`Admission No: ${admissionNumber}`);
doc.text(`Class: ${className}`);
doc.moveDown();
doc.text(`Fee Category: ${feeType}`);
doc.text(`Term: ${term}`);
doc.text(`Amount Paid: KES ${(amount / 100).toLocaleString()}`);
doc.moveDown(2);
// Get updated balance
const balances = await db.query(
`SELECT fs.fee_type, fb.total_amount, fb.paid_amount, fb.balance
FROM fee_balances fb
JOIN fee_structures fs ON fb.fee_structure_id = fs.id
JOIN students s ON fb.student_id = s.id
WHERE s.admission_number = $1
AND fs.term = $2
ORDER BY fs.fee_type`,
[admissionNumber, term]
);
// Balance summary table
doc.font('Helvetica-Bold').text('Current Balance Summary:');
doc.font('Helvetica');
for (const b of balances) {
const paid = (b.paid_amount / 100).toLocaleString();
const bal = (b.balance / 100).toLocaleString();
doc.text(` ${b.fee_type}: Paid KES ${paid} / Balance KES ${bal}`);
}
doc.end();
return new Promise(resolve => {
doc.on('end', () => {
const pdfBuffer = Buffer.concat(buffers);
// Store in your file storage (S3, Supabase Storage, etc.)
resolve(pdfBuffer);
});
});
}
Store every receipt in a durable location (cloud storage, not your local disk). The parent portal should let parents download any past receipt. When a parent calls asking "Where is my receipt for last term?", the bursar should be able to pull it up by admission number in under ten seconds.
Some schools also need receipts in a format that satisfies KRA requirements. If the school is registered for eTIMS, your receipt generation should integrate with the eTIMS API to produce compliant electronic tax invoices. See KRA eTIMS and Payment Receipt Integration Considerations for details on that integration.
The parent portal is where most of the daily interaction happens. A parent logs in (usually by phone number and a PIN or OTP), sees their children listed, and for each child sees the fee breakdown for the current term.
The balance view should look something like this:
// API endpoint: get fee summary for a student
app.get('/api/students/:admissionNumber/fees', async (req, res) => {
const { admissionNumber } = req.params;
const { year, term } = req.query;
const summary = await db.query(
`SELECT
fs.fee_type,
fs.term,
fb.total_amount,
fb.paid_amount,
fb.balance,
fb.status
FROM fee_balances fb
JOIN fee_structures fs ON fb.fee_structure_id = fs.id
JOIN students s ON fb.student_id = s.id
WHERE s.admission_number = $1
AND fs.academic_year = $2
AND fs.term = $3
ORDER BY fs.fee_type`,
[admissionNumber, year || new Date().getFullYear(), term || getCurrentTerm()]
);
const totalOwed = summary.reduce((sum, row) => sum + row.total_amount, 0);
const totalPaid = summary.reduce((sum, row) => sum + row.paid_amount, 0);
const totalBalance = summary.reduce((sum, row) => sum + row.balance, 0);
res.json({
admissionNumber,
term: term || getCurrentTerm(),
year: year || new Date().getFullYear(),
fees: summary.map(row => ({
feeType: row.fee_type,
total: row.total_amount,
paid: row.paid_amount,
balance: row.balance,
status: row.status,
})),
totals: { owed: totalOwed, paid: totalPaid, balance: totalBalance },
});
});
Display amounts in KES (dividing by 100 from your cents-based storage) and show each category on its own line. Parents want to know exactly what they still owe for boarding versus what they owe for tuition. A single "total balance" number is not enough.
The portal also needs a payment history view. For each payment, show the date, amount, fee category it was applied to, payment method (M-Pesa or card), and a link to download the receipt. This history answers the question "Did my payment go through?" without anyone needing to call the school.
Two weeks before term starts, the school needs to remind parents about outstanding balances. One week before the reporting date, they send another round. Your system should automate this entirely.
// Scheduled job: send fee reminders
async function sendFeeReminders(academicYear, term) {
// Get all students with outstanding balances
const studentsWithBalances = await db.query(
`SELECT
s.admission_number,
s.first_name,
s.last_name,
s.parent_phone,
s.parent_name,
SUM(fb.balance) as total_balance
FROM students s
JOIN fee_balances fb ON fb.student_id = s.id
JOIN fee_structures fs ON fb.fee_structure_id = fs.id
WHERE fs.academic_year = $1
AND fs.term = $2
AND fb.balance > 0
AND s.status = 'active'
GROUP BY s.id
ORDER BY total_balance DESC`,
[academicYear, term]
);
for (const student of studentsWithBalances) {
const balanceKES = (student.total_balance / 100).toLocaleString();
const message = `Dear ${student.parent_name}, the fee balance for ${student.first_name} ${student.last_name} (${student.admission_number}) for Term ${term} is KES ${balanceKES}. Pay via the school portal: ${process.env.PORTAL_URL}. Thank you.`;
await sendSMS(student.parent_phone, message);
// Log the reminder
await db.insert('reminder_logs', {
student_id: student.id,
type: 'fee_reminder',
message,
sent_at: new Date(),
});
}
return { sent: studentsWithBalances.length };
}
Practical tips for SMS reminders in the Kenyan context:
Reconciliation is the boring part that prevents financial chaos. The school collects fees through Paystack, Paystack settles to the school bank account (minus fees), and the bursary office needs to confirm that what left the parents' M-Pesa wallets actually arrived in the school account.
There are three levels of reconciliation:
charge.success webhook should match a payment record in your database. Run a daily job that pulls the transaction list from Paystack's Transaction List API and compares it against your payments table. Flag any mismatches.// Daily reconciliation job
async function dailyReconciliation(date) {
// Fetch all transactions for the date from Paystack
const paystackTransactions = await fetchPaystackTransactions(date);
// Fetch all completed payments from our database for the date
const ourPayments = await db.query(
'SELECT * FROM payments WHERE DATE(completed_at) = $1 AND status = $2',
[date, 'completed']
);
const paystackRefs = new Set(paystackTransactions.map(t => t.reference));
const ourRefs = new Set(ourPayments.map(p => p.reference));
// Find payments in Paystack but not in our system (missed webhooks)
const missingInOurSystem = paystackTransactions.filter(t => !ourRefs.has(t.reference));
// Find payments in our system but not in Paystack (should not happen)
const missingInPaystack = ourPayments.filter(p => !paystackRefs.has(p.reference));
// Find amount mismatches
const amountMismatches = paystackTransactions.filter(t => {
const ours = ourPayments.find(p => p.reference === t.reference);
return ours && ours.amount !== t.amount;
});
if (missingInOurSystem.length > 0 || missingInPaystack.length > 0 || amountMismatches.length > 0) {
// Alert the bursary office
await sendReconciliationAlert({
date,
missingInOurSystem,
missingInPaystack,
amountMismatches,
});
}
return {
date,
totalPaystackTransactions: paystackTransactions.length,
totalOurPayments: ourPayments.length,
missingInOurSystem: missingInOurSystem.length,
missingInPaystack: missingInPaystack.length,
amountMismatches: amountMismatches.length,
};
}
For schools, reconciliation is not just a technical exercise. The bursary officer reports to the school board. The board wants to know total collections for the term, how much is outstanding, and whether the bank balance matches. Your system should generate these summary reports automatically. A weekly reconciliation report sent to the finance committee saves the bursar hours of manual work.
For more on reconciliation patterns, see Reconciling M-Pesa Payments Received Through Paystack.
Metadata is the glue that connects a Paystack transaction to your internal records. Get it wrong, and you spend hours manually matching payments to students. Get it right, and everything reconciles automatically.
Here is the metadata structure we use for school fee payments:
const metadata = {
// Student identification
student_id: 'uuid-here',
admission_number: 'ADM/2024/001',
student_name: 'Jane Wanjiku Kamau',
class: 'Form 2 East',
// Fee identification
fee_balance_id: 'uuid-here',
fee_type: 'tuition',
term: 1,
academic_year: 2026,
// Custom fields (displayed in Paystack dashboard)
custom_fields: [
{ display_name: 'Student', variable_name: 'student', value: 'Jane Wanjiku Kamau (ADM/2024/001)' },
{ display_name: 'Fee Category', variable_name: 'fee_category', value: 'Tuition - Term 1, 2026' },
{ display_name: 'Class', variable_name: 'class', value: 'Form 2 East' },
],
};
The custom_fields array is what appears in the Paystack dashboard when someone views the transaction. This is for the humans on the operations team. The top-level fields (student_id, fee_balance_id) are for your code.
Rules for school payment metadata:
A school fees portal is a real product that real Kenyan schools need. It combines payment integration, database design, webhook handling, reconciliation, and user-facing features. These are the exact skills you develop in a full-stack engineering program.
The McTaba M-Pesa Integration course (KES 9,999) teaches you to build payment collection systems with STK push, callbacks, and reconciliation. 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 everything in this article plus authentication, database design, deployment, and more. You ship working products that handle real money.
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