# Content Studio · Participant

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## GET /client/content-studio/mine

**My courses**

`operationId: ContentStudioClientController_mine`

The caller’s courses, applications and trainings — every post_progress they own that is not withdrawn, most recently touched first (up to 200) — with status, % complete and the next item. A post taken down since is left out.

#### Signature

```http
GET /client/content-studio/mine () -> Array of { enrollmentId, course: { id, slug, title, coverImage }, status, percent, next, dueAt }
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | SIGNIN_REQUIRED | Sign in to continue. | No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. | — |

Plus the standard platform errors: `403`, `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | Array of { enrollmentId, course: { id, slug, title, coverImage }, status, percent, next, dueAt } |
| `401` | Sign in to continue. — No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. |
| `403` | You do not have permission to perform this action — The caller is authenticated but lacks the role required by the endpoint, or is acting on another org. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
[
  {
    "enrollmentId": "66f2b1c0ffee",
    "course": {
      "id": "66f2a0c0ffee",
      "slug": "allergen-awareness",
      "title": "Allergen awareness"
    },
    "status": "active",
    "percent": 33,
    "next": "p-quiz-1",
    "dueAt": "2026-10-04T15:02:11.000Z"
  }
]
```

## GET /client/content-studio/{course}

**A course, its outline and my enrollment**

`operationId: ContentStudioClientController_outline`

The post as its player shows it: layout, every outline item with its status and — when locked — why, % complete, the next item, and the caller’s post_progress summary (null when not enrolled). Readable before signing in, so a course page can sell itself; the post’s own access rules (open dates, access code, required identity) apply.

#### Signature

```http
GET /client/content-studio/{course} (course: string, code?: string) -> The course view
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | — | No post by that name. | `course` matches no post’s sk or slug (for participants: no published copy). | — |
| `403` | NOT_OPEN | This course is not open yet. | Before the post’s start date, after its end date (“This course has closed.”), or its status has ended it. Body: `{ message, reason: "not-open-yet" \| "closed", stage: "open", title, authenticationType }`. | — |
| `401` | CODE_REQUIRED | This course needs an access code. | The post’s `authenticationType` is `code`, it has a shared `accessCode`, and no code was sent. `reason: "code-required"`, `stage: "access"`. | Ask for the code and send it as `?code=`. |

Plus the standard platform errors: `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `course` | path | string | yes | The post’s sk or slug. |
| `code` | query | string | — | Access code, when the post’s access mode asks for one. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The course view |
| `401` | This course needs an access code. — The post’s `authenticationType` is `code`, it has a shared `accessCode`, and no code was sent. `reason: "code-required"`, `stage: "access"`. |
| `403` | This course is not open yet. — Before the post’s start date, after its end date (“This course has closed.”), or its status has ended it. Body: `{ message, reason: "not-open-yet" \| "closed", stage: "open", title, authenticationType }`. |
| `404` | No post by that name. — `course` matches no post’s sk or slug (for participants: no published copy). |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

Example response:

```json
{
  "course": {
    "id": "66f2a0c0ffee",
    "slug": "allergen-awareness",
    "title": "Allergen awareness",
    "layout": "sidebar",
    "navigation": "sidebar",
    "steps": 3,
    "dueInDays": 14
  },
  "enrollment": {
    "id": "66f2b1c0ffee",
    "status": "active",
    "enrolledAt": "2026-09-20T15:02:11.000Z",
    "dueAt": "2026-10-04T15:02:11.000Z",
    "percent": 33
  },
  "outline": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "type": "video",
      "status": "done",
      "percent": 100
    }
  ],
  "items": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "status": "done",
      "percent": 100,
      "step": 1
    },
    {
      "id": "p-quiz-1",
      "title": "Check your knowledge",
      "pageId": "p-quiz-1",
      "depth": 0,
      "kind": "page",
      "type": "quiz",
      "status": "open",
      "percent": 0,
      "step": 2
    },
    {
      "id": "p-plan",
      "title": "Your allergen plan",
      "pageId": "p-plan",
      "depth": 0,
      "kind": "page",
      "type": "form",
      "status": "locked",
      "lockReason": "Opens when “Check your knowledge” scores 80 or more",
      "percent": 0,
      "step": 3
    }
  ],
  "next": "p-quiz-1"
}
```

## POST /client/content-studio/{course}/enroll

**Enroll myself**

`operationId: ContentStudioClientController_enroll`

Creates the caller’s post_progress for the post (status active, `dueAt` from the post’s `course.dueInDays`), or returns the one they already have — calling it twice does not enroll twice. The post’s access rules apply.

#### Signature

```http
POST /client/content-studio/{course}/enroll (course: string, code?: string) -> The course view, with `enrollment` set
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | SIGNIN_REQUIRED | Sign in to continue. | No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. | — |
| `404` | — | No post by that name. | `course` matches no post’s sk or slug (for participants: no published copy). | — |
| `403` | NOT_OPEN | This course is not open yet. | Before the post’s start date, after its end date (“This course has closed.”), or its status has ended it. Body: `{ message, reason: "not-open-yet" \| "closed", stage: "open", title, authenticationType }`. | — |
| `500` | — | The enrollment could not be saved. | The post_progress could not be written. | Retry. |

Plus the standard platform errors: `429`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `course` | path | string | yes | The post’s sk or slug. |
| `code` | query | string | — | Access code, when the post’s access mode asks for one. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The course view, with `enrollment` set |
| `401` | Sign in to continue. — No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. |
| `403` | This course is not open yet. — Before the post’s start date, after its end date (“This course has closed.”), or its status has ended it. Body: `{ message, reason: "not-open-yet" \| "closed", stage: "open", title, authenticationType }`. |
| `404` | No post by that name. — `course` matches no post’s sk or slug (for participants: no published copy). |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | The enrollment could not be saved. — The post_progress could not be written. |

Example response:

```json
{
  "course": {
    "id": "66f2a0c0ffee",
    "slug": "allergen-awareness",
    "title": "Allergen awareness",
    "layout": "sidebar",
    "navigation": "sidebar",
    "steps": 3,
    "dueInDays": 14
  },
  "enrollment": {
    "id": "66f2b1c0ffee",
    "status": "active",
    "enrolledAt": "2026-09-20T15:02:11.000Z",
    "dueAt": "2026-10-04T15:02:11.000Z",
    "percent": 33
  },
  "outline": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "type": "video",
      "status": "done",
      "percent": 100
    }
  ],
  "items": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "status": "done",
      "percent": 100,
      "step": 1
    },
    {
      "id": "p-quiz-1",
      "title": "Check your knowledge",
      "pageId": "p-quiz-1",
      "depth": 0,
      "kind": "page",
      "type": "quiz",
      "status": "open",
      "percent": 0,
      "step": 2
    },
    {
      "id": "p-plan",
      "title": "Your allergen plan",
      "pageId": "p-plan",
      "depth": 0,
      "kind": "page",
      "type": "form",
      "status": "locked",
      "lockReason": "Opens when “Check your knowledge” scores 80 or more",
      "percent": 0,
      "step": 3
    }
  ],
  "next": "p-quiz-1"
}
```

## GET /client/content-studio/{course}/items/{itemId}

**Open an item**

`operationId: ContentStudioClientController_item`

One item’s page content, when it is open. Reading needs no enrollment — a first lesson opens for anyone the post’s access rules let in, with locks worked out on no progress. When the caller is enrolled, `progress` and `answer` are theirs, so a step can be resumed or corrected. `page.reviewed` says the page waits for a person after it is sent in. The page’s answer key is never returned.

#### Signature

```http
GET /client/content-studio/{course}/items/{itemId} (course: string, itemId: string, code?: string) -> { item: { id, title, prev, next }, page: { id, title, summary, content, type, duration, reviewed } \| null, progress, answer, enrolled }
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | — | No post by that name. | `course` matches no post’s sk or slug (for participants: no published copy). | — |
| `403` | LOCKED | <why it is locked> | The item’s conditions do not hold yet. The body carries `reason: "locked"` and the lock reason as `message`. | Finish what the message names first. |
| `401` | CODE_REQUIRED | This course needs an access code. | The post’s `authenticationType` is `code`, it has a shared `accessCode`, and no code was sent. `reason: "code-required"`, `stage: "access"`. | Ask for the code and send it as `?code=`. |

Plus the standard platform errors: `429`, `500`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `course` | path | string | yes | The post’s sk or slug. |
| `itemId` | path | string | yes | Outline (toc) item id — a page id also works for the item that shows it. |
| `code` | query | string | — | Access code, when the post’s access mode asks for one. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { item: { id, title, prev, next }, page: { id, title, summary, content, type, duration, reviewed } \| null, progress, answer, enrolled } |
| `401` | This course needs an access code. — The post’s `authenticationType` is `code`, it has a shared `accessCode`, and no code was sent. `reason: "code-required"`, `stage: "access"`. |
| `403` | <why it is locked> — The item’s conditions do not hold yet. The body carries `reason: "locked"` and the lock reason as `message`. |
| `404` | No post by that name. — `course` matches no post’s sk or slug (for participants: no published copy). |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | An unexpected error occurred. Our team has been notified. — An unhandled server-side failure. |

## POST /client/content-studio/{course}/items/{itemId}/progress

**Report an item**

`operationId: ContentStudioClientController_progress`

What happened on a page with nothing to send in: `done`, how much was watched (`percent`, 0–100, only ever rises) or a `score`. A score is ignored on a page with an answer key — only the server marks those (see /answer). `done` on a page with reviewers does not finish it: the item goes to `review.status = pending`. An item already approved is left as it is. When every page is done the post_progress turns `completed` with `completedAt`, and a `post_progress.saved` event is raised (Business Made readiness listens to it).

#### Signature

```http
POST /client/content-studio/{course}/items/{itemId}/progress (course: string, itemId: string, code?: string, body) -> The course view
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | SIGNIN_REQUIRED | Sign in to continue. | No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. | — |
| `403` | — | Enroll in this course first. | The caller has no active post_progress for this post. | POST /client/content-studio/{course}/enroll |
| `404` | — | No post by that name. | `course` matches no post’s sk or slug (for participants: no published copy). | — |
| `500` | — | Progress could not be saved. | The post_progress could not be written. | Retry. |

Plus the standard platform errors: `429`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `course` | path | string | yes | The post’s sk or slug. |
| `itemId` | path | string | yes | Outline (toc) item id — a page id also works for the item that shows it. |
| `code` | query | string | — | Access code, when the post’s access mode asks for one. |

### Request body

```json
{
  "percent": 72
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The course view |
| `401` | Sign in to continue. — No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. |
| `403` | Enroll in this course first. — The caller has no active post_progress for this post. |
| `404` | No post by that name. — `course` matches no post’s sk or slug (for participants: no published copy). |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | Progress could not be saved. — The post_progress could not be written. |

Example response:

```json
{
  "course": {
    "id": "66f2a0c0ffee",
    "slug": "allergen-awareness",
    "title": "Allergen awareness",
    "layout": "sidebar",
    "navigation": "sidebar",
    "steps": 3,
    "dueInDays": 14
  },
  "enrollment": {
    "id": "66f2b1c0ffee",
    "status": "active",
    "enrolledAt": "2026-09-20T15:02:11.000Z",
    "dueAt": "2026-10-04T15:02:11.000Z",
    "percent": 33
  },
  "outline": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "type": "video",
      "status": "done",
      "percent": 100
    }
  ],
  "items": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "status": "done",
      "percent": 100,
      "step": 1
    },
    {
      "id": "p-quiz-1",
      "title": "Check your knowledge",
      "pageId": "p-quiz-1",
      "depth": 0,
      "kind": "page",
      "type": "quiz",
      "status": "open",
      "percent": 0,
      "step": 2
    },
    {
      "id": "p-plan",
      "title": "Your allergen plan",
      "pageId": "p-plan",
      "depth": 0,
      "kind": "page",
      "type": "form",
      "status": "locked",
      "lockReason": "Opens when “Check your knowledge” scores 80 or more",
      "percent": 0,
      "step": 3
    }
  ],
  "next": "p-quiz-1"
}
```

## POST /client/content-studio/{course}/items/{itemId}/answer

**Send in an item’s answer**

`operationId: ContentStudioClientController_answer`

Saves the page’s form values to one form_submission per item (owned by the caller’s post_progress, written again on each save) and reports the item. `done: false` keeps a draft without finishing the step. When finished, questions with a right answer are marked by the server against the page’s answer key — the share of keyed questions answered exactly right, 0–100 — and that becomes the item’s `score`. A page with reviewers then waits for review instead of being done.

#### Signature

```http
POST /client/content-studio/{course}/items/{itemId}/answer (course: string, itemId: string, code?: string, body) -> The course view
```

#### Access

Requires a bearer JWT (`Authorization: Bearer <token>`).

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | — | Nothing to save. | `values` is missing or not an object. | — |
| `413` | — | That answer is too large. Upload files instead of pasting them. | `values` is over about 200 KB as JSON. | Upload files and send their references. |
| `409` | — | This was approved and can no longer be changed. | A reviewer already approved this item. | — |
| `401` | SIGNIN_REQUIRED | Sign in to continue. | No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. | — |
| `403` | — | Enroll in this course first. | The caller has no active post_progress for this post. | POST /client/content-studio/{course}/enroll |
| `404` | — | No post by that name. | `course` matches no post’s sk or slug (for participants: no published copy). | — |
| `500` | — | The answer could not be saved. | The form_submission could not be written. | Retry. |

Plus the standard platform errors: `429`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `orgid` | header | string | yes | Organization (tenant) identifier. Every request is scoped to this org; data from other orgs is never visible. Issued with your API credentials. |
| `course` | path | string | yes | The post’s sk or slug. |
| `itemId` | path | string | yes | Outline (toc) item id — a page id also works for the item that shows it. |
| `code` | query | string | — | Access code, when the post’s access mode asks for one. |

### Request body

```json
{
  "values": {
    "q1": "sesame",
    "q2": [
      "peanuts",
      "tree-nuts"
    ]
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The course view |
| `400` | Nothing to save. — `values` is missing or not an object. |
| `401` | Sign in to continue. — No signed-in person (the site’s app token alone is nobody). Body carries `reason: "signin-required"`. |
| `403` | Enroll in this course first. — The caller has no active post_progress for this post. |
| `404` | No post by that name. — `course` matches no post’s sk or slug (for participants: no published copy). |
| `409` | This was approved and can no longer be changed. — A reviewer already approved this item. |
| `413` | That answer is too large. Upload files instead of pasting them. — `values` is over about 200 KB as JSON. |
| `429` | Too Many Requests — More than 100,000 requests from one IP within 5 minutes (configurable per deployment). CORS preflights and requests from inside the platform cluster are not counted. The limiter answers before the error filter, so the body is `{ statusCode, error, message }` with no `path`, `method` or `timeStamp`; the `RateLimit-*` response headers say when the window resets. |
| `500` | The answer could not be saved. — The form_submission could not be written. |

Example response:

```json
{
  "course": {
    "id": "66f2a0c0ffee",
    "slug": "allergen-awareness",
    "title": "Allergen awareness",
    "layout": "sidebar",
    "navigation": "sidebar",
    "steps": 3,
    "dueInDays": 14
  },
  "enrollment": {
    "id": "66f2b1c0ffee",
    "status": "active",
    "enrolledAt": "2026-09-20T15:02:11.000Z",
    "dueAt": "2026-10-04T15:02:11.000Z",
    "percent": 33
  },
  "outline": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "type": "video",
      "status": "done",
      "percent": 100
    }
  ],
  "items": [
    {
      "id": "p-intro",
      "title": "Introduction",
      "pageId": "p-intro",
      "depth": 0,
      "kind": "page",
      "status": "done",
      "percent": 100,
      "step": 1
    },
    {
      "id": "p-quiz-1",
      "title": "Check your knowledge",
      "pageId": "p-quiz-1",
      "depth": 0,
      "kind": "page",
      "type": "quiz",
      "status": "open",
      "percent": 0,
      "step": 2
    },
    {
      "id": "p-plan",
      "title": "Your allergen plan",
      "pageId": "p-plan",
      "depth": 0,
      "kind": "page",
      "type": "form",
      "status": "locked",
      "lockReason": "Opens when “Check your knowledge” scores 80 or more",
      "percent": 0,
      "step": 3
    }
  ],
  "next": "p-quiz-1"
}
```

