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.
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:
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.
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 subscriptionsThe 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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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.
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.
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.
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.
Test the full flow in Paystack test mode before accepting real payments. Walk through every scenario:
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.
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