POST /api/v1/programs/{programId}/lessons
Writes a lesson. It is born a draft — invisible to members until published —
and lands last in its scope: inside courseId if you give one, among the loose
lessons of the program otherwise.
Resources can be attached in the same call, or later one by one.
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed |
ADMIN | Allowed |
MEMBER | Refused |
| Requirement | Rule for this endpoint |
|---|---|
| Business role | None — writing content is the same gesture as composing the structure |
| Resource scope | Program of the school attached to the API key |
| Resource state | An archived program, or an archived course, answers 403 |
| Parameter | Type | Required | Description |
|---|---|---|---|
programId | UUID | yes | Program visible from the key's school |
| Header | Required | Description |
|---|---|---|
x-api-key | yes | API key created for this school |
Idempotency-Key | yes | Stable key for this creation intention |
X-Correlation-Id | no | Caller correlation id, returned or generated |
| Field | Type | Required | Nullable | Description |
|---|---|---|---|---|
title | string | yes | no | 3 to 160 characters |
courseId | UUID | no | yes | Course of the same program. null leaves the lesson loose |
content | string | no | yes | Markdown, up to 20 000 characters |
videoUrl | string | no | yes | A Vimeo, YouTube or Bunny Stream link. Stored in canonical form |
resources | array | no | no | Up to 50 resources, attached in the same transaction |
externalId | string | no | yes | Your stable identifier for the lesson, unique inside the program (case-insensitive): 1 to 32 letters, digits, ., -, _, starting with a letter or digit. null clears it |
audience | string[] | no | no | Who the lesson is for: up to 5 of all, freelance, employee, agency, saas. all cannot be combined. [] clears it |
level | string | no | yes | essential or deep_dive. null clears it |
prerequisites | string[] | no | no | Up to 20 externalIds of other lessons of the program (they may not exist yet). Never the lesson's own externalId. [] clears them |
videoMinutes | integer | no | yes | Video duration in minutes, 1 to 600. null clears it |
taskRef | string | no | yes | The task this lesson counts for (e.g. T1.1), same format as externalId. null clears it |
Each resource is {resourceType, title, url?, mediaId?, storagePath?, mimeType?, sizeBytes?}.
A link needs url; a file needs a mediaId from the school's media library
(or a storagePath in the folder of another lesson of the same school), which
you get from Upload a lesson resource.
A storagePath outside the school, or one where no file was uploaded, is a
400; a mediaId of another school is a 404. For a file, the url is not
read: the address is always the one of its media.
Vimeo, YouTube and Bunny Stream videos play inside a lesson; another provider is refused with a message that says so, not silently stored.
{
"title": "Structure a discovery call",
"courseId": "9f2c0a1e-0000-4000-8000-000000000000",
"content": "## The plan\n\nOpen with the outcome.",
"videoUrl": "https://vimeo.com/912440188",
"resources": [
{"resourceType": "link", "title": "Call grid", "url": "https://example.com/grid"}
]
}curl -X POST "$COHORTE_API_BASE_URL/api/v1/programs/$PROGRAM_ID/lessons" \
-H "x-api-key: $COHORTE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"title":"Structure a discovery call","courseId":"'"$COURSE_ID"'"}'201 Created — the lesson exists, as a draft.200 OK — the same Idempotency-Key replayed the original creation.400 Bad Request — missing Idempotency-Key, invalid JSON, a title outside 3–160 characters, a video from another provider, a link without url, a file with neither mediaId nor storagePath, a storagePath outside the school or with no uploaded file behind it, a media that is not a stored file, or more than 50 resources.401 Unauthorized — the key is missing, invalid, or no longer attached to a member.403 Forbidden — the bearer is not OWNER or ADMIN, or the program is archived.404 Not Found — the program is unknown, belongs to another school, courseId is not one of its courses, or a resource's mediaId is not a media of this school.409 Conflict — another lesson of the program already uses this externalId (comparison ignores case).{
"success": true,
"replayed": false,
"data": {
"id": "8c3b1e72-6d4a-4a9f-b2e3-7c8d9e0f1a2b",
"programId": "9f2c0a1e-4b7d-4c1a-9e3f-2a6b8c0d1e2f",
"courseId": "3b8e2d41-7a6c-4f1e-8b25-9c0d1e2f3a4b",
"sectionId": null,
"title": "Structure a discovery call",
"content": null,
"videoUrl": null,
"status": "draft",
"position": 2,
"createdAt": "2026-09-01T09:00:00.000Z",
"updatedAt": "2026-09-01T09:00:00.000Z"
},
"correlationId": "0f6b1d92-5c3e-4a8b-9d71-3e2f4a6b8c10"
}lesson.created event are
written in one transaction.