Skip to main content

API Contracts

:::info Source Sourced from services/authoring-service/04-API_CONTRACTS.md in the documentation repo. :::

Companion: 05 API Design · 03 Application Logic


1. API Surface Summary

SurfaceBase pathAuthPurpose
Tenant REST/api/v1/draftsJWT + tenantCRUD drafts, blocks, AI flows, publish
SSE Streaming/api/v1/drafts/{id}/ai/streamJWT + tenantStream AI generation progress
WebSocket/ws/collab/{draftId}JWT + tenantYjs real-time collaboration (M4+)
Internal/internal/drafts/*mTLS + service tokenInter-service calls (bypasses user auth)

All endpoints conform to the platform-wide conventions in docs/05-api-design.md:

  • JSON request/response envelopes
  • Cursor-only pagination
  • Problem+JSON error format (RFC 9457)
  • Idempotency-Key header required on writes
  • If-Match for optimistic concurrency

2. Endpoint Reference

2.1 Draft Management

POST /api/v1/drafts

Create a new course draft.

Request:

POST /api/v1/drafts HTTP/1.1
Authorization: Bearer <jwt>
X-Tenant-Id: tnt_01H...
Idempotency-Key: idm_01H...
Content-Type: application/json

{
"title": { "en-US": "Introduction to Python" },
"defaultLocale": "en-US",
"fromTemplate": null
}

Response 201:

{
"data": {
"id": "drf_01H...",
"tenantId": "tnt_01H...",
"title": { "en-US": "Introduction to Python" },
"defaultLocale": "en-US",
"state": "editing",
"collaborators": ["usr_01H..."],
"modules": [],
"draftVersion": 1,
"createdAt": "2026-04-15T09:30:00.000Z",
"updatedAt": "2026-04-15T09:30:00.000Z"
},
"meta": { "requestId": "req_01H...", "apiVersion": "v1.0" }
}

Errors:

  • 403 authoring.forbidden — User lacks author role
  • 422 validation.field_required — Missing title or defaultLocale
  • 429 rate_limit.exceeded — Too many draft creations

GET /api/v1/drafts

List drafts for the current tenant.

Query params: state, author, cursor, pageSize (default 50, max 200)

Response 200: Collection envelope with data: CourseDraftSummary[]


GET /api/v1/drafts/{id}

Fetch full draft including all blocks.

Response 200:

{
"data": { /* CourseDraft */ },
"meta": { "etag": "\"42\"", "requestId": "req_01H...", "apiVersion": "v1.0" }
}

Errors:

  • 404 authoring.draft_not_found
  • 403 authoring.forbidden — Not a collaborator

PATCH /api/v1/drafts/{id}

Update draft metadata (title, locale, collaborators).

Headers: If-Match: "<draftVersion>" required

Request:

{
"title": { "en-US": "Introduction to Python 3.12" }
}

Errors:

  • 409 authoring.version_conflict — If-Match mismatch
  • 409 authoring.invalid_state — Draft not in editing

DELETE /api/v1/drafts/{id}

Soft-delete draft. Only owner, only in editing state.

2.2 Block Management

POST /api/v1/drafts/{id}/modules

Add a module to the draft.

Request:

{
"title": { "en-US": "Getting Started" },
"sortOrder": 0
}

POST /api/v1/drafts/{id}/modules/{moduleId}/lessons

Add a lesson to a module.


POST /api/v1/drafts/{id}/lessons/{lessonId}/blocks

Add a block to a lesson.

Request:

{
"kind": "text",
"markdown": "# Welcome\n\nIn this lesson...",
"sortOrder": 0,
"required": false
}

Response 201: Block resource


PATCH /api/v1/drafts/{id}/blocks/{blockId}

Update a block.

Headers: If-Match: "<draftVersion>" required

Request (partial):

{
"markdown": "# Welcome to Python\n\nRevised content..."
}

DELETE /api/v1/drafts/{id}/blocks/{blockId}

Remove a block.


POST /api/v1/drafts/{id}/lessons/{lessonId}/blocks/reorder

Reorder blocks in a lesson.

Request:

{
"blockIds": ["blk_A", "blk_C", "blk_B"]
}

Guards: All IDs must belong to the lesson; length must match.


POST /api/v1/drafts/{id}/blocks/{blockId}/move

Move a block to a different lesson.

Request:

{
"targetLessonId": "lsn_01H...",
"position": 3
}

2.3 AI Co-Author

POST /api/v1/drafts/{id}/ai/generate-block

Start a background job to generate a block.

Request:

{
"lessonId": "lsn_01H...",
"intent": "Add a quiz about list comprehensions",
"targetKind": "quiz",
"context": {
"includeSurroundingBlocks": true,
"windowSize": 3
}
}

Response 202 Accepted:

{
"data": {
"jobId": "aij_01H...",
"status": "queued",
"streamUrl": "/api/v1/drafts/drf_01H.../ai/stream?jobId=aij_01H...",
"estimatedDurationMs": 8000
},
"meta": { "requestId": "req_01H...", "apiVersion": "v1.0" }
}

POST /api/v1/drafts/{id}/ai/improve-block

Improve an existing block (rewrite, simplify, expand, translate).

Request:

{
"blockId": "blk_01H...",
"instruction": "Rewrite for an executive audience",
"preserveOriginal": true
}

When preserveOriginal: true, the improved version is inserted as a new draft_ai block alongside the original.


POST /api/v1/drafts/{id}/ai/generate-quiz

Generate a quiz block from lesson content.

Request:

{
"lessonId": "lsn_01H...",
"difficulty": "intermediate",
"questionCount": 5,
"types": ["mcq", "short_answer"]
}

GET /api/v1/drafts/{id}/ai/stream?jobId={id}

SSE stream of AI generation progress.

Event types:

event: progress
data: {"jobId":"aij_01H...","tokensEmitted":124,"percentComplete":42}

event: chunk
data: {"jobId":"aij_01H...","chunk":"The quick brown"}

event: complete
data: {"jobId":"aij_01H...","blockId":"blk_01H...","status":"draft_ai"}

event: error
data: {"jobId":"aij_01H...","error":"moderation_blocked","detail":"..."}

POST /api/v1/drafts/{id}/blocks/{blockId}/accept-ai

Accept an AI-generated block. Transitions status: draft_ai → reviewed.


POST /api/v1/drafts/{id}/blocks/{blockId}/reject-ai

Reject and remove an AI-generated block.

2.4 Review & Publish

POST /api/v1/drafts/{id}/submit-for-review

Transition editing → in_review.


POST /api/v1/drafts/{id}/approve

Approve review. Transition in_review → approved. Reviewer must differ from author.


POST /api/v1/drafts/{id}/reject

Reject review with reason. Transition in_review → editing.

Request:

{
"reason": "Quiz questions too easy; please add branching scenarios."
}

POST /api/v1/drafts/{id}/publish

Initiate publish saga. Transition approved → publishing.

Response 202 Accepted:

{
"data": {
"draftId": "drf_01H...",
"state": "publishing",
"sagaId": "sag_01H...",
"estimatedCompletionMs": 60000
},
"meta": { "requestId": "req_01H...", "apiVersion": "v1.0" }
}

GET /api/v1/drafts/{id}/publish-readiness

Check publish readiness without starting the saga.

Response 200:

{
"data": {
"ready": false,
"blockers": [
{
"kind": "unreviewed_required_block",
"blockId": "blk_01H...",
"lessonId": "lsn_01H..."
}
]
}
}

2.5 Fork & History

POST /api/v1/drafts/{id}/fork

Fork a published draft to create a new editable draft.

Guard: source must be in published_idle state.


GET /api/v1/drafts/{id}/history

Retrieve draft version history (cursor-paginated).

2.6 SCORM Import

POST /api/v1/drafts/{id}/import/scorm

Import SCORM package as a draft.

Request:

{
"sourceUrl": "https://media.ghasi.io/uploads/scorm_01H....zip",
"replaceExisting": false,
"scormVersion": "1.2"
}

Response 202 Accepted: Returns a job id. Progress via ai.stream endpoint.

2.7 Collaboration (WebSocket)

WS /ws/collab/{draftId}

Yjs awareness and updates (M4+).

Protocol:

  • Binary frames (Yjs update vectors)
  • Message types: sync-step-1, sync-step-2, update, awareness-update
  • Authentication: JWT in query param or subprotocol header

3. Error Response Catalog

All errors follow RFC 9457 problem+json. Service-specific codes:

CodeHTTPTitle
authoring.draft_not_found404Draft not found
authoring.block_not_found404Block not found
authoring.lesson_not_found404Lesson not found
authoring.module_not_found404Module not found
authoring.forbidden403Action not permitted
authoring.cross_tenant403Cross-tenant reference rejected
authoring.version_conflict409Optimistic concurrency conflict
authoring.invalid_state409Invalid state transition
authoring.publish_not_ready409Publish preconditions not met
authoring.ai_block_required422AI block cannot be required
authoring.block_order_gap422Non-contiguous block ordering
authoring.ai_moderation_blocked422AI output blocked by safety filter
authoring.scorm_import_failed422SCORM package invalid
authoring.saga_timeout504Publish saga timed out
authoring.collaborator_not_member422Collaborator not in tenant

4. Rate Limits

Endpoint familyLimitWindow
Draft reads600 req/minPer user
Draft writes120 req/minPer user
AI generate calls30 req/minPer user
AI generate calls1000 req/hourPer tenant
Publish initiations5 req/minPer user
SCORM imports2 req/minPer tenant

Exceeded limits return 429 with Retry-After header and retryAfter body field.

5. Zod Schemas (Request Validation)

// Create draft
const CreateDraftSchema = z.object({
title: z.record(z.string(), z.string().min(1).max(200)),
defaultLocale: z.string().regex(/^[a-z]{2}(-[A-Z]{2})?$/),
fromTemplate: z.string().nullable().optional(),
});

// Add block (discriminated by kind)
const AddBlockSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('text'),
markdown: z.string().max(50_000),
sortOrder: z.number().int().min(0),
required: z.boolean().default(false),
}),
z.object({
kind: z.literal('image'),
assetId: z.string().regex(/^ast_[0-9A-Z]{26}$/),
alt: z.record(z.string(), z.string()),
sortOrder: z.number().int().min(0),
required: z.boolean().default(false),
}),
// ... other block kinds
]);

// AI generate
const AIGenerateSchema = z.object({
lessonId: z.string().regex(/^lsn_[0-9A-Z]{26}$/),
intent: z.string().min(5).max(2000),
targetKind: z.enum(['text', 'quiz', 'image', 'branching']).optional(),
context: z.object({
includeSurroundingBlocks: z.boolean().default(true),
windowSize: z.number().int().min(0).max(10).default(3),
}).optional(),
});

6. OpenAPI Export

The full OpenAPI 3.1 spec is generated from decorators and exported at /api/v1/openapi.json. Published to the platform API portal at each deployment.

7. Breaking Change Policy

  • Field removals, type changes, enum narrowing: require new v2 URL prefix
  • Additive changes: minor version bump, signalled via X-API-Version header
  • Deprecation: Deprecation: true + Sunset: <date> headers, minimum 90-day notice