Implementing Online Course Sales (Access After Payment) on a Website
Course sales platform is not "add product to WooCommerce". It's a combination of payment gateway, access management system, video hosting, and progress tracking. Each layer has its own failure logic, and an error in any of them means either money loss or content leak to non-paying users.
Architectural Skeleton
Typical schema:
[User] → [Checkout] → [Payment Gateway]
↓
[Webhook Handler]
↓
[Enrollment Service] → [DB: enrollments]
↓
[Access Control Layer]
↓
[LMS / Video Delivery / Downloads]
Webhook handler is critical — it creates the access record after payment confirmation. Attempting to grant access synchronously during redirect after payment is a classic error leading to race conditions on network failures.
Payment Gateways and Integration
Stripe is preferred for international payments. Use stripe.checkout.sessions.create with mode: 'payment' or mode: 'subscription' for recurring model.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: course.title },
unit_amount: course.price_cents,
},
quantity: 1,
}],
metadata: { course_id: course.id, user_id: user.id },
success_url: `${BASE_URL}/courses/${course.slug}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/courses/${course.slug}`,
});
After payment, Stripe sends checkout.session.completed to webhook endpoint. Validate signature (stripe.webhooks.constructEvent), extract metadata.course_id and metadata.user_id, create enrollment.
For CIS markets, integrate YooKassa or Robokassa. Both work via similar scheme: payment notification → signature check → grant access.
Idempotency: webhook may arrive twice. Enrollment record created with unique index by (user_id, course_id) or payment_id — second call simply returns existing record.
Access Management
Table enrollments:
| Field | Type | Description |
|---|---|---|
| id | uuid | primary key |
| user_id | bigint | FK → users |
| course_id | bigint | FK → courses |
| payment_id | varchar | transaction ID from gateway |
| expires_at | timestamp | NULL = perpetual |
| status | enum | active / suspended / refunded |
| created_at | timestamp | purchase date |
Middleware on every protected route checks for active record:
// Laravel Gate
Gate::define('access-course', function (User $user, Course $course) {
return $user->enrollments()
->where('course_id', $course->id)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
});
Video Delivery and Content Protection
Can't give direct video links to users — they'll share them. Options:
Signed URLs (AWS S3 / CloudFront):
$url = $s3->createPresignedRequest(
$s3->getCommand('GetObject', [
'Bucket' => 'courses-bucket',
'Key' => "courses/{$courseId}/lesson-{$lessonId}.mp4",
]),
'+2 hours'
)->getUri();
Link lives 2 hours and tied to specific file. After expiration — 403.
Vimeo Private / Mux: for production with high traffic, use specialized video platforms. Mux provides adaptive streaming (HLS), view analytics, and protection via signed playback tokens:
const playbackId = lesson.mux_playback_id;
const token = await signMuxToken(playbackId, 'video', {
expiration: '2h',
params: { user_id: userId },
});
const src = `https://stream.mux.com/${playbackId}.m3u8?token=${token}`;
For PDF/EPUB — similarly: generate signed URL for download, log each access in content_access_logs.
Progress and Certificates
Lesson progress stored in lesson_progress(user_id, lesson_id, completed_at, watch_percent). Frontend sends completion events:
// Every 30 seconds or on pause/end
videoPlayer.on('timeupdate', debounce(() => {
api.post('/progress', {
lesson_id: lessonId,
watch_percent: Math.round((player.currentTime / player.duration) * 100),
});
}, 5000));
Certificate auto-generated when watch_percent >= 80 for all course lessons. For PDF generation use puppeteer (HTML template render) or PDFKit for simple cases.
Trial Access (Preview)
First 1-2 lessons usually open without payment. Flag is_free_preview at lesson level, check in middleware:
if ($lesson->is_free_preview || Gate::allows('access-course', $course)) {
return $next($request);
}
return redirect()->route('course.buy', $course);
Refunds
On payment refund, Stripe sends charge.refunded. Change enrollments.status = 'refunded', user loses access immediately. For YooKassa — similar webhook payment.canceled.
Implementation Timelines
| Stage | Time |
|---|---|
| Basic Stripe integration + enrollment | 2–3 days |
| Protected video delivery (S3 signed URL) | 1–2 days |
| Progress tracking + UI progress bar | 1–2 days |
| Certificates (PDF generation) | 1 day |
| YooKassa / second gateway integration | 1–2 days |
| Admin dashboard (enrollments, revenue) | 2–3 days |
Minimum viable version — 7–10 working days.
Common Mistakes
Granting access before gateway confirmation. User clicked pay button → landed on success page → got access. Payment may not have gone through. Access granted only via webhook.
Storing video on same server as application. Bandwidth kills server at 50+ concurrent views. Video — only in object storage or specialized provider.
No access logs. On dispute with user ("I paid, no access given") unable to trace event chain. Must log payment_id, webhook timestamp, enrollment_created_at.







