McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. School Fee Instalment Plans with Paystack
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

School Fee Instalment Plans with Paystack

Use Paystack charge_authorization (not the Subscriptions API) for school fee instalments because amounts vary and schedules follow the school calendar, not monthly intervals. Collect the first payment through checkout to get the authorization code, then charge remaining instalments on your schedule. Build an instalment tracker with per-student records, due dates, amounts, and payment status.

In this article

  1. 1. Why the Subscriptions API Does Not Work for School Fees
  2. 2. The Instalment Data Model
  3. 3. Setting Up Instalment Plans
  4. 4. Collecting the First Payment and Authorization
  5. 5. Charging Subsequent Instalments
  6. 6. Reminders and Parent Communication
  7. 7. Handling Multiple Children Per Parent

Why the Subscriptions API Does Not Work for School Fees

School fee billing breaks every assumption of the Subscriptions API:

  • Amounts vary. Term 1 might be 150,000 NGN, Term 2 might be 120,000 NGN (no exam fees). Instalments within a term may not be equal.
  • Schedules follow the school calendar. Due dates are January 15, May 1, September 1, not "monthly." They shift each year.
  • Billing is per student, not per account. A parent might have three children in different classes, each with different fee structures.
  • The billing ends. When the school year ends or the student graduates, billing stops. There is no indefinite subscription.

Authorization codes with charge_authorization give you the flexibility to handle all of this.

This article is part of the subscriptions and recurring billing guide.

The Instalment Data Model

CREATE TABLE students (
  id SERIAL PRIMARY KEY,
  parent_user_id INTEGER REFERENCES users(id),
  full_name VARCHAR(200) NOT NULL,
  class VARCHAR(50),
  admission_number VARCHAR(50) UNIQUE
);

CREATE TABLE fee_schedules (
  id SERIAL PRIMARY KEY,
  student_id INTEGER REFERENCES students(id),
  academic_year VARCHAR(10) NOT NULL,
  term VARCHAR(20) NOT NULL,
  total_fee INTEGER NOT NULL,
  currency VARCHAR(3) DEFAULT 'NGN',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE instalments (
  id SERIAL PRIMARY KEY,
  fee_schedule_id INTEGER REFERENCES fee_schedules(id),
  instalment_number INTEGER NOT NULL,
  amount INTEGER NOT NULL,
  due_date DATE NOT NULL,
  status VARCHAR(20) DEFAULT 'pending',
  paid_at TIMESTAMPTZ,
  paystack_reference VARCHAR(100),
  reminder_sent BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_instalments_due ON instalments(due_date, status);

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

Setting Up Instalment Plans

async function createInstalmentPlan(studentId, academicYear, term, totalFee, instalmentDates) {
  // Create fee schedule
  var schedule = await db.query(
    'INSERT INTO fee_schedules (student_id, academic_year, term, total_fee) VALUES ($1, $2, $3, $4) RETURNING id',
    [studentId, academicYear, term, totalFee]
  );

  var scheduleId = schedule.rows[0].id;

  // Calculate instalment amounts
  var numInstalments = instalmentDates.length;
  var baseAmount = Math.floor(totalFee / numInstalments);
  var remainder = totalFee - (baseAmount * numInstalments);

  for (var i = 0; i < instalmentDates.length; i++) {
    // Add remainder to the first instalment
    var amount = i === 0 ? baseAmount + remainder : baseAmount;

    await db.query(
      'INSERT INTO instalments (fee_schedule_id, instalment_number, amount, due_date) VALUES ($1, $2, $3, $4)',
      [scheduleId, i + 1, amount, instalmentDates[i]]
    );
  }

  return scheduleId;
}

// Example: Term 1 fees of 150,000 NGN in 3 instalments
createInstalmentPlan(
  studentId,
  '2026/2027',
  'Term 1',
  15000000, // 150,000 NGN in kobo
  ['2026-09-01', '2026-10-01', '2026-11-01']
);

Collecting the First Payment and Authorization

async function collectFirstInstalment(parentEmail, studentId, scheduleId) {
  var firstInstalment = await db.query(
    'SELECT id, amount FROM instalments WHERE fee_schedule_id = $1 AND instalment_number = 1',
    [scheduleId]
  );

  var instalment = firstInstalment.rows[0];
  var student = await db.query('SELECT full_name, class FROM students WHERE id = $1', [studentId]);

  var response = await axios.post(
    'https://api.paystack.co/transaction/initialize',
    {
      email: parentEmail,
      amount: instalment.amount,
      callback_url: 'https://yourschool.com/fees/callback',
      metadata: {
        custom_fields: [
          { display_name: 'Student', variable_name: 'student', value: student.rows[0].full_name },
          { display_name: 'Class', variable_name: 'class', value: student.rows[0].class },
          { display_name: 'Instalment', variable_name: 'instalment', value: '1 of 3' },
        ],
        instalment_id: instalment.id,
        schedule_id: scheduleId,
      },
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data.data.authorization_url;
}

// Webhook handler: save authorization and mark first instalment paid
function handleFirstFeePayment(eventData) {
  var auth = eventData.authorization;
  var metadata = eventData.metadata;

  if (auth.reusable) {
    saveParentAuthorization(eventData.customer.email, auth);
  }

  db.query(
    'UPDATE instalments SET status = $1, paid_at = NOW(), paystack_reference = $2 WHERE id = $3',
    ['paid', eventData.reference, metadata.instalment_id]
  );
}

Charging Subsequent Instalments

// Run daily via cron
async function processSchoolFeeInstalments() {
  var dueToday = await db.query(
    'SELECT i.id, i.amount, i.instalment_number, fs.student_id, s.parent_user_id, u.email FROM instalments i JOIN fee_schedules fs ON i.fee_schedule_id = fs.id JOIN students s ON fs.student_id = s.id JOIN users u ON s.parent_user_id = u.id WHERE i.due_date = CURRENT_DATE AND i.status = $1',
    ['pending']
  );

  for (var j = 0; j < dueToday.rows.length; j++) {
    var instalment = dueToday.rows[j];
    var paymentMethod = await getDefaultPaymentMethod(instalment.parent_user_id);

    if (!paymentMethod) {
      console.log('No payment method for parent ' + instalment.parent_user_id);
      await sendManualPaymentReminder(instalment.email, instalment);
      continue;
    }

    var authCode = await getAuthorizationCode(paymentMethod.id);
    var reference = 'SCHOOLFEE_' + instalment.id + '_' + Date.now();

    var result = await chargeAuthorization(
      instalment.email,
      instalment.amount,
      authCode,
      reference
    );

    if (result.status === 'success') {
      await db.query(
        'UPDATE instalments SET status = $1, paid_at = NOW(), paystack_reference = $2 WHERE id = $3',
        ['paid', reference, instalment.id]
      );
      await sendInstalmentReceiptEmail(instalment.email, instalment);
    } else {
      await db.query(
        'UPDATE instalments SET status = $1 WHERE id = $2',
        ['failed', instalment.id]
      );
      await sendInstalmentFailedEmail(instalment.email, instalment, result.gateway_response);
    }
  }
}

Reminders and Parent Communication

// Run daily: send reminders for upcoming instalments
async function sendUpcomingInstalmentReminders() {
  // 7-day reminder
  var sevenDays = await db.query(
    'SELECT i.id, i.amount, i.due_date, i.instalment_number, s.full_name as student_name, u.email FROM instalments i JOIN fee_schedules fs ON i.fee_schedule_id = fs.id JOIN students s ON fs.student_id = s.id JOIN users u ON s.parent_user_id = u.id WHERE i.due_date = CURRENT_DATE + INTERVAL '7 days' AND i.status = $1 AND i.reminder_sent = false',
    ['pending']
  );

  for (var i = 0; i < sevenDays.rows.length; i++) {
    var row = sevenDays.rows[i];
    await sendEmail(row.email, 'instalment_reminder_7days', {
      studentName: row.student_name,
      amount: (row.amount / 100).toLocaleString(),
      dueDate: row.due_date,
      instalmentNumber: row.instalment_number,
    });

    await db.query('UPDATE instalments SET reminder_sent = true WHERE id = $1', [row.id]);
  }
}

School fee communication needs to be clear and respectful. Parents are often managing tight budgets. State the student name, the amount, and the due date. Include a link to pay early if they want to.

For failed payments, avoid aggressive language. "We were unable to process the payment for [Student]'s school fees. Please ensure funds are available and we will try again in 3 days, or click here to pay with a different card." This is a community, not a collections agency.

Handling Multiple Children Per Parent

// Get all instalments for a parent across all children
async function getParentFeeOverview(parentUserId) {
  var results = await db.query(
    'SELECT s.full_name as student, s.class, fs.term, fs.academic_year, i.instalment_number, i.amount, i.due_date, i.status FROM instalments i JOIN fee_schedules fs ON i.fee_schedule_id = fs.id JOIN students s ON fs.student_id = s.id WHERE s.parent_user_id = $1 ORDER BY s.full_name, i.due_date',
    [parentUserId]
  );

  // Group by student
  var byStudent = {};
  for (var i = 0; i < results.rows.length; i++) {
    var row = results.rows[i];
    if (!byStudent[row.student]) {
      byStudent[row.student] = {
        class: row.class,
        instalments: [],
        totalDue: 0,
        totalPaid: 0,
      };
    }
    byStudent[row.student].instalments.push(row);
    if (row.status === 'pending' || row.status === 'failed') {
      byStudent[row.student].totalDue = byStudent[row.student].totalDue + row.amount;
    } else {
      byStudent[row.student].totalPaid = byStudent[row.student].totalPaid + row.amount;
    }
  }

  return byStudent;
}

One parent can have one authorization code used for all their children's fees. The same card pays for multiple students. Build a parent dashboard that shows all children, their fee schedules, and payment history in one view.

Key Takeaways

  • ✓Use charge_authorization, not the Subscriptions API, for school fees. Amounts vary per instalment and schedules follow the school calendar.
  • ✓Collect the first instalment through Paystack checkout. This captures the parent's card and creates the reusable authorization.
  • ✓Track instalments per student per term in your database. Each instalment has a due date, amount, and payment status.
  • ✓Send reminders 7 days and 3 days before each instalment due date. Parents need time to ensure funds are available.
  • ✓Handle missed instalments carefully. Education regulators may have rules about suspending a student for non-payment.
  • ✓Support multiple children per parent. One card can be used to pay fees for siblings across different classes.

Frequently Asked Questions

Can I use the Paystack Subscriptions API for school fee collection?
It is not ideal. School fees have variable amounts per instalment, follow the school calendar rather than monthly intervals, and end after a set number of payments. The Subscriptions API's fixed amount and standard intervals make it a poor fit. Use charge_authorization instead.
What if a parent does not have a card?
Offer bank transfer as a manual payment option for each instalment. Send them a payment link before each due date. You will not be able to auto-charge them, but you can still track instalments and send reminders.
How do I handle partial payments?
If a parent wants to pay less than the full instalment amount, accept a partial payment through checkout (not charge_authorization, since you cannot partial-charge for less than the instalment). Record the partial amount and adjust the remaining balance for the next instalment.
Should I charge penalties for late payments?
This is a school policy decision, not a technical one. If late fees apply, add a penalty charge to the next instalment amount. Be transparent about the penalty policy and communicate it clearly to parents at enrollment.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Subscriptions and Recurring Billing: Complete Guide

Everything you need to build recurring billing on Paystack. Covers the subscriptions API, plans, authorization codes for manual charges, dunning, lifecycle webhooks, cancellation, and real-world patterns for SaaS, memberships, school fees, and loan collection across Africa.

Read more

Charging a Saved Card with Paystack Authorization Codes

A complete walkthrough of the Paystack charge_authorization endpoint. Learn how to charge a customer's saved card without them re-entering card details, handle success and failure responses, build idempotent charges, and avoid the common mistakes that cause double charges or missed payments.

Read more

Handling Failed Recurring Charges and Dunning on Paystack

Recurring charges fail. Cards expire, accounts run dry, banks decline for opaque reasons. This guide covers every failure scenario in Paystack recurring billing, shows you how to read gateway responses, build a retry strategy, implement a dunning state machine, and handle the higher failure rates typical in African card markets.

Read more

Building Your Own Billing Engine on Paystack Authorizations

When the Subscriptions API is too rigid for your billing model, you build your own engine on top of Paystack authorization codes. This guide walks through the database schema, charge scheduler, billing state machine, retry queue, invoice generation, and reconciliation process you need to run reliable recurring billing.

Read more

Building a Dunning Email Sequence for Paystack Failures

A dunning email sequence recovers revenue from failed recurring charges. This guide walks through the timing, content, and automation of each email in the sequence. Covers the first failure notification, retry updates, card update prompts, and final suspension warnings, with code for automating the entire flow.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.