API Contracts
:::info Source
Sourced from services/delivery-service/API_CONTRACTS.md in the documentation repo.
:::
Companion: 05 API Design · APPLICATION_LOGIC · SECURITY_MODEL
All endpoints conform to the platform API conventions defined in doc 05: JSON envelope, cursor pagination, Idempotency-Key on writes, If-Match for optimistic concurrency, RFC 9457 error responses.
1. Base Path
/api/v1/play-sessions
1a. Shipped slice — A3 (checklist 5.3)
Reconciles Phase 3.7 with the implementation monorepo. Normative OpenAPI: Ghasi-edTech/services/delivery-service/openapi.json.
| Surface | Shipped behaviour |
|---|---|
play_sessions | Authoritative aggregate: state ∈ active | paused | completed | failed | abandoned; JSON cursor (sequence index, module/lesson, enrollment, course version, etc.); version, attempt_number, started_at, last_activity_at; lamport; RLS on tenant_id. Migrations through 0005_play_session_lifecycle.sql. |
POST /play-sessions | Creates row (active, version 1). Body: enrollmentId, courseVersionId, optional deviceId. Prefixed IDs must match OpenAPI (Crockford base32 segment 26 chars after enr_ / cv_ / dev_ / usr_). |
PATCH /play-sessions/{id}/navigate | Cursor update: target next | jump | branch; bumps version and lamport; optional If-Match on aggregate version. |
GET /play-sessions/last | Max lamport across aggregate and sync.mutations PlaySession; state, source (aggregate | sync), lifecycle fields when source is aggregate. |
POST /play-sessions/{id}/resume | Aggregate or sync required; 409 delivery.play_session.terminal if aggregate terminal; upserts active + merged cursor; event delivery.play_session.resumed.v1; 403 if sync.revoked_bundles blocks bundle. |
| J-11 | Journey: docs/frontend/journeys/player/J-11-learner-online-certificate.md + player spec §8. Server contract (no browser): Ghasi-edTech/services/delivery-service/test/integration/play-session-j11-contract.integration.spec.ts — start → navigate (next) → last → resume. |
§3 below: §3.1 (start) and §3.2 (navigate) match the shipped routes; use OpenAPI + handlers for authoritative fields and error codes (examples use the platform envelope — minor field drift is possible). §3.3 onward (pause, complete, tutor, mount-offline, list, etc.) remain target until implemented; error tables there are aspirational where not wired to enrollment-service or manifest validation.
2. Authentication & Headers
All endpoints require:
Authorization: Bearer <jwt>X-Tenant-Id: <tenantId>(must match JWTtid)traceparent: <W3C trace context>Idempotency-Key: <ulid>(write endpoints)If-Match: "<version>"(PATCH endpoints)
3. Endpoints
3.1 Start Play Session
POST /api/v1/play-sessions
Request Body:
{
"enrollmentId": "enr_01H...",
"courseVersionId": "cv_01H...",
"deviceId": "dev_01H..."
}
Response: 201 Created
{
"data": {
"id": "pls_01H...",
"state": "active",
"attemptNumber": 1,
"cursor": {
"moduleId": "mod_01",
"lessonId": "les_01",
"blockId": null,
"sequenceIndex": 0
},
"startedAt": "2026-04-15T10:00:00Z",
"lastActivityAt": "2026-04-15T10:00:00Z",
"isOffline": false,
"version": 1
},
"meta": {
"requestId": "req_01H...",
"apiVersion": "v1.0",
"traceId": "00-..."
}
}
Errors:
| Status | Code | Condition |
|---|---|---|
| 403 | enrollment.revoked | Enrollment is not active |
| 404 | enrollment.not_found | Enrollment does not exist |
| 409 | session.rate_limited | Too many session starts |
| 422 | play_package.not_found | No PlayPackage for courseVersionId |
3.2 Navigate
PATCH /api/v1/play-sessions/{id}/navigate
Request Body:
{
"target": {
"type": "next"
}
}
Or for jump navigation:
{
"target": {
"type": "jump",
"targetModuleId": "mod_02",
"targetLessonId": "les_05"
}
}
Or for branch decisions:
{
"target": {
"type": "branch",
"branchChoice": "scenario_a"
}
}
Response: 200 OK
{
"data": {
"id": "pls_01H...",
"state": "active",
"cursor": {
"moduleId": "mod_01",
"lessonId": "les_02",
"blockId": null,
"sequenceIndex": 1
},
"lastActivityAt": "2026-04-15T10:05:00Z",
"version": 2
},
"meta": { "requestId": "req_01H...", "apiVersion": "v1.0", "traceId": "00-..." }
}
Errors:
| Status | Code | Condition |
|---|---|---|
| 403 | session.not_owner | JWT user does not own session |
| 404 | session.not_found | Session ID invalid |
| 409 | session.invalid_state | Session not active |
| 409 | session.version_conflict | If-Match does not match current version |
| 422 | navigation.unreachable | Target not reachable (prerequisite unmet) |
| 422 | navigation.invalid_target | Target node does not exist in manifest |
3.3 Pause Session
POST /api/v1/play-sessions/{id}/pause
Request Body: None (empty JSON {} acceptable)
Response: 200 OK
{
"data": {
"id": "pls_01H...",
"state": "paused",
"cursor": { "moduleId": "mod_01", "lessonId": "les_02", "blockId": null },
"lastActivityAt": "2026-04-15T10:10:00Z",
"version": 3
},
"meta": { "..." }
}
3.4 Complete Session
POST /api/v1/play-sessions/{id}/complete
Request Body: None
Response: 200 OK
{
"data": {
"id": "pls_01H...",
"state": "completed",
"cursor": { "moduleId": "mod_03", "lessonId": "les_12", "blockId": null },
"endedAt": "2026-04-15T11:30:00Z",
"durationSeconds": 5400,
"version": 15
},
"meta": { "..." }
}
Errors:
| Status | Code | Condition |
|---|---|---|
| 422 | completion.requirements_unmet | Not all required lessons/gates completed |
3.5 Abandon Session
POST /api/v1/play-sessions/{id}/abandon
Request Body:
{
"reason": "user_initiated"
}
Response: 200 OK
{
"data": {
"id": "pls_01H...",
"state": "abandoned",
"endedAt": "2026-04-15T10:15:00Z",
"version": 4
},
"meta": { "..." }
}
3.6 Get Session State
GET /api/v1/play-sessions/{id}/state
Response: 200 OK
{
"data": {
"id": "pls_01H...",
"state": "active",
"attemptNumber": 1,
"cursor": {
"moduleId": "mod_01",
"lessonId": "les_03",
"blockId": "blk_07",
"sequenceIndex": 5
},
"startedAt": "2026-04-15T10:00:00Z",
"lastActivityAt": "2026-04-15T10:20:00Z",
"durationSeconds": 1200,
"assistantTurnsCount": 3,
"isOffline": false,
"version": 6
},
"meta": { "..." }
}
3.7 AI Tutor — Submit Turn
POST /api/v1/play-sessions/{id}/tutor/turn
Request Body:
{
"prompt": "Can you explain the concept of polymorphism in this lesson?"
}
Response: 202 Accepted
{
"data": {
"turnId": "trn_01H...",
"streamUrl": "/api/v1/play-sessions/pls_01H.../tutor/stream?turnId=trn_01H..."
},
"meta": { "..." }
}
3.8 AI Tutor — SSE Stream
GET /api/v1/play-sessions/{id}/tutor/stream?turnId=trn_01H...
SSE Event Stream:
event: token
data: {"text": "Poly", "index": 0}
event: token
data: {"text": "morphism", "index": 1}
event: token
data: {"text": " is", "index": 2}
...
event: done
data: {"turnId": "trn_01H...", "totalTokens": 342, "aiProvenance": {"model": "claude-haiku-4.5", "local": false}}
event: error
data: {"code": "ai.unavailable", "message": "AI service temporarily unavailable"}
Headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
3.9 Mount Offline Bundle
POST /api/v1/play-sessions/{id}/mount-offline
Request Body:
{
"bundleId": "bnd_01H...",
"licenseEnvelope": "<JWS token>",
"bundleChecksum": "sha256-abc123..."
}
Response: 201 Created
{
"data": {
"mountId": "mnt_01H...",
"bundleId": "bnd_01H...",
"expiresAt": "2026-05-15T10:00:00Z",
"signatureValid": true
},
"meta": { "..." }
}
Errors:
| Status | Code | Condition |
|---|---|---|
| 403 | device.not_trusted | Device not registered or not trusted |
| 403 | license.invalid | License envelope signature verification failed |
| 403 | license.expired | License envelope has expired |
| 409 | bundle.already_mounted | Bundle already mounted on this device |
| 422 | bundle.integrity_failed | Bundle checksum mismatch |
3.10 Unmount Offline Bundle
POST /api/v1/play-sessions/{id}/unmount-offline
Request Body:
{
"mountId": "mnt_01H...",
"reason": "user_initiated"
}
Response: 200 OK
{
"data": {
"mountId": "mnt_01H...",
"unmountedAt": "2026-04-15T12:00:00Z",
"reason": "user_initiated"
},
"meta": { "..." }
}
4. Rate Limits
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
POST /play-sessions | 5 | 1 min | per user |
PATCH /{id}/navigate | 120 | 1 min | per session |
POST /{id}/tutor/turn | 30 | 1 hour | per session |
POST /{id}/mount-offline | 10 | 1 hour | per device |
GET /{id}/state | 60 | 1 min | per user |
5. Pagination
GET /api/v1/play-sessions (list user sessions) uses cursor pagination:
Query Parameters:
cursor— opaque cursor from previous responselimit— max 100, default 20state— filter:active,paused,completed,abandonedcourseVersionId— filter by course version
6. OpenAPI Schema Reference
The full OpenAPI 3.1 specification is generated from NestJS decorators and published at:
/api/v1/docs (Swagger UI)
/api/v1/openapi.json (machine-readable)