Online Courses
Sell online courses with modules, lessons, drip unlocking and a members area
Overview
A product can carry an online course (LMS): content split into modules → lessons, where a lesson holds a video embed URL, a markdown body and downloadable attachments. When an order containing a course product is paid, Behio automatically creates an enrollment for the buyer. Guest purchases work too: the enrollment is keyed by the order email, and once the customer registers or logs in with that email, the course appears in their account automatically.
Lessons support drip unlocking: each lesson can unlock a number of days after enrollment, or on a fixed date (the later of the two wins). Locked lessons never expose their content: the API returns only the title and unlockAt, so drip cannot be bypassed client-side. Course access can optionally expire N days after enrollment.
A course can also have an independent start date (contentAvailableFrom, epoch ms): the merchant sells now, the course starts later. Until that time EVERY lesson (previews included) is locked with unlockAt >= contentAvailableFrom, so render "Startujeme 15. 1." with a countdown in the member area. Lessons additionally respect their own drip; the later of the two wins.
Courses are managed in the Behio admin (Eshop → Kurzy): create a course on top of an existing product, add modules and lessons, set drip rules, and enroll members manually by email (useful when migrating students from another platform).
Two engagement features come built in:
- Lesson quizzes: a lesson can carry single-choice quiz questions (the merchant writes them or generates them with AI from the lesson text). The quiz payload never contains the correct answers; scoring happens server-side and a passing score (70 % or more) marks the lesson completed automatically.
- Completion certificates: when the student finishes 100 % of the lessons (and the merchant keeps certificates enabled), Behio automatically issues a certificate with a public verification code. Anyone with the code can verify it (no login), and a branded PDF is available for download.
Behio also sends automatic completion nudge e-mails on the merchant's behalf (through the eshop's configured e-mail provider): a "get started" nudge after 3 days with no progress, a "continue where you left off" nudge after 7 days of inactivity, and a congratulations e-mail with the certificate code on completion. Each enrollment receives each nudge at most once; no storefront code is needed.
Authentication
All course endpoints live under storefront/v1/customer/* and require both:
- the eshop
X-Api-Keyheader (the SDK sends it automatically), and - an authenticated customer session (
client.auth.login(...)/useAuth()); the SDK attaches the customer token automatically.
Calls without a customer session return 401.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /storefront/v1/customer/courses | List the customer's enrolled courses with progress |
GET | /storefront/v1/customer/courses/{courseId} | Course player payload (modules + lessons with unlock state) |
POST | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/complete | Mark an unlocked lesson as completed (idempotent) |
GET | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/quiz | Quiz for an unlocked lesson (no correct answers included) |
POST | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/quiz/submit | Score the quiz server-side; passing completes the lesson |
GET | /storefront/v1/customer/courses/certificates | The customer's completion certificates |
GET | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/tutor | The student's private AI tutor thread for the lesson |
POST | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/tutor | Ask the AI tutor a question about the lesson (rate limited 20/min) |
GET | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/comments | Lesson discussion, paginated (?page=1, newest first) |
POST | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/comments | Post a comment, or a reply with parentId (one level) |
DELETE | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/comments/{commentId} | Delete the customer's own comment |
GET | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/note | The student's private note for the lesson |
PUT | /storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/note | Save (upsert) the private note; empty string clears it |
GET | /storefront/v1/course-certificates/{code} | Public certificate verification (no customer session) |
GET | /storefront/v1/course-certificates/{code}/pdf | Certificate as a branded PDF (binary; fetch as a blob) |
Error semantics:
| Status | Meaning |
|---|---|
401 | No customer session (does not apply to the two public certificate routes) |
403 | Course access expired, a locked lesson (complete/quiz/tutor/comments/note), or a feature the merchant disabled (aiTutorEnabled / discussionEnabled = false) |
404 | Course the customer is not enrolled in, a lesson without a quiz, someone else's comment on DELETE, or an unknown certificate code (same response for non-existent ids, so nothing can be probed) |
Types
interface CourseListItem {
courseId: string;
name: string | null; // localized product name
slug: string; // product slug (PDP link)
imageUrl: string | null;
totalLessons: number;
completedLessons: number;
enrolledAt: number; // epoch ms
expiresAt: number | null; // null = lifetime access
isExpired: boolean;
contentAvailableFrom?: number | null; // course start; render a countdown while in the future
}
interface CourseDetail {
courseId: string;
name: string | null;
slug: string;
imageUrl: string | null;
welcomeText: string | null; // markdown, show above the content
contentAvailableFrom?: number | null; // course start; every lesson stays locked until then
enrolledAt: number;
expiresAt: number | null;
totalLessons: number;
completedLessons: number;
aiTutorEnabled: boolean; // render the AI tutor widget only when true
discussionEnabled: boolean; // render the lesson discussion only when true
modules: CourseModule[];
certificate?: { code: string; issuedAt: number } | null; // present at 100 %
}
interface CourseModule {
id: string;
title: string;
lessons: CourseLesson[];
}
interface CourseLesson {
id: string;
title: string;
isPreview: boolean; // preview lessons ignore drip and are always unlocked
isUnlocked: boolean;
unlockAt: number | null; // when a locked lesson unlocks; null once unlocked
isCompleted: boolean;
videoUrl: string | null; // null while locked
content: string | null; // markdown, null while locked
attachments: CourseAttachment[]; // empty while locked
quizQuestionCount: number; // 0 = no quiz; load via getLessonQuiz() once unlocked
}
interface LessonQuiz {
courseId: string;
lessonId: string;
passPercent: number; // 70, the score that completes the lesson
questions: { id: string; question: string; options: string[] }[];
}
interface QuizResult {
courseId: string;
lessonId: string;
totalQuestions: number;
correctCount: number;
scorePercent: number;
passPercent: number;
passed: boolean;
lessonCompleted: boolean; // true when the passing score completed the lesson
results: { // correct answers revealed only AFTER submitting
questionId: string;
selectedIndex: number | null;
correctIndex: number;
correct: boolean;
}[];
progress: { totalLessons: number; completedLessons: number } | null;
}
interface CourseCertificate {
courseId: string;
courseName: string | null;
code: string; // public verification code
issuedAt: number;
}
interface CertificateVerification {
valid: boolean;
code: string;
courseId: string;
courseName: string | null;
studentName: string; // full name, or a masked e-mail (j***@example.com)
issuedAt: number;
}
interface CourseAttachment {
name: string;
url: string;
fileSize?: number; // bytes
}
interface CourseProgress {
courseId: string;
lessonId: string;
totalLessons: number;
completedLessons: number;
}Client methods
List my courses
const { data, error } = await client.customer.getCourses();
// data: { items: CourseListItem[] }Course player payload
Returns modules and lessons in order with per-lesson unlock state. Render locked lessons as teasers (title + unlock date), because their videoUrl / content are null and attachments empty until the server unlocks them:
const { data: course } = await client.customer.getCourse(courseId);
// data: CourseDetailComplete a lesson
Idempotent, so completing the same lesson twice keeps the count stable. Completing a locked lesson is rejected with 403, so drip cannot be bypassed:
const { data } = await client.customer.completeLesson(courseId, lessonId);
// data: CourseProgressLesson quiz
Load the quiz once the lesson is unlocked (quizQuestionCount > 0). The payload never contains the correct answers; submit the chosen option indexes and the server scores them. A score of passPercent (70 %) or more completes the lesson automatically:
const { data: quiz } = await client.customer.getLessonQuiz(courseId, lessonId);
// quiz: LessonQuiz
const { data: result } = await client.customer.submitLessonQuiz(courseId, lessonId, [
{ questionId: quiz.questions[0].id, selectedIndex: 2 },
// ... one answer per question; unanswered questions count as wrong
]);
// result: QuizResult, reveals correctIndex per question, passed, lessonCompletedMy certificates
const { data } = await client.customer.getCourseCertificates();
// data: { items: CourseCertificate[] }Verify a certificate (public)
No customer session required, so you can build a public /certifikat/[code] page with this. Unknown codes return a NOT_FOUND error:
const { data, error } = await client.certificates.verify(code);
// data: CertificateVerificationDownload the certificate PDF
Binary endpoint: the SDK fetches it as a Blob (the X-Api-Key header is required, so a plain <a href> will not work):
const { data: blob } = await client.certificates.downloadPdf(code);
if (blob) {
const url = URL.createObjectURL(blob);
const link = Object.assign(document.createElement("a"), {
href: url,
download: `certifikat-${code}.pdf`,
});
link.click();
URL.revokeObjectURL(url);
}AI tutor ("Ask about this lesson")
Every unlocked lesson carries a private AI tutor thread per student. The answer
sticks to the lesson topic, comes back in the language of the question (Czech by
default) and is grounded in the lesson text. Render the widget only when
CourseDetail.aiTutorEnabled is true.
const thread = await behio.customer.getLessonTutorThread(courseId, lessonId);
// { courseId, lessonId, enabled, items: [{ id, question, answer, createdAt }] }
const message = await behio.customer.askLessonTutor(
courseId,
lessonId,
"Jak se liší drip od pevného data?",
);
// { id, question, answer, createdAt }, append it to the threadAsking is rate limited (20/min). A locked lesson or a disabled tutor returns 403.
Lesson discussion
Paginated top-level comments (newest first) with one level of replies. Author
names are the customer's first name, or a masked e-mail when no name is known.
Render the widget only when CourseDetail.discussionEnabled is true.
const list = await behio.customer.getLessonComments(courseId, lessonId, 1);
// { enabled, page, pageSize, totalCount,
// items: [{ id, body, authorName, isMine, createdAt, replies: [...] }] }
const comment = await behio.customer.postLessonComment(courseId, lessonId, "Super lekce!");
const reply = await behio.customer.postLessonComment(courseId, lessonId, "Souhlasím", comment.id);
await behio.customer.deleteLessonComment(courseId, lessonId, comment.id);
// only the customer's OWN comments; anything else returns 404Private lesson note
One private note per student and lesson, visible only to them. body is an
empty string until the student writes something; saving an empty string clears
the note.
const note = await behio.customer.getLessonNote(courseId, lessonId);
// { courseId, lessonId, body, updatedAt }
await behio.customer.saveLessonNote(courseId, lessonId, "Timestamp 12:30 - skvělý příklad");React hooks
useCourses
"My courses" list for the logged-in customer. Only runs when a customer session exists:
import { useCourses } from "@behio/storefront-sdk/react";
const { courses, isLoading, error, refetch } = useCourses();
// courses: CourseListItem[]useCourse
Course player payload plus a completion mutation in one hook. Completing a lesson automatically refreshes the course query (progress and isCompleted flags update without extra code):
import { useCourse } from "@behio/storefront-sdk/react";
const { course, isLoading, completeLesson, isCompleting } = useCourse(courseId);
// course: CourseDetail | undefined
await completeLesson(lesson.id);Pass null as courseId to keep the hook idle (e.g. before the route param resolves).
useLessonQuiz
Quiz payload plus a submit mutation. A passing submit invalidates the course queries, so progress and isCompleted update automatically:
import { useLessonQuiz } from "@behio/storefront-sdk/react";
const { quiz, submitQuiz, isSubmitting, result } = useLessonQuiz(courseId, lessonId);
const outcome = await submitQuiz(answers); // QuizResultuseCourseCertificates
import { useCourseCertificates } from "@behio/storefront-sdk/react";
const { certificates } = useCourseCertificates();
// certificates: CourseCertificate[]useCertificateVerification
Public verification page (no login):
import { useCertificateVerification } from "@behio/storefront-sdk/react";
const { verification, isLoading, error } = useCertificateVerification(code);
// error with code NOT_FOUND = invalid certificateuseLessonTutor
AI tutor thread + ask mutation for one lesson:
import { useLessonTutor } from "@behio/storefront-sdk/react";
const { messages, enabled, ask, isAsking } = useLessonTutor(courseId, lessonId);
// messages: CourseTutorMessage[] (oldest first); hide the widget when !enabled
await ask("Můžete mi vysvětlit ten příklad jinak?");
// the answer is appended to `messages` automaticallyuseLessonComments
Discussion under a lesson with post/delete mutations:
import { useLessonComments } from "@behio/storefront-sdk/react";
const { comments, totalCount, enabled, postComment, deleteComment } =
useLessonComments(courseId, lessonId, { page: 1 });
await postComment("Super lekce!");
await postComment("Souhlasím", parentCommentId); // one-level reply
await deleteComment(myCommentId); // own comments onlyuseLessonNote
Autosave-friendly private note:
import { useLessonNote } from "@behio/storefront-sdk/react";
const { body, updatedAt, save, isSaving } = useLessonNote(courseId, lessonId);
// render a textarea with defaultValue={body}, call save(value) on blurBuilding a members area
A typical implementation is two pages:
1. "Moje kurzy" account page: from useCourses(), one card per course with a progress bar:
const { courses } = useCourses();
return courses.map((c) => (
<a key={c.courseId} href={`/account/courses/${c.courseId}`}>
<img src={c.imageUrl ?? "/placeholder.png"} alt="" />
<h3>{c.name}</h3>
<progress value={c.completedLessons} max={c.totalLessons} />
{c.isExpired && <span>Přístup vypršel</span>}
</a>
));2. Course player page: from useCourse(courseId), sidebar with modules/lessons, player pane for the selected lesson:
const { course, completeLesson } = useCourse(courseId);
const [activeId, setActiveId] = useState<string | null>(null);
const lessons = course?.modules.flatMap((m) => m.lessons) ?? [];
const active = lessons.find((l) => l.id === activeId) ?? lessons.find((l) => l.isUnlocked);
return (
<div className="grid grid-cols-[280px_1fr]">
<nav>
{course?.modules.map((m) => (
<section key={m.id}>
<h4>{m.title}</h4>
{m.lessons.map((l) => (
<button key={l.id} disabled={!l.isUnlocked} onClick={() => setActiveId(l.id)}>
{l.isCompleted ? "✓" : l.isUnlocked ? "▶" : "🔒"} {l.title}
{!l.isUnlocked && l.unlockAt && (
<time>{new Date(l.unlockAt).toLocaleDateString()}</time>
)}
</button>
))}
</section>
))}
</nav>
<main>
{active?.videoUrl && <iframe src={active.videoUrl} allowFullScreen />}
{active?.content && <Markdown>{active.content}</Markdown>}
{active?.attachments.map((a) => (
<a key={a.url} href={a.url} download>{a.name}</a>
))}
{active && !active.isCompleted && (
<button onClick={() => completeLesson(active.id)}>Označit jako dokončené</button>
)}
</main>
</div>
);Implementation notes:
- Locked lessons render as teasers: show the title, a lock icon and
unlockAt. Never try to fetch their content another way; the server simply does not send it. welcomeTextis markdown intended for the top of the course page (instructor greeting, how-to).- Video embeds:
videoUrlis either an external embed (Vimeo/YouTube, render their iframe) or a Behio-hosted video the merchant uploaded in the admin. Behio-hosted videos arrive as short-lived signed URLs (about 15 minutes), so render them with<video controls>and simply refetch the course payload if playback starts failing after a long session. The signing means a copied URL stops working shortly, so paid content cannot be shared permanently. - Handle
isExpiredon the list and a403on the detail as "access expired" UI, not as a generic error.