# Staff Portal · Training

Part of AppEngine API Documentation. Full index: https://appengine.appmint.io/documentation.md
## GET /staff-portal/training/courses

**My courses**

`operationId: StaffTrainingController_myCourses`

The caller’s courses — from their requirements and from direct assignments — most urgent first. State, due date and expiry are computed by the server (assigned, in_progress, due_soon ≤14 days, overdue, completed, expiring ≤30 days or renewal open, expired, covered, waived). `attention` marks what the dashboard shows. `launch` is set for courses taken at a link.

#### Signature

```http
GET /staff-portal/training/courses () -> { items: MyCourse[], summary: { total, attention, <state>: n }, mode: "app" \| "link" }
```

#### Access

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

#### Errors

Plus the standard platform errors: `401`, `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` | { items: MyCourse[], summary: { total, attention, <state>: n }, mode: "app" \| "link" } |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |

## POST /staff-portal/training/courses/launch

**Start / resume a course**

`operationId: StaffTrainingController_launch`

Opens the caller’s enrollment (a fresh one when the completion is expiring or expired), marks it in progress, and returns where to go: the course app’s per-learner start/resume link when the app is configured, else the course’s own launchUrl. Completion is not self-reported — HR marks it, or the course app reports it. For a course backed by a Content Studio post the caller is enrolled on the course engine (a fresh post_progress when renewing) and `url` is the site’s `content-player` page for that post (`<page>/<post slug>`); completion then comes from the post_progress.

#### Signature

```http
POST /staff-portal/training/courses/launch (body) -> { url, item: MyCourse }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | NOT_YOUR_COURSE | That is not one of your courses | Unknown key. | — |
| `409` | NO_LAUNCH_LINK | This course has no link yet. Ask HR. | No launchUrl and no course-app link. | — |
| `400` | KEY_REQUIRED | key is required | `key` is missing. | — |

Plus the standard platform errors: `401`, `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. |

### Request body

```json
{
  "key": "req:66f1c0ffee"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { url, item: MyCourse } |
| `400` | key is required — `key` is missing. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |
| `404` | That is not one of your courses — Unknown key. |
| `409` | This course has no link yet. Ask HR. — No launchUrl and no course-app link. |
| `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. |

## GET /staff-portal/training

**My training**

`operationId: StaffTrainingController_mine`

The caller’s courses, policies, floor sign-offs and certifications from the readiness engine, most urgent first, each with the content to show and the actions available now.

#### Signature

```http
GET /staff-portal/training () -> { items }
```

#### Access

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

#### Errors

Plus the standard platform errors: `401`, `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` | { items } |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |

## GET /staff-portal/training/at-clock-in

**Training required at clock-in**

`operationId: StaffTrainingController_atClockIn`

What the clock-in gate says for this station (block / warn) plus station-scoped items coming due. `completableNow` marks short own-content courses and policies that can be done at the clock.

#### Signature

```http
GET /staff-portal/training/at-clock-in (station?: string) -> { station, effect, overrideActive, required: [TrainingItem & { effect, completableNow, estimatedMinutes }] }
```

#### Access

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

#### Errors

Plus the standard platform errors: `401`, `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. |
| `station` | query | string | — | Station about to be worked (matches bm_schedule.station). |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { station, effect, overrideActive, required: [TrainingItem & { effect, completableNow, estimatedMinutes }] } |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |

## POST /staff-portal/training/{requirementId}/start

**Start a course**

`operationId: StaffTrainingController_start`

Creates the caller’s enrollment for the requirement (a fresh one for a renewal) and marks it in progress. Own-content courses only; marketplace courses open in the course app. For a course backed by a Content Studio post (`content.source` = post) it enrolls the caller on the course engine once (their post_progress) and returns the item; the course itself is opened from POST /staff-portal/training/courses/launch.

#### Signature

```http
POST /staff-portal/training/{requirementId}/start (requirementId: string) -> { item }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | POST_NOT_FOUND | The Content Studio course linked to this training no longer exists. Ask HR. | The bm_course’s postId names a post that was deleted. | — |
| `409` | LINK_COURSE | This course is taken at its link | A link or course-app course: use POST /staff-portal/training/courses/launch (MARKETPLACE_COURSE for course-app courses). | — |
| `400` | NOT_A_COURSE | A policy requirement is not started — acknowledge it | The requirement is not a course. | — |

Plus the standard platform errors: `401`, `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. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | A policy requirement is not started — acknowledge it — The requirement is not a course. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |
| `404` | The Content Studio course linked to this training no longer exists. Ask HR. — The bm_course’s postId names a post that was deleted. |
| `409` | This course is taken at its link — A link or course-app course: use POST /staff-portal/training/courses/launch (MARKETPLACE_COURSE for course-app courses). |
| `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 /staff-portal/training/{requirementId}/complete

**Complete a course**

`operationId: StaffTrainingController_complete`

Completes the caller’s in-progress enrollment. Courses with an assessment need `score` at or above the pass mark (rule evidence.minScore, else the course’s). Records expiry from the rule’s renewal and hands the enrollment to the readiness engine as evidence.

#### Signature

```http
POST /staff-portal/training/{requirementId}/complete (requirementId: string, body) -> { item }
```

#### Access

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

#### Notes

- A course backed by a Content Studio post is never completed here: it completes when the caller finishes it in the course player, and its score is marked by the server.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `422` | ASSESSMENT_FAILED | Score below the pass mark | score < passingScore; the attempt is recorded. | — |
| `409` | POST_COURSE | This course records your progress as you go — finishing it in the course is all it takes. | The course is backed by a Content Studio post: it completes itself in the course player. | — |
| `400` | NOT_A_COURSE | Only a course is completed here | The requirement is a policy, sign-off or certification. | — |

Plus the standard platform errors: `401`, `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. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Request body

```json
{
  "score": 90
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | Only a course is completed here — The requirement is a policy, sign-off or certification. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |
| `409` | This course records your progress as you go — finishing it in the course is all it takes. — The course is backed by a Content Studio post: it completes itself in the course player. |
| `422` | Score below the pass mark — score < passingScore; the attempt is recorded. |
| `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 /staff-portal/training/{requirementId}/acknowledge

**Acknowledge a policy**

`operationId: StaffTrainingController_acknowledge`

E-signs the current version of the requirement’s policy. Signature time, IP and user agent are stamped by the server.

#### Signature

```http
POST /staff-portal/training/{requirementId}/acknowledge (requirementId: string, body) -> { item }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | SIGNATURE_INVALID | The signature is incomplete | No consent, or neither a typed name nor an image. | — |
| `409` | NO_POLICY_ATTACHED | No policy is attached to this requirement yet | The policy rule names no policy. | — |

Plus the standard platform errors: `401`, `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. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Request body

```json
{
  "signature": {
    "typedName": "Ana Ruiz",
    "consent": true
  }
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | The signature is incomplete — No consent, or neither a typed name nor an image. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `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. |
| `409` | No policy is attached to this requirement yet — The policy rule names no policy. |
| `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 /staff-portal/training/qr-session

**Start a training session (QR / kiosk)**

`operationId: StaffTrainingController_qrSession`

Issues a 30-minute training-only session. `{ code }` redeems a one-time QR code; `{ employeeId, pin?, cardUid? }` signs in at a kiosk exactly like quick-card sign-in; signed in with no body = a session for yourself plus a QR code (10 minutes, single use) to continue on another device. An owner/admin may send `{ forEmployeeId }` to get a code for someone else.

#### Signature

```http
POST /staff-portal/training/qr-session (body) -> { session: { token, expiresAt, via, station, employee: { id, name } }, items, qr?: { code, expiresAt, path } }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | ORG_REQUIRED | orgid header is required | No `orgid` header. | — |
| `401` | TRAINING_SESSION_REQUIRED | Scan a training code, sign in at the kiosk, or sign in | No code, kiosk sign-in or login. | — |
| `403` | FORBIDDEN | Only a manager or admin can create a code for someone else | Creating a code for another person without a manager or admin role. | — |

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. |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { session: { token, expiresAt, via, station, employee: { id, name } }, items, qr?: { code, expiresAt, path } } |
| `400` | orgid header is required — No `orgid` header. |
| `401` | Scan a training code, sign in at the kiosk, or sign in — No code, kiosk sign-in or login. |
| `403` | Only a manager or admin can create a code for someone else — Creating a code for another person without a manager or admin role. |
| `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. |

## GET /staff-portal/training/session

**My training (session)**

`operationId: StaffTrainingController_sessionItems`

Same as GET /staff-portal/training for the session holder.

#### Signature

```http
GET /staff-portal/training/session () -> { session, items }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { session, items } |
| `401` | Training session required — No `x-training-session` header. |
| `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. |

## GET /staff-portal/training/session/at-clock-in

**At-clock-in (session)**

`operationId: StaffTrainingController_sessionAtClockIn`

#### Signature

```http
GET /staff-portal/training/session/at-clock-in (station?: string) -> As GET /staff-portal/training/at-clock-in
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |
| `station` | query | string | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | As GET /staff-portal/training/at-clock-in |
| `401` | Training session required — No `x-training-session` header. |
| `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 /staff-portal/training/session/end

**End the training session**

`operationId: StaffTrainingController_endSession`

#### Signature

```http
POST /staff-portal/training/session/end () -> { ended: true }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { ended: true } |
| `401` | Training session required — No `x-training-session` header. |
| `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 /staff-portal/training/session/courses/launch

**Start / resume a course (session)**

`operationId: StaffTrainingController_sessionLaunch`

Kiosk / QR version of POST /staff-portal/training/courses/launch, for the session holder only. `key` is the item key `req:<requirementId>` (or an `enr:<enrollmentId>` from My courses).

#### Signature

```http
POST /staff-portal/training/session/courses/launch (body) -> { url, item }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |
| `400` | KEY_REQUIRED | key is required | `key` is missing. | — |

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. |
| `x-training-session` | header | string | — | Token from POST /staff-portal/training/qr-session (or send it as body.sessionToken). |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { url, item } |
| `400` | key is required — `key` is missing. |
| `401` | Training session required — No `x-training-session` header. |
| `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 /staff-portal/training/session/{requirementId}/start

**Start a course (session)**

`operationId: StaffTrainingController_sessionStart`

#### Signature

```http
POST /staff-portal/training/session/{requirementId}/start (requirementId: string) -> { item }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |
| `400` | NOT_A_COURSE | A policy requirement is not started — acknowledge it | The requirement is not a course. | — |
| `409` | NO_COURSE_ATTACHED | No course is attached to this requirement yet | The course rule names no course. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | A policy requirement is not started — acknowledge it — The requirement is not a course. |
| `401` | Training session required — No `x-training-session` header. |
| `409` | No course is attached to this requirement yet — The course rule names no course. |
| `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 /staff-portal/training/session/{requirementId}/complete

**Complete a course (session)**

`operationId: StaffTrainingController_sessionComplete`

#### Signature

```http
POST /staff-portal/training/session/{requirementId}/complete (requirementId: string) -> { item }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |
| `400` | NOT_A_COURSE | Only a course is completed here | The requirement is a policy, sign-off or certification. | — |
| `409` | NOT_STARTED | Start the course first | There is no in-progress enrollment. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | Only a course is completed here — The requirement is a policy, sign-off or certification. |
| `401` | Training session required — No `x-training-session` header. |
| `409` | Start the course first — There is no in-progress enrollment. |
| `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 /staff-portal/training/session/{requirementId}/acknowledge

**Acknowledge a policy (session)**

`operationId: StaffTrainingController_sessionAcknowledge`

#### Signature

```http
POST /staff-portal/training/session/{requirementId}/acknowledge (requirementId: string) -> { item }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `401` | TRAINING_SESSION_REQUIRED | Training session required | No `x-training-session` header. | — |
| `400` | NOT_A_POLICY | Only a policy is acknowledged | The requirement is not a policy. | — |
| `409` | NO_POLICY_ATTACHED | No policy is attached to this requirement yet | The policy rule names no policy. | — |

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. |
| `x-training-session` | header | string | yes | Token from POST /staff-portal/training/qr-session. |
| `requirementId` | path | string | yes | bm_requirement_rule sk — must be one of the caller’s own requirements. |

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { item } |
| `400` | Only a policy is acknowledged — The requirement is not a policy. |
| `401` | Training session required — No `x-training-session` header. |
| `409` | No policy is attached to this requirement yet — The policy rule names no policy. |
| `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. |

