# Business Made · Training

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

**Courses with roster counts**

`operationId: TrainingController_listCourses`

Every course with its requirement rules and per-state counts, plus how courses launch now (`courseApp.mode` app | link).

#### Signature

```http
GET /business-made/training/courses (search?: string, status?: string) -> { courseApp: { connected, mode, provider?, reason? }, data: [{ id, title, description, category, source (post \| own \| link \| marketplace), postId, isLink, launchUrl, provider, durationMinutes, renewalMonths, status, requirements, counts }], total }
```

#### 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. |
| `search` | query | string | — |  |
| `status` | query | string | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { courseApp: { connected, mode, provider?, reason? }, data: [{ id, title, description, category, source (post \| own \| link \| marketplace), postId, isLink, launchUrl, provider, durationMinutes, renewalMonths, status, requirements, counts }], total } |
| `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 /business-made/training/courses/link

**Add a link course**

`operationId: TrainingController_createLinkCourse`

Creates a bm_course with source = link, status active. `durationMinutes` is stored as duration.hours. `renewalMonths` sets the expiry of each completion when no requirement rule sets one. Give either `launchUrl` (taken elsewhere) or `postId` — a Content Studio course post, taken in the site’s course player, whose completion and score come only from each person’s post_progress. With `postId`, `launchUrl` is optional.

#### Signature

```http
POST /business-made/training/courses/link (body) -> The course view
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | COURSE_INVALID | title is required; launchUrl must be an http(s) link | Missing title, neither launchUrl nor postId, bad link, a postId that names no post (“postId does not name a Content Studio course”), negative numbers, unknown category. | — |

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
{
  "title": "Food handler basics",
  "launchUrl": "https://learn.example.com/food-handler",
  "durationMinutes": 45,
  "renewalMonths": 36,
  "category": "safety"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The course view |
| `400` | title is required; launchUrl must be an http(s) link — Missing title, neither launchUrl nor postId, bad link, a postId that names no post (“postId does not name a Content Studio course”), negative numbers, unknown category. |
| `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. |

## PUT /business-made/training/courses/link/{id}

**Change a link course**

`operationId: TrainingController_updateLinkCourse`

#### Signature

```http
PUT /business-made/training/courses/link/{id} (id: string, body) -> The course view
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | COURSE_NOT_FOUND | Course not found | No bm_course has that id. | — |

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. |
| `id` | path | string | yes | bm_course sk |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `200` | The course view |
| `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` | Course not found — No bm_course has that id. |
| `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 /business-made/training/courses/{id}/roster

**Course roster**

`operationId: TrainingController_roster`

Everyone the course applies to (through a requirement rule or a direct assignment) with state, due, completion and expiry, worst first. Rows carry the actions available: mark_completed, mark_not_completed.

#### Signature

```http
GET /business-made/training/courses/{id}/roster (id: string, state?: string, search?: string, location?: string, page?: integer, pageSize?: integer) -> { course, states: [{ value, label, count }], counts, rows, total, page, pageSize, courseApp }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | COURSE_NOT_FOUND | Course not found | No bm_course has that id. | — |
| `400` | BAD_FILTER | state must be one of assigned, in_progress, … | `state` is not a course state. | — |

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. |
| `id` | path | string | yes | bm_course sk |
| `state` | query | string | — |  |
| `search` | query | string | — |  |
| `location` | query | string | — |  |
| `page` | query | integer | — | 0-based |
| `pageSize` | query | integer | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { course, states: [{ value, label, count }], counts, rows, total, page, pageSize, courseApp } |
| `400` | state must be one of assigned, in_progress, … — `state` is not a course state. |
| `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` | Course not found — No bm_course has that id. |
| `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 /business-made/training/courses/{id}/complete

**Mark completed (one or many)**

`operationId: TrainingController_markCompleted`

For each person: completes their open enrollment (or creates one), stamps `manualCompletion` and a `completionHistory` entry, sets expiry from the requirement’s renewal else the course’s, records readiness evidence and emits `journeys.evidence`. `completedAt` defaults to now and cannot be in the future. Per-person results — one failure does not stop the rest.

#### Signature

```http
POST /business-made/training/courses/{id}/complete (id: string, body) -> { courseId, updated, failed, results }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `422` | SCORE_BELOW_PASS | Score is below the pass mark | Per person, in results: score < the rule’s evidence.minScore. | — |
| `409` | POST_COURSE | This is a Content Studio course: completion and score come only from the course itself. Review or approve the person’s work there. | The course is backed by a Content Studio post (postId). Review the person’s work under /content-studio instead. | — |
| `404` | COURSE_NOT_FOUND | Course not found | No bm_course has that id. | — |
| `403` | NOT_ALLOWED_TO_MARK | Only HR or the location manager can record a completion | The caller is neither (or is marking their own training: "Nobody can mark their own training completed"). | — |
| `400` | EMPLOYEES_REQUIRED | employeeIds is required | `employeeIds` is empty. | — |

Plus the standard platform errors: `401`, `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. |
| `id` | path | string | yes | bm_course sk |

### Request body

```json
{
  "employeeIds": [
    "EMP-1043",
    "EMP-1051"
  ],
  "completedAt": "2026-09-20",
  "note": "Certificate on file"
}
```

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { courseId, updated, failed, results } |
| `400` | employeeIds is required — `employeeIds` is empty. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | Only HR or the location manager can record a completion — The caller is neither (or is marking their own training: "Nobody can mark their own training completed"). |
| `404` | Course not found — No bm_course has that id. |
| `409` | This is a Content Studio course: completion and score come only from the course itself. Review or approve the person’s work there. — The course is backed by a Content Studio post (postId). Review the person’s work under /content-studio instead. |
| `422` | Score is below the pass mark — Per person, in results: score < the rule’s evidence.minScore. |
| `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 /business-made/training/courses/{id}/not-completed

**Mark not completed (undo)**

`operationId: TrainingController_markNotCompleted`

For each person: reopens their latest completed enrollment. Nothing is deleted — the completion stays in `completionHistory` with who undid it and why (a course-app reference stays there too so a webhook retry is not re-applied), and the readiness ledger gets an `evidence_rejected` entry. Emits `journeys.evidence`.

#### Signature

```http
POST /business-made/training/courses/{id}/not-completed (id: string, body) -> { courseId, updated, failed, results }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `409` | NOT_COMPLETED | has no completion of this course to undo | Per person, in results. | — |
| `404` | COURSE_NOT_FOUND | Course not found | No bm_course has that id. | — |
| `403` | NOT_ALLOWED_TO_MARK | Only HR or the location manager can record a completion | The caller is neither (or is marking their own training: "Nobody can mark their own training completed"). | — |
| `400` | EMPLOYEES_REQUIRED | employeeIds is required | `employeeIds` is empty. | — |

Plus the standard platform errors: `401`, `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. |
| `id` | path | string | yes | bm_course sk |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { courseId, updated, failed, results } |
| `400` | employeeIds is required — `employeeIds` is empty. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | Only HR or the location manager can record a completion — The caller is neither (or is marking their own training: "Nobody can mark their own training completed"). |
| `404` | Course not found — No bm_course has that id. |
| `409` | has no completion of this course to undo — Per person, in results. |
| `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 /business-made/training/courses/{id}/assign

**Assign people directly**

`operationId: TrainingController_assign`

Creates an enrollment (enrollmentType assigned) per person, or updates the due date of an open one. For a course backed by a Content Studio post, each person is also enrolled on the course engine (their post_progress).

#### Signature

```http
POST /business-made/training/courses/{id}/assign (id: string, body) -> { courseId, updated, failed, results }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `404` | COURSE_NOT_FOUND | Course not found | No bm_course has that id. | — |
| `400` | EMPLOYEES_REQUIRED | employeeIds is required | `employeeIds` is empty. | — |
| `409` | NO_LEARNER_EMAIL | There is no email on file to enrol this person in the course | A post-backed course needs the person's email to enrol them in the Content Studio 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. |
| `id` | path | string | yes | bm_course sk |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { courseId, updated, failed, results } |
| `400` | employeeIds is required — `employeeIds` is empty. |
| `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` | Course not found — No bm_course has that id. |
| `409` | There is no email on file to enrol this person in the course — A post-backed course needs the person's email to enrol them in the Content Studio 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. |

## GET /business-made/training/marketplace/status

**Course app connection**

`operationId: TrainingController_marketplaceStatus`

#### Signature

```http
GET /business-made/training/marketplace/status () -> { connected, mode: "app" \| "link", provider?, reason? } — link mode (no course app configured) is fully working: courses open from their own launchUrl.
```

#### 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` | { connected, mode: "app" \| "link", provider?, reason? } — link mode (no course app configured) is fully working: courses open from their own launchUrl. |
| `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 /business-made/training/marketplace/courses

**Search the course app**

`operationId: TrainingController_marketplaceCourses`

Searches the course app catalogue. Without a configured course app this answers `{ connected: false, reason, data: [] }` — add link courses instead.

#### Signature

```http
GET /business-made/training/marketplace/courses (search?: string, page?: integer, pageSize?: integer) -> { connected, reason?, data: [MarketplaceCourse & { attachedCourseId }], total, page, pageSize }
```

#### 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. |
| `search` | query | string | — |  |
| `page` | query | integer | — |  |
| `pageSize` | query | integer | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { connected, reason?, data: [MarketplaceCourse & { attachedCourseId }], total, page, pageSize } |
| `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 /business-made/training/marketplace/attach

**Attach a marketplace course**

`operationId: TrainingController_attach`

Creates (or reuses) a bm_course with source = marketplace and, with requirementId, adds it to that course requirement’s target.courseIds.

#### Signature

```http
POST /business-made/training/marketplace/attach (body) -> The bm_course
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `503` | COURSE_APP_NOT_CONNECTED | The course marketplace app is not connected | No course app yet. | — |
| `400` | MARKETPLACE_COURSE_ID_REQUIRED | marketplaceCourseId is required | `marketplaceCourseId` is missing. | — |
| `404` | MARKETPLACE_COURSE_NOT_FOUND | The course app has no such course | The course app does not know that id. | — |

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

### Responses

| Status | Meaning |
| --- | --- |
| `201` | The bm_course |
| `400` | marketplaceCourseId is required — `marketplaceCourseId` 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` | The course app has no such course — The course app does not know that id. |
| `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. |
| `503` | The course marketplace app is not connected — No course app yet. |

## POST /business-made/training/marketplace/completion

**Course completion from the course app**

`operationId: TrainingController_completion`

Webhook-style. Accepted when signed by the course app (HMAC-SHA256 of the raw body with the integration’s webhookSecret), or posted by an owner/admin. The course is identified by `enrollmentId` or `courseId` (both echoed from the launch link) or the app’s `marketplaceCourseId`; the person by the enrollment, `employeeId` or `email`. Idempotent on `reference`, including references HR has undone. Records `externalCompletion` and a completionHistory entry on the enrollment (creating it if needed), sets expiry from the requirement’s renewal else the course’s, records readiness evidence when passed and emits `journeys.evidence`.

#### Signature

```http
POST /business-made/training/marketplace/completion (body) -> { enrollmentId, status, requirementId, evidenceRecorded } or { duplicate: true }
```

#### Access

Public — no credentials required.

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | ORG_REQUIRED | orgid header is required | No `orgid` header. | — |
| `401` | WEBHOOK_SIGNATURE_INVALID | Completion signature is missing or invalid | The webhook signature does not verify with the course app secret. | — |
| `404` | ENROLLMENT_NOT_FOUND | No enrollment matches this completion | The enrollment id matches nothing. | — |
| `409` | ENROLLMENT_MISMATCH | The enrollment belongs to someone else | The enrollment and the learner disagree. | — |

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-course-app-signature` | header | string | — | HMAC-SHA256 of the raw body with the org’s course-app secret. |

### Request body

### Responses

| Status | Meaning |
| --- | --- |
| `201` | { enrollmentId, status, requirementId, evidenceRecorded } or { duplicate: true } |
| `400` | orgid header is required — No `orgid` header. |
| `401` | Completion signature is missing or invalid — The webhook signature does not verify with the course app secret. |
| `404` | No enrollment matches this completion — The enrollment id matches nothing. |
| `409` | The enrollment belongs to someone else — The enrollment and the learner disagree. |
| `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 /business-made/signoffs/checklist

**Sign-off checklist**

`operationId: TrainingController_checklist`

The checklist for a sign-off rule, whether the caller may observe, and — with `employee` — that person’s prerequisites and last sign-off.

#### Signature

```http
GET /business-made/signoffs/checklist (requirementId?: string, employee?: string) -> { requirementId, title, items: [{ key, label, required, photoRequired }], stations, observerRoles, prerequisiteRuleIds, renewalMonths, canObserve, cannotObserveReason?, employee? }
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `400` | REQUIREMENT_ID_REQUIRED | requirementId is required | `requirementId` is missing. | — |
| `404` | REQUIREMENT_NOT_FOUND | Requirement not found | No rule has that id. | — |

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` | query | string | yes |  |
| `employee` | query | string | — | Employee code or sk (optional). |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { requirementId, title, items: [{ key, label, required, photoRequired }], stations, observerRoles, prerequisiteRuleIds, renewalMonths, canObserve, cannotObserveReason?, employee? } |
| `400` | requirementId is required — `requirementId` 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` | Requirement not found — No rule has that id. |
| `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 /business-made/signoffs

**List sign-offs**

`operationId: TrainingController_list`

#### Signature

```http
GET /business-made/signoffs (employee?: string, station?: string, requirementId?: string, result?: string, page?: integer, pageSize?: integer) -> { data, total, page, pageSize } (observer signature image omitted)
```

#### 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. |
| `employee` | query | string | — |  |
| `station` | query | string | — |  |
| `requirementId` | query | string | — |  |
| `result` | query | string | — |  |
| `page` | query | integer | — |  |
| `pageSize` | query | integer | — |  |

### Responses

| Status | Meaning |
| --- | --- |
| `200` | { data, total, page, pageSize } (observer signature image omitted) |
| `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 /business-made/signoffs

**Record a floor sign-off**

`operationId: TrainingController_create`

The observer is the caller and must hold one of the rule’s observerRoles (or be the person’s manager when none are set; owners/admins always may; never yourself). Every checklist line needs pass/fail; photoRequired lines need photoIds; a pass needs all required lines passed and prerequisites complete. A pass expires after the rule’s renewalMonths and is readiness evidence.

#### Signature

```http
POST /business-made/signoffs (body) -> The bm_signoff
```

#### Access

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

#### Errors

| Status | Code | Message | When | What to do |
| --- | --- | --- | --- | --- |
| `403` | NOT_AN_OBSERVER | Only … may sign this off | Caller’s roles are not in the rule’s observerRoles. | — |
| `409` | PREREQUISITES_NOT_MET | Prerequisite training is not complete | A pass before the prerequisite rules are met. | — |
| `400` | REQUIREMENT_ID_REQUIRED | requirementId is required | `requirementId` is missing. | — |
| `404` | REQUIREMENT_NOT_FOUND | Requirement not found | No rule has that id. | — |

Plus the standard platform errors: `401`, `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` | The bm_signoff |
| `400` | requirementId is required — `requirementId` is missing. |
| `401` | Authentication failed: Invalid or expired token — The `Authorization` header is missing, malformed, or the JWT has expired. |
| `403` | Only … may sign this off — Caller’s roles are not in the rule’s observerRoles. |
| `404` | Requirement not found — No rule has that id. |
| `409` | Prerequisite training is not complete — A pass before the prerequisite rules are met. |
| `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. |

