> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cativa.digital/llms.txt
> Use this file to discover all available pages before exploring further.

# Courses and Certificates

> The Course > Module > Lesson hierarchy, enrollment and progress, badge-gated access, and public certificate verification.

A **Course** in Cativa is a sequence of lessons organized into modules. It lives inside a Group and issues a **certificate** when the student completes it. Course access is gated by a badge, and every certificate carries a code that anyone can verify publicly, with no key, which is ideal for "validate certificate" pages.

Think of a course as a book: the **module** is the chapter, the **lesson** is the page. The student turns page by page, and progress is simply how many pages they have read. The **library card** (the badge) is what lets them check the book out: without it, they can't even open it.

## The hierarchy

```
Course (e.g. "Mentoring 2026")
└── Module (thematic block, e.g. "Fundamentals")
    └── Lesson (content unit: video, text, etc.)
```

A **Course** groups **Modules**, each Module groups **Lessons**. The student advances lesson by lesson, and progress is measured by the share of completed lessons. When the course is completed, the certificate is issued.

<Note>
  A Course is a static entity defined by the tenant admin, in the same spirit as Groups and Spaces. Creating structure (course, module, lesson) requires admin scope. See [Communities and Spaces](/en/concepts/communities-and-spaces) for the full rule on who creates what.
</Note>

## Course access: the badge gates it

Just like Groups, a Course is unlocked by a **badge**. A student can only enroll and consume lessons if they hold the badge the course requires. This is configured in the admin dashboard, not through the partner API.

```
Course "Mentoring 2026"  ----requires----> Badge "Premium"
Course "Intro"           ----no badge required----> any user can enroll
```

The partner **does not configure** the access rule, but **triggers** the transition: by assigning the badge to the user, they gain access to the course and can enroll. In practice, access delivery almost always comes from a badge tied to a purchase or subscription. See the [Grant access via purchase](/en/guides/grant-access-via-purchase) guide.

<Warning>
  Do not treat enrollment as the access mechanism. Access belongs to the badge. Enrolling without the matching badge returns `403 forbidden`. Always model unlocking through the badge, never by creating one course per customer.
</Warning>

## Base URL and authentication

Every call uses the public API base:

```
https://apis.cativalab.digital/tenant/api/v2
```

Authenticate with an API Key in the header, except the public certificate validation endpoint (covered below):

```
Authorization: Bearer cativa_live_...
```

<Note>
  IDs are ULIDs (e.g. `01HQ5ABCDEF1234567890XYZ`). Do not infer response payload shapes from the examples here. For the exact schema of each field, check the **API Reference** tab and filter by the **Course** and **Certificate** tags.
</Note>

## Read courses

```bash theme={null}
# List courses available in the tenant
curl https://apis.cativalab.digital/tenant/api/v2/education/courses \
  -H "Authorization: Bearer cativa_live_..."

# Detail of a specific course
curl https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5ABCDEF1234567890XYZ \
  -H "Authorization: Bearer cativa_live_..."

# Modules (and their lessons) of a course
curl https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5ABCDEF1234567890XYZ/modules \
  -H "Authorization: Bearer cativa_live_..."
```

## Create a course

Creating a course requires admin scope. Partner keys with default scope cannot reach this category. The natural flow is the admin building the structure in the dashboard, but the endpoint exists for administrative automations.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://apis.cativalab.digital/tenant/api/v2/education/courses \
    -H "Authorization: Bearer cativa_live_..." \
    -H "Content-Type: application/json" \
    -d '{
      "groupId": "01HQ5ABCDEF1234567890XYZ",
      "title": "Mentoring 2026",
      "description": "Full mentoring track."
    }'
  ```

  ```js Node theme={null}
  // groupId points to the group that owns the course. Student access comes from
  // the course badge, configured later in the admin panel, not in this POST body.
  const res = await fetch('https://apis.cativalab.digital/tenant/api/v2/education/courses', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      groupId: '01HQ5ABCDEF1234567890XYZ',
      title: 'Mentoring 2026',
      description: 'Full mentoring track.'
    })
  });
  const course = await res.json();
  ```
</CodeGroup>

To edit course metadata, use `PUT /education/courses/{courseId}` (general update) or `PATCH /admin/courses/{courseId}` (targeted admin adjustment). To remove it, `DELETE /admin/courses/{courseId}`.

### Modules and lessons (admin)

The course's internal structure is managed under the `/admin/education/courses/{courseId}` prefix:

```
POST   /admin/education/courses/{courseId}/modules              # create module
PUT    /admin/education/courses/{courseId}/modules/{moduleId}   # edit module
DELETE /admin/education/courses/{courseId}/modules/{moduleId}   # remove module

POST   /admin/education/courses/{courseId}/modules/{moduleId}/lessons   # create lesson
PUT    /admin/education/courses/{courseId}/lessons/{lessonId}           # edit lesson
DELETE /admin/education/courses/{courseId}/lessons/{lessonId}           # remove lesson
```

## From an empty course to an enrolled student

<Steps>
  <Step title="Create the course (admin)">
    `POST /education/courses` with `groupId` and a title. Keep the returned `courseId`.
  </Step>

  <Step title="Add modules and lessons (admin)">
    `POST /admin/education/courses/{courseId}/modules` creates the module; then `POST .../modules/{moduleId}/lessons` creates each lesson. Repeat until the track is built.
  </Step>

  <Step title="Unlock access via a badge">
    Student access comes from the **badge** the course requires (configured in the dashboard). Assign that badge to the user (typically via a purchase or subscription). See [Grant access via purchase](/en/guides/grant-access-via-purchase).
  </Step>

  <Step title="Enroll and complete (student)">
    With the badge, the user calls `POST .../enroll`, advances by marking `PUT .../lessons/{lessonId}/complete`, and receives the certificate once every lesson is done.
  </Step>
</Steps>

## Enrollment and progress (student)

On the student side, the cycle is enroll, track progress, and complete lesson by lesson.

```bash theme={null}
# Enroll the credential's user into the course
curl -X POST https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5.../enroll \
  -H "Authorization: Bearer cativa_live_..."

# Read the user's progress in the course
curl https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5.../progress \
  -H "Authorization: Bearer cativa_live_..."

# Mark a lesson as complete
curl -X PUT https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5.../lessons/01HQ7.../complete \
  -H "Authorization: Bearer cativa_live_..."
```

Illustrative progress response (the authoritative schema is in the **API Reference**, tag **Course**). `progressPercentage` derives from `completedLessons / totalLessons`:

```json theme={null}
{
  "items": [
    {
      "userId": "01HQ0USER1234567890ABCDEF",
      "userName": "john",
      "completedLessons": 6,
      "totalLessons": 10,
      "progressPercentage": 60.0
    }
  ],
  "total": 1
}
```

The student is always the user tied to the authenticated credential. Do not send `userId` in the body. If the user lacks the badge the course requires, enrollment returns `403 forbidden`.

When every lesson is complete, the course counts as finished and the certificate is issued. The student can download the course certificate PDF:

```bash theme={null}
curl https://apis.cativalab.digital/tenant/api/v2/education/courses/01HQ5.../certificate/pdf \
  -H "Authorization: Bearer cativa_live_..." \
  --output certificate.pdf
```

## Certificates

On course completion, a **certificate** is issued to the student. Each certificate carries a verification code.

### Public verification by code (anonymous)

This is the only endpoint in the family that **needs no key**. It confirms a certificate's authenticity by its code, ideal for a public "validate certificate" page where anyone pastes a code and checks whether it is legitimate.

<CodeGroup>
  ```bash cURL theme={null}
  # No Authorization. Public authenticity verification endpoint.
  curl https://apis.cativalab.digital/tenant/api/v2/certificates/validate/ABCD-1234-EFGH
  ```

  ```js Node theme={null}
  // Anonymous call: no credential header.
  // Use it on a public "validate certificate" landing page.
  const res = await fetch(
    'https://apis.cativalab.digital/tenant/api/v2/certificates/validate/ABCD-1234-EFGH'
  );
  const result = await res.json();
  ```
</CodeGroup>

Illustrative response (full schema in the **API Reference**, tag **Certificate**). The `url` points to the certificate PDF, and `code` is the same validated code:

```json theme={null}
{
  "url": "https://cdn.cativalab.digital/certificates/01HQ2CERT1234567890XYZ.pdf",
  "code": "ABCD-1234-EFGH",
  "certificateId": "01HQ2CERT1234567890XYZ",
  "userId": "01HQ0USER1234567890ABCDEF"
}
```

<Note>
  Because it is anonymous, wire this flow straight into your site without passing the API Key to the browser. The code is the only input needed.
</Note>

### Other certificate reads

```bash theme={null}
# Certificate URL for a specific user
curl https://apis.cativalab.digital/tenant/api/v2/certificates/url/01HQ9.../01HQ2... \
  -H "Authorization: Bearer cativa_live_..."

# Certificate tied to a course
curl https://apis.cativalab.digital/tenant/api/v2/certificates/course/01HQ5... \
  -H "Authorization: Bearer cativa_live_..."
```

### Certificate administration (admin)

The template and manual issuance live under `/admin/certificates`:

```
POST   /admin/certificates                       # create certificate template
GET    /admin/certificates                       # list
GET    /admin/certificates/{certificateId}       # detail
PUT    /admin/certificates/{certificateId}       # edit
DELETE /admin/certificates/{certificateId}       # remove

POST   /admin/certificates/issue                 # issue to a user
POST   /admin/certificates/issue-and-generate    # issue and generate the PDF
```

<Note>
  We do not lay out each certificate response field here. Check the **API Reference** tab under the **Certificate** tag for the exact issuance, template and validation contracts.
</Note>

## Anti-pattern: one course per customer

<Warning>
  Don't create one course per incoming customer. Courses are static content entities, not per-user containers. To customize who has access, use **badges**: a single course and the badge assigned to the right students. Enrollment and the certificate follow the badge, not a copy of the course.
</Warning>

## Common errors and questions

<AccordionGroup>
  <Accordion title="403 forbidden when enrolling the student">
    The **badge** the course requires is missing. Enrollment is not the access mechanism: access belongs to the badge. Assign the badge configured on the course to the user (typically via a purchase/subscription) and retry `enroll`. See [Grant access via purchase](/en/guides/grant-access-via-purchase).
  </Accordion>

  <Accordion title="I enrolled the same student twice">
    Enrollment is idempotent per user and course: enrolling again does not create a second enrollment or reset progress. You can safely retry it in sync jobs.
  </Accordion>

  <Accordion title="All lessons complete but no certificate was issued">
    The certificate is issued when progress reaches 100% (every lesson marked `complete`). Check the user's `progress`: if `completedLessons` is below `totalLessons`, a lesson is still pending. Lessons added to the course later count too and can reopen the pending state.
  </Accordion>

  <Accordion title="Do I need the API Key to validate a certificate on my site?">
    No. `GET /certificates/validate/{code}` is the only endpoint in the family that needs **no key**. Call it straight from the browser on your "validate certificate" landing page. Never put the API Key in the frontend for the other endpoints (those are server-side).
  </Accordion>

  <Accordion title="Should I create one course per customer who buys?">
    No. A course is static content, not a per-user container. A single course serves everyone; the **badge** assigned to the right students is what customizes access. One course per customer becomes dead duplication and breaks enrollment.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Badges as Permissions" icon="shield-check" href="/en/concepts/badges-as-permissions">
    How course access is gated by a badge, without creating new structure.
  </Card>

  <Card title="Grant access via purchase" icon="cart-shopping" href="/en/guides/grant-access-via-purchase">
    The typical flow: an external purchase assigns the badge that enrolls the student.
  </Card>

  <Card title="Events" icon="calendar" href="/en/concepts/events">
    How events and lives sit alongside courses in the community.
  </Card>
</CardGroup>
