Bonaventure OgetoBy Bonaventure Ogeto|

Building a School Fees Portal for a Kenyan School

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.

Why Kenyan Schools Need a Digital Fees Portal

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:

  • Student registry. Every student identified by their admission number, linked to a class, stream, and parent contact.
  • Fee structure definition. Tuition, boarding, activity levy, transport, and any other categories, set per class and per term.
  • Payment collection. M-Pesa (the most common method by far), cards for parents who prefer them, and Pesalink for high-value payments.
  • Balance tracking. Real-time view of what each student owes, has paid, and still has outstanding per fee category.
  • Receipts. Automatic, official receipts generated on every successful payment.
  • Reconciliation. Matching payments received through Paystack to internal fee records and ultimately to the school bank account.

We will build each of these pieces. The payment layer uses Paystack, which handles M-Pesa, cards, and Pesalink through a single API.

Student Registration and the Data Model

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:

  • Amounts in cents. Paystack expects amounts in the smallest currency unit. KES 45,000 tuition is stored as 4500000. This avoids floating-point issues and matches what Paystack returns in webhooks.
  • Boarding status on the student. This determines which fee categories apply. A day scholar does not pay boarding fees.
  • Generated balance column. The balance auto-calculates as total_amount - paid_amount. You update paid_amount when payments come in.
  • Separate fee structures per class and term. Form 4 fees differ from Form 1 fees. Term 1 fees might include a one-time admission charge that Term 2 does not.

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.

Designing Fee Structures for Kenyan Schools

Kenyan school fees are never a single number. A typical secondary school might have this breakdown for Term 1:

Fee CategoryBoarding (KES)Day Scholar (KES)
Tuition25,00025,000
Boarding18,0000
Activity Levy3,0003,000
Transport05,000
Computer Lab2,0002,000
Total48,00035,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.

Collecting Payments via M-Pesa and Cards

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.

Webhook Handler and Balance Updates

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:

  • Return 200 immediately. Paystack will retry if it does not get a 200 within a reasonable time. Do the heavy processing after responding.
  • Idempotency check. If Paystack retries the webhook (which it will if your server was slow the first time), you do not want to double-credit the student's balance.
  • Use raw body for signature verification. The signature is computed on the raw request body, not a parsed JSON object. Using express.raw() ensures the body is not modified before verification.
  • SMS confirmation. Parents trust SMS more than they trust a web portal. The confirmation message includes the student name, admission number, amount, and fee type so there is no ambiguity.

Payment Receipt Generation

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.

Fee Balance Tracking and the Parent Portal

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.

Bulk Payment Reminders via SMS

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:

  • Use a registered sender ID. Parents are more likely to read and trust an SMS from "SCHOOL NAME" than from a random short code. Register your sender ID with your SMS provider (Africa's Talking, Twilio, or similar).
  • Include the portal link. The parent should be able to tap and pay directly. Reduce friction between "I saw the reminder" and "I made the payment."
  • Respect timing. Do not send reminders at 6 AM or 10 PM. Business hours only.
  • Keep records. Log every reminder sent. When a parent says "I never got a reminder," you can show the delivery report.
  • Personalize. Including the student name and exact balance makes the reminder feel real, not generic. A message that says "KES 18,000 outstanding" gets more attention than "Please pay your fees."

Reconciliation with School Accounts

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:

  1. Transaction level. Every 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.
  2. Settlement level. Paystack batches transactions into settlements. Each settlement has an ID and a total amount. Compare the settlement amount against the sum of transactions in that settlement. Account for Paystack's fees (the settlement amount is transactions minus fees).
  3. Bank level. The settlement amount should match a deposit in the school bank account. This requires either bank statement parsing or a manual confirmation step.
// 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 Design for School Payments

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:

  • Always include the admission number. It is the one identifier that everyone at the school recognizes. The UUID means nothing to the bursar.
  • Always include the term and fee type. A payment without this context is just a number. "KES 25,000 from 0712345678" tells you nothing. "KES 25,000 for tuition, Term 1, ADM/2024/001" tells you everything.
  • Keep metadata under Paystack's size limits. Paystack limits metadata size. Do not stuff the entire student record in there. Include identifiers and labels, not full records.

Learn to Build Payment Systems Like This

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.

Key Takeaways

  • Use the student admission number as the primary key in your payment system. Every transaction must link back to a specific student, term, and fee category.
  • Structure fees into categories (tuition, boarding, activity, transport) rather than a single lump sum. Parents pay in installments, so your system must track partial payments per category.
  • Paystack metadata is where you encode the student ID, term, fee type, and class. This metadata flows through to webhooks and makes reconciliation possible without guesswork.
  • M-Pesa is the primary payment method for most Kenyan parents. Use the Paystack Charge API with mobile_money provider set to "mpesa" for direct STK push.
  • Generate PDF receipts automatically on successful payment. Schools need official receipts, and parents need proof of payment for disputes and record-keeping.
  • Build a reconciliation flow that matches Paystack settlements against your internal fee ledger. The bursary office needs to know which students have paid, not just the total amount collected.
  • Bulk SMS reminders before term starts reduce the back-and-forth between parents and the school office. Use the fee balance data you already have to personalize each message.

Frequently Asked Questions

Can parents pay school fees via M-Pesa through this portal?
Yes. The portal uses the Paystack Charge API with the mobile_money provider set to "mpesa." When a parent initiates a payment, Paystack sends an STK push to their phone. The parent enters their M-Pesa PIN and the payment is processed. The system updates the student's fee balance automatically and sends an SMS confirmation.
How do I handle partial fee payments?
Track each fee category separately with a paid_amount and a computed balance column. When a payment comes in, increment the paid_amount for the specific fee category. The status changes from "unpaid" to "partial" until the balance reaches zero, at which point it becomes "paid." Parents pay what they can, when they can, and the system keeps an accurate running total.
What if a parent has multiple children in the same school?
Link multiple student records to the same parent phone number. The parent portal shows all their children and the fee status for each one. Payments are always tied to a specific student and fee category through the metadata, so there is no confusion even if a parent pays for two children on the same day.
How do I reconcile Paystack payments with the school bank account?
Run a daily reconciliation job that fetches transactions from the Paystack API and compares them against your payments table. Then match Paystack settlement amounts (transactions minus fees) against deposits in the school bank account. Flag any discrepancies for the bursary office to investigate.
Does the portal need to comply with KRA eTIMS requirements?
If the school is registered for eTIMS, yes. Every fee payment should generate a compliant electronic tax invoice through the eTIMS API. This is separate from the Paystack integration but should be triggered by the same webhook that processes the payment. The receipt the parent receives should include the eTIMS invoice number.

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