Bonaventure OgetoBy Bonaventure Ogeto|

Build a University Application Fee Portal

Build a university application fee portal by requiring payment of the application fee via Paystack before generating an application number and unlocking the document upload and submission sections. Use the charge.success webhook to create the application record, issue the application number, and send submission instructions. Block document upload and application submission until payment is confirmed.

Application Form and Fee Payment

Tables: applicants (name, email, phone, id_number, nationality, dob), applications (applicant_id, intake_year, intake_month, programme_choices[], status, application_number, paystack_ref, paid_at, submitted_at), documents (application_id, type, file_url, verified), intake_periods (year, month, application_fee, deadline).

async function startApplication(applicantEmail, applicantData, intakePeriodId, programmeChoices) {
  var intake = await db.intakePeriods.findById(intakePeriodId);
  if (new Date() > new Date(intake.deadline)) throw new Error('Application deadline has passed');

  var applicant = await db.applicants.upsert({ email: applicantEmail, ...applicantData });
  var reference = 'APP-' + Date.now();

  var application = await db.applications.create({
    applicant_id: applicant.id,
    intake_year: intake.year,
    intake_month: intake.month,
    programme_choices: programmeChoices,
    status: 'pending_payment',
    paystack_ref: reference,
  });

  var res = 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: applicantEmail,
      amount: intake.application_fee,
      currency: 'KES', // Kenya example
      reference,
      metadata: {
        application_id: application.id,
        applicant_id: applicant.id,
        intake: intake.year + '-' + intake.month,
        programmes: programmeChoices.join(', '),
      },
    }),
  });
  return { application, authorizationUrl: (await res.json()).data.authorization_url };
}

// Webhook: generate application number and unlock submission
if (event.event === 'charge.success') {
  var meta = event.data.metadata;
  if (!meta.application_id) return res.sendStatus(200);

  var appYear = new Date().getFullYear().toString().slice(-2);
  var seq = await db.applications.nextSequenceNumber(meta.intake);
  var applicationNumber = 'APP-' + appYear + '-' + seq.toString().padStart(5, '0');

  await db.applications.update(meta.application_id, {
    status: 'payment_confirmed',
    application_number: applicationNumber,
    paid_at: new Date(),
  });

  await sendApplicationConfirmation(meta.applicant_id, applicationNumber, meta.programmes);
}

Document Upload and Admissions Status

// Gate document upload behind payment
async function uploadDocument(applicationId, applicantId, docType, fileUrl) {
  var application = await db.applications.findById(applicationId);
  if (application.applicant_id !== applicantId) throw new Error('Not your application');
  if (application.status === 'pending_payment') throw new Error('Pay the application fee first');

  var requiredDocs = ['kcse_certificate', 'id_copy', 'passport_photo', 'recommendation_letter'];
  if (!requiredDocs.includes(docType)) throw new Error('Invalid document type');

  await db.documents.upsert({ application_id: applicationId, type: docType, file_url: fileUrl, verified: false });
}

// Applicant submits complete application
async function submitApplication(applicationId, applicantId) {
  var application = await db.applications.findById(applicationId);
  if (application.applicant_id !== applicantId) throw new Error('Not your application');
  if (application.status !== 'payment_confirmed') throw new Error('Payment required before submission');

  var requiredDocs = ['kcse_certificate', 'id_copy', 'passport_photo', 'recommendation_letter'];
  var uploadedDocs = await db.documents.findByApplication(applicationId);
  var uploadedTypes = uploadedDocs.map(function(d) { return d.type; });
  var missing = requiredDocs.filter(function(d) { return !uploadedTypes.includes(d); });
  if (missing.length > 0) throw new Error('Missing documents: ' + missing.join(', '));

  await db.applications.update(applicationId, { status: 'submitted', submitted_at: new Date() });
  await sendSubmissionAcknowledgement(applicantId, application.application_number);
}

// Admissions staff updates decision
async function updateAdmissionDecision(applicationId, decision, offeredProgramme, conditions) {
  var validDecisions = ['offered', 'waitlisted', 'rejected'];
  if (!validDecisions.includes(decision)) throw new Error('Invalid decision');

  await db.applications.update(applicationId, {
    status: decision, offered_programme: offeredProgramme, conditions, decided_at: new Date(),
  });

  var application = await db.applications.findById(applicationId);
  if (decision === 'offered') {
    await sendOfferLetter(application.applicant_id, offeredProgramme, conditions);
  } else {
    await sendDecisionEmail(application.applicant_id, decision);
  }
}

Learn More

See build a gym membership app for a related membership and access management pattern after enrollment fees are paid.

Sign up for the McTaba newsletter

Key Takeaways

  • Generate the application number only in the charge.success webhook — never before payment.
  • Gate document upload and final submission behind payment confirmation status.
  • Support multiple programmes per application: one fee covers multiple course choices.
  • Send email with application number and document checklist immediately after payment.
  • Build an admin portal for admissions staff to review applications and update decision status.

Frequently Asked Questions

Should I refund the application fee if the student is rejected?
Application fees are almost universally non-refundable — they cover the cost of processing the application, which occurs regardless of the outcome. State this clearly on the application page before the student pays. The exception is if the application was rejected due to a system error or if the intake was cancelled by the institution before the decision was made.
How do I handle international students who cannot pay in NGN or KES?
Paystack supports USD for Nigerian merchants and GBP/USD for some account types. For international applicants, initialize the transaction with currency: "USD" and let Paystack handle the conversion. Alternatively, create a separate Stripe payment link for international applicants. Track currency paid in the application record for reconciliation.
Can I support fee waivers for students who cannot afford the application fee?
Yes. Add a waiver_code field to your intake_periods table. When a student enters a valid waiver code on the application form, skip the Paystack payment and set the application status directly to "payment_confirmed" with payment_method: "waiver". Log all waivers with the student ID and approving officer for audit purposes.

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