Behio Storefront SDK
Customer

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:

  1. the eshop X-Api-Key header (the SDK sends it automatically), and
  2. an authenticated customer session (client.auth.login(...) / useAuth()); the SDK attaches the customer token automatically.

Calls without a customer session return 401.

Endpoints

MethodPathPurpose
GET/storefront/v1/customer/coursesList 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}/completeMark an unlocked lesson as completed (idempotent)
GET/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/quizQuiz for an unlocked lesson (no correct answers included)
POST/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/quiz/submitScore the quiz server-side; passing completes the lesson
GET/storefront/v1/customer/courses/certificatesThe customer's completion certificates
GET/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/tutorThe student's private AI tutor thread for the lesson
POST/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/tutorAsk the AI tutor a question about the lesson (rate limited 20/min)
GET/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/commentsLesson discussion, paginated (?page=1, newest first)
POST/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/commentsPost 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}/noteThe student's private note for the lesson
PUT/storefront/v1/customer/courses/{courseId}/lessons/{lessonId}/noteSave (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}/pdfCertificate as a branded PDF (binary; fetch as a blob)

Error semantics:

StatusMeaning
401No customer session (does not apply to the two public certificate routes)
403Course access expired, a locked lesson (complete/quiz/tutor/comments/note), or a feature the merchant disabled (aiTutorEnabled / discussionEnabled = false)
404Course 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: CourseDetail

Complete 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: CourseProgress

Lesson 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, lessonCompleted

My 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: CertificateVerification

Download 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 thread

Asking 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 404

Private 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); // QuizResult

useCourseCertificates

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 certificate

useLessonTutor

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` automatically

useLessonComments

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 only

useLessonNote

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 blur

Building 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.
  • welcomeText is markdown intended for the top of the course page (instructor greeting, how-to).
  • Video embeds: videoUrl is 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 isExpired on the list and a 403 on the detail as "access expired" UI, not as a generic error.

On this page