Build a Course Platform with Paid Enrolment
Build a course platform with paid enrolment by creating courses with lessons, gating content behind payment verification, and using Paystack to handle both one-time course purchases and monthly subscription access. Initialize a transaction when a student enrols, verify payment via webhook, and grant access by creating an enrolment record. For subscriptions, use Paystack Plans and listen for subscription lifecycle events.
Architecture Overview
A course platform with paid enrolment combines content management with payment gating. The core idea is simple: students cannot access lesson content until they have a verified payment. Everything else (browsing the course catalog, reading descriptions, seeing instructor bios) is free.
The payment flow works like this:
- Student browses the course catalog and selects a course
- They click "Enrol Now" and your backend initializes a Paystack transaction for the course price
- Student pays through Paystack checkout
- Paystack sends a charge.success webhook to your backend
- Your backend creates an enrolment record linking the student to the course
- Student is redirected back to the course page, which now shows lesson content
For subscription-based access, the flow uses Paystack Plans instead of one-time transactions. You create a Plan (say, monthly access at KES 2,000), subscribe the student to it, and Paystack handles recurring billing. Your backend listens for subscription lifecycle webhooks to grant or revoke access.
The tech stack is Node.js with Express, PostgreSQL for storing courses, lessons, students, and enrolments, and a simple frontend that checks enrolment status before rendering lesson content.
Data Model
You need five core tables: courses, lessons, students, enrolments, and progress.
The courses table holds each course with its title, description, instructor name, price (in smallest currency unit), pricing_type (one_time or subscription), and status (draft, published, archived). If the pricing_type is subscription, also store the Paystack plan_code after creating the plan.
The lessons table stores individual lessons within a course. Each lesson has a course_id, title, content (text, video URL, or both), order_index (for sequencing), and is_free flag. Some courses offer the first lesson free as a preview; the is_free flag controls this.
The enrolments table is the access control layer. Each record has:
student_id: Who has accesscourse_id: Which course they can accesstype: one_time or subscriptionstatus: active, expired, or cancelledpaystack_reference: Transaction reference for one-time purchasespaystack_subscription_code: Subscription code for recurring accessexpires_at: Null for one-time purchases, next billing date for subscriptions
The progress table tracks which lessons each student has completed, with timestamps. This table exists independently of enrolment status so that a student who re-subscribes after a gap still sees their previous progress.
When your API receives a request for lesson content, it checks the enrolments table. If the student has an active enrolment for that lesson's course (or if the lesson is marked is_free), return the content. Otherwise, return a 403 with the course price and a payment link.
One-Time Course Purchase
For courses sold as a one-time purchase, initialize a standard Paystack transaction:
var axios = require("axios");
async function enrolOneTime(studentId, courseId) {
var student = await db.query("SELECT * FROM students WHERE id = $1", [studentId]);
var course = await db.query("SELECT * FROM courses WHERE id = $1", [courseId]);
// Check if already enrolled
var existing = await db.query(
"SELECT * FROM enrolments WHERE student_id = $1 AND course_id = $2 AND status = $3",
[studentId, courseId, "active"]
);
if (existing) {
throw new Error("Already enrolled in this course");
}
var reference = "ENROL-" + courseId + "-" + studentId + "-" + Date.now();
var response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: student.email,
amount: course.price,
currency: "KES",
reference: reference,
callback_url: "https://yourplatform.com/courses/" + courseId,
metadata: {
student_id: studentId,
course_id: courseId,
course_title: course.title,
enrolment_type: "one_time"
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data.authorization_url;
}
The metadata includes the student_id and course_id so your webhook handler knows exactly which enrolment to create when payment succeeds. The callback_url points back to the course page so the student lands there after payment.
One-time enrolments never expire. Once the payment is verified, the student has lifetime access to that course. This is the simplest model and works well for standalone courses.
Subscription-Based Access
For subscription-based access, first create a Paystack Plan (do this once per pricing tier, not per student):
async function createCoursePlan(courseId) {
var course = await db.query("SELECT * FROM courses WHERE id = $1", [courseId]);
var response = await axios.post(
"https://api.paystack.co/plan",
{
name: course.title + " Monthly Access",
amount: course.price,
interval: "monthly",
currency: "KES"
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
await db.query(
"UPDATE courses SET paystack_plan_code = $1 WHERE id = $2",
[response.data.data.plan_code, courseId]
);
}
When a student subscribes, initialize a transaction with the plan attached:
async function enrolSubscription(studentId, courseId) {
var student = await db.query("SELECT * FROM students WHERE id = $1", [studentId]);
var course = await db.query("SELECT * FROM courses WHERE id = $1", [courseId]);
var response = await axios.post(
"https://api.paystack.co/transaction/initialize",
{
email: student.email,
amount: course.price,
currency: "KES",
plan: course.paystack_plan_code,
callback_url: "https://yourplatform.com/courses/" + courseId,
metadata: {
student_id: studentId,
course_id: courseId,
enrolment_type: "subscription"
}
},
{
headers: {
Authorization: "Bearer " + process.env.PAYSTACK_SECRET_KEY
}
}
);
return response.data.data.authorization_url;
}
When the student completes this first payment, Paystack creates a subscription and charges them automatically each month. You handle access control by listening for subscription events in your webhook handler.
Webhook Handling for Enrolments
Your webhook handler needs to process several event types:
var crypto = require("crypto");
router.post("/webhooks/paystack", express.raw({ type: "application/json" }), async function(req, res) {
var 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");
}
var event = JSON.parse(req.body);
if (event.event === "charge.success") {
var meta = event.data.metadata;
if (meta.enrolment_type === "one_time") {
await createEnrolment(meta.student_id, meta.course_id, "one_time", event.data.reference, null);
}
}
if (event.event === "subscription.create") {
var meta = event.data.metadata || {};
var subscriptionCode = event.data.subscription_code;
if (meta.course_id) {
await createEnrolment(meta.student_id, meta.course_id, "subscription", null, subscriptionCode);
}
}
if (event.event === "subscription.not_renew" || event.event === "subscription.disable") {
var subscriptionCode = event.data.subscription_code;
await db.query(
"UPDATE enrolments SET status = $1, updated_at = NOW() WHERE paystack_subscription_code = $2",
["expired", subscriptionCode]
);
}
if (event.event === "invoice.payment_failed") {
var subscriptionCode = event.data.subscription.subscription_code;
// Send reminder email but do not revoke access immediately
await sendPaymentFailedEmail(subscriptionCode);
}
res.status(200).send("OK");
});
async function createEnrolment(studentId, courseId, type, reference, subscriptionCode) {
var existing = await db.query(
"SELECT * FROM enrolments WHERE student_id = $1 AND course_id = $2 AND status = $3",
[studentId, courseId, "active"]
);
if (existing) return; // Idempotent: do not create duplicate enrolment
await db.query(
"INSERT INTO enrolments (student_id, course_id, type, status, paystack_reference, paystack_subscription_code) VALUES ($1, $2, $3, $4, $5, $6)",
[studentId, courseId, type, "active", reference, subscriptionCode]
);
await sendWelcomeEmail(studentId, courseId);
}
Handle invoice.payment_failed with grace. Do not immediately revoke access when a payment fails. Send the student a reminder, give them a few days to update their payment method, and only revoke access if the subscription is actually disabled by Paystack.
Content Gating and Access Control
Every API endpoint that serves lesson content must check enrolment status:
router.get("/api/courses/:courseId/lessons/:lessonId", async function(req, res) {
var lesson = await db.query(
"SELECT * FROM lessons WHERE id = $1 AND course_id = $2",
[req.params.lessonId, req.params.courseId]
);
if (!lesson) {
return res.status(404).json({ error: "Lesson not found" });
}
// Free lessons are accessible to everyone
if (lesson.is_free) {
return res.json({ lesson: lesson });
}
// Check enrolment
var enrolment = await db.query(
"SELECT * FROM enrolments WHERE student_id = $1 AND course_id = $2 AND status = $3",
[req.user.id, req.params.courseId, "active"]
);
if (!enrolment) {
var course = await db.query("SELECT price, pricing_type FROM courses WHERE id = $1", [req.params.courseId]);
return res.status(403).json({
error: "Enrolment required",
price: course.price,
pricing_type: course.pricing_type
});
}
// Mark progress
await db.query(
"INSERT INTO progress (student_id, lesson_id, completed_at) VALUES ($1, $2, NOW()) ON CONFLICT (student_id, lesson_id) DO NOTHING",
[req.user.id, lesson.id]
);
res.json({ lesson: lesson });
});
The critical point: content gating happens on the server. The frontend can show or hide UI elements based on enrolment status, but the actual content must never be sent to the browser unless the server confirms the student has access. A determined user can inspect network requests and bypass frontend checks, but they cannot bypass a server-side 403.
For video content, use signed URLs that expire after a short period (say, 2 hours). Generate a new signed URL each time the student loads the lesson page. This prevents URL sharing.
Progress Tracking and Completion Certificates
Track which lessons a student has completed and calculate their overall course progress as a percentage. This gives students a sense of accomplishment and helps you identify drop-off points in your courses.
async function getCourseProgress(studentId, courseId) {
var totalLessons = await db.query(
"SELECT COUNT(*) as count FROM lessons WHERE course_id = $1",
[courseId]
);
var completedLessons = await db.query(
"SELECT COUNT(*) as count FROM progress p JOIN lessons l ON p.lesson_id = l.id WHERE p.student_id = $1 AND l.course_id = $2",
[studentId, courseId]
);
var percentage = Math.round((completedLessons.count / totalLessons.count) * 100);
return {
total: totalLessons.count,
completed: completedLessons.count,
percentage: percentage,
is_complete: percentage === 100
};
}
When a student completes all lessons (100% progress), you can generate a completion certificate. Store this as a record in a certificates table with a unique certificate ID, the student name, course title, and completion date. Generate a PDF or an image on demand using a library like PDFKit.
Progress data is valuable for your business too. If 80% of students drop off at lesson 7, that lesson probably needs improvement. If most students complete the course within 3 weeks, you know the pacing is right.
Deployment and Going Live
Test the full flow in Paystack test mode before accepting real payments. Walk through every scenario:
- One-time purchase: pay, verify enrolment is created, access lessons, check progress tracking
- Subscription: subscribe, verify enrolment, simulate a failed renewal, verify access is handled gracefully
- Re-enrolment: cancel subscription, verify access is revoked, re-subscribe, verify progress is preserved
- Duplicate payment: send the same webhook twice, verify no duplicate enrolment is created
Deploy the backend on Railway, Render, or Vercel with a PostgreSQL database. Make sure the webhook URL is publicly accessible over HTTPS. Set environment variables for your Paystack keys and swap test keys for live keys when ready.
For production, add caching on the enrolment check. Every page load hits the database to verify access, which can slow down the experience. Cache the enrolment status in Redis with a 5-minute TTL. When a new enrolment is created or revoked, invalidate the cache entry.
Monitor two critical metrics: conversion rate (how many students who view a course page actually enrol) and completion rate (how many enrolled students finish the course). These tell you whether your pricing is right and whether your content is engaging.
Key Takeaways
- ✓Gate content access on the server side by checking enrolment records, not on the frontend. A student without a verified enrolment should receive no lesson content from your API.
- ✓Support both one-time purchases (pay once, access forever) and subscriptions (monthly access) using Paystack Initialize Transaction and Plans respectively.
- ✓Create the enrolment record only after receiving the charge.success webhook, not after the redirect. The redirect can be faked; the webhook cannot.
- ✓Store course prices in the smallest currency unit. A course priced at KES 5,000 is stored as 500000 cents in your database.
- ✓For subscription-based access, listen for the subscription.not_renew and invoice.payment_failed events to revoke access gracefully when a student stops paying.
- ✓Track student progress (lessons completed, quizzes passed) independently from payment status. A student who cancels their subscription should still see their progress if they re-subscribe later.
Frequently Asked Questions
- Can I offer free courses alongside paid ones?
- Yes. Set the course price to 0 and skip the Paystack transaction entirely. When a student clicks "Enrol," create the enrolment record directly without initializing a payment. Your content gating logic already checks for an active enrolment, so free and paid courses work through the same access control system.
- How do I handle refunds for course purchases?
- Use the Paystack Refund API. When you process a refund, also update the enrolment status to "refunded" and revoke access to the course content. For subscriptions, cancel the subscription first using the Paystack Subscription endpoint, then issue a refund for the most recent charge if needed.
- Can I offer discounts or promotional pricing?
- Yes. Calculate the discounted price on your backend and pass it as the amount to the Paystack Initialize Transaction call. Store the original price and discount percentage in the enrolment record for your financial records. Do not modify the Paystack Plan amount for subscription discounts; instead, initialize the first transaction at the discounted amount and let subsequent renewals use the full plan price.
- How do I let instructors sell courses and receive payouts?
- Use Paystack subaccounts. Create a subaccount for each instructor with their bank details and commission split. When a student enrols, initialize the transaction with the instructor subaccount. Paystack automatically splits the payment between the instructor and your platform at settlement time.
- What if a student changes their email address?
- Paystack associates subscriptions with the customer email used during the initial transaction. If a student changes their email in your system, the existing subscription continues to work because Paystack uses its internal customer ID, not just the email. However, you should update the customer email on Paystack using the Update Customer endpoint to keep records consistent.
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