---
title: "Create lessons"
method: POST
path: "/v2/lessons/"
tags: ["Lessons"]
---

# Create lessons

`POST /v2/lessons/`

Create up to BATCH_MAX_ITEMS lessons in a single request.

The ``data`` array must contain at least one item and at most
``BATCH_MAX_ITEMS`` items. Each item is processed independently — a failure
on one row does not abort the rest. Response is 200 if all items succeed,
207 if any fail, 400 if the request envelope is empty or over the cap.

See ``api_v2/docs/batch-responses.md`` for the 200/207/400 status convention
and per-item error code vocabulary.

**Required OAuth scope:** `lessons:write`

## Request body

- LessonCreateRequestEnvelope — JSON:API envelope for POST /v2/lessons/. The ``data`` field is always an array — single-create is a list of one. Cap enforcement (<=100 items) lives in the endpoint, NOT here — that gives us HTTP 400 via ``ValidationError`` instead of Pydantic's 422.
  - `data` LessonCreateData[], required — List of lesson items to create.
    - `type` 'lessons', required — Must be "lessons".
    - `attributes` LessonCreateRequest, required — Request schema for creating a new lesson. ``type`` admits ``HTML``, ``MODULAR``, and ``QUIZ``. An HTML lesson carries its body in ``content_html``; a MODULAR lesson carries an ordered ``content_items`` array and forbids lesson-level ``content_html``; a QUIZ lesson references an existing quiz via ``quiz_id`` and forbids both ``content_html`` and ``content_items``. The schema's ``Literal`` is the single, reviewable gate — the service layer is permissive.
      - `course_id` string, required — Obfuscated ID of the parent course.
      - `type` 'HTML' | 'MODULAR' | 'QUIZ', required — Lesson type. One of "HTML", "MODULAR", or "QUIZ".
      - `title` string, required — Lesson title.
      - `description_html` string — Optional HTML description shown above the lesson body.
      - `content_html` string, nullable — HTML body of the lesson. Required and must be non-empty when ``type`` is "HTML"; forbidden when ``type`` is "MODULAR". The underlying model column permits empty strings, but the API rejects empty content on create because an HTML lesson with no body is not a useful resource.
      - `content_items` ContentItemRequest[], nullable — Ordered content blocks. Valid only when ``type`` is "MODULAR". Capped at 15 items; at most one QUIZ and one RATING item.
        - `id` string, nullable — Obfuscated content-item id. Present = update existing child; omitted = create new.
        - `type` 'HTML' | 'VIDEO' | 'AUDIO' | 'QUIZ' | 'RATING', required — Content item type.
        - `header` string — Optional header shown above the block.
        - `content_html` string, nullable — HTML body. Only valid when type is "HTML".
        - `asset_id` string, nullable — Obfuscated asset id. Only valid when type is "VIDEO" or "AUDIO".
        - `quiz_id` string, nullable — Obfuscated quiz id. Only valid when type is "QUIZ".
        - `rating` RatingConfig — Rating configuration carried by a ``RATING`` content item. ``rating_scale`` is a **proxy** for the course-level ``Course.rating_scale`` (not a ``RatingContentBlock`` column), mirroring ``api/v1/lessons/rating_content_blocks/serializers.py``. Constrained to ``5`` / ``10`` at the type level so an out-of-range value (e.g. ``7``) is a 422; the 10-point capability gate is enforced in the service. Unknown keys are rejected (422) so a typo'd rating field never silently no-ops. Reused as the read shape on ``ContentItemResource``: the builder echoes the effective course ``rating_scale`` and omits the low/high labels when the 10-point flag is off (relying on the endpoint's ``exclude_none``).
          - `rating_question` string, nullable — Main rating question shown to learners.
          - `additional_feedback_question` string, nullable — Optional follow-up feedback question.
          - `include_additional_feedback` boolean, nullable — Whether to show the feedback question.
          - `rating_scale_low_label` string, nullable — Low-end label (10-point scale only).
          - `rating_scale_high_label` string, nullable — High-end label (10-point scale only).
          - `rating_scale` 5 | 10, nullable — Rating scale (5 or 10). Course-level proxy — stored on Course, not the block.
      - `quiz_id` string, nullable — Obfuscated ID of the quiz this lesson references. Required and must be non-empty when ``type`` is "QUIZ"; forbidden for every other type.
      - `order` integer, nullable — Optional ordering value within the parent course. Defaults to max(existing) + 10 when omitted.

## Response `200`

OK

- BatchResultEnvelopeLessonDetailResource
  - `data` union[], required — Per-item results in request order.
    - union
      - BatchSucceededItemLessonDetailResource
        - `status` 'succeeded'
        - `id` string, nullable — Resource ID of the created or updated resource.
        - `result` LessonDetailResource, required — JSON:API resource object for a lesson on the detail/create/update path. Carries ``content_items`` (via ``LessonDetailAttributes``). The **list** route keeps returning the plain ``LessonResource`` so its shape is unchanged.
          - `type` 'lessons' — Always "lessons".
          - `id` string, required — Opaque lesson ID. Use in URL paths.
          - `attributes` LessonDetailAttributes, required — Attributes of a lesson resource on the detail/create/update responses. Extends the list attributes with the MODULAR ``content_items`` array. Kept off the shared ``LessonAttributes`` so the **list** response stays byte-identical (an Optional ``content_items`` on the list type would serialize as ``content_items: null`` — the list route has no ``exclude_none``). ``None`` for non-MODULAR lessons; the detail/create/update routes set ``exclude_none`` so the field is omitted then.
            - `title` string, required — Lesson title.
            - `type` string, required — Lesson type code (e.g. HTML).
            - `order` integer, required — Ordering value within the parent course.
            - `course_id` string, required — Obfuscated ID of the parent course.
            - `description_html` string — HTML description shown above the lesson body.
            - `content_html` string, nullable — HTML body of the lesson (when type="HTML").
            - `quiz_id` string, nullable — Obfuscated ID of the referenced quiz (when type="QUIZ").
            - `external_id` string, required — Stable UUID5 for cross-system correlation.
            - `created_at` string, date-time, required — Timestamp when the lesson was created.
            - `modified_at` string, date-time, required — Timestamp when the lesson was last modified.
            - `content_items` ContentItemResource[], nullable — Ordered content blocks. Present only when type is "MODULAR".
              - …
          - `relationships` object, nullable — Related resource links.
          - `links` object, nullable — Self and related links.
      - BatchFailedItem — Wrapper for a failed item in a 207 batch response.
        - `status` 'failed'
        - `id` string, nullable — Echoed resource ID if the input identified a row.
        - `error` BatchItemError, required — Per-item error inside a 207 batch response. Aligned with JSON:API ``ErrorObject`` field naming (``code`` rather than ``reason``) so consumers can reuse error-handling logic across document-level errors (``ErrorObject`` in ``ErrorEnvelope``) and per-item errors (here). Differences from ``ErrorObject``: ``status``/``title`` are omitted because they're redundant for the 207-batch context (HTTP status is on the envelope, and the title is derivable from ``code``).
          - `code` 'duplicate_email' | 'duplicate_in_batch' | 'duplicate_name' | 'validation_error' | 'not_found' | 'internal_error' | 'not_in_domain' | 'already_enrolled' | 'already_published', required — Machine-readable error code.
          - `source` object, nullable — Pointer to the offending input slot, e.g. {"pointer": "/data/0/attributes/email"}.
          - `detail` string, nullable — Human-readable explanation of the error.
  - `summary` BatchSummary, required — Aggregate counts for a 207 batch response. Invariant: ``succeeded + failed == total``. Enforced by ``@model_validator``.
    - `total` integer, required — Total number of items submitted.
    - `succeeded` integer, required — Number of items that succeeded.
    - `failed` integer, required — Number of items that failed.

## Other responses

- `207` — Multi-Status
- `400` — Bad Request
- `401` — Unauthorized
- `403` — Forbidden
- `422` — Unprocessable Entity
- `500` — Internal Server Error

---

[API](https://skmtc.net/skilljar/apis/skilljar-api-v2.md) · [All operations](https://skmtc.net/skilljar/apis/skilljar-api-v2/llms.txt) · [OpenAPI document](https://skmtc-service-staging.skmtc.workers.dev/v1/apis/skilljar/skilljar-api-v2/revisions/f8025de20169/schema)
