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
| Surface | Base path | Auth | Purpose |
|---|---|---|---|
| Tenant REST | /api/v1/drafts | JWT + tenant | CRUD drafts, blocks, AI flows, publish |
| SSE Streaming | /api/v1/drafts/{id}/ai/stream | JWT + tenant | Stream AI generation progress |
| WebSocket | /ws/collab/{draftId} | JWT + tenant | Yjs real-time collaboration (M4+) |
| Internal | /internal/drafts/* | mTLS + service token | Inter-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-Keyheader required on writesIf-Matchfor 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 lacksauthorrole422 validation.field_required— Missing title or defaultLocale429 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_found403 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 mismatch409 authoring.invalid_state— Draft not inediting
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:
| Code | HTTP | Title |
|---|---|---|
authoring.draft_not_found | 404 | Draft not found |
authoring.block_not_found | 404 | Block not found |
authoring.lesson_not_found | 404 | Lesson not found |
authoring.module_not_found | 404 | Module not found |
authoring.forbidden | 403 | Action not permitted |
authoring.cross_tenant | 403 | Cross-tenant reference rejected |
authoring.version_conflict | 409 | Optimistic concurrency conflict |
authoring.invalid_state | 409 | Invalid state transition |
authoring.publish_not_ready | 409 | Publish preconditions not met |
authoring.ai_block_required | 422 | AI block cannot be required |
authoring.block_order_gap | 422 | Non-contiguous block ordering |
authoring.ai_moderation_blocked | 422 | AI output blocked by safety filter |
authoring.scorm_import_failed | 422 | SCORM package invalid |
authoring.saga_timeout | 504 | Publish saga timed out |
authoring.collaborator_not_member | 422 | Collaborator not in tenant |
4. Rate Limits
| Endpoint family | Limit | Window |
|---|---|---|
| Draft reads | 600 req/min | Per user |
| Draft writes | 120 req/min | Per user |
| AI generate calls | 30 req/min | Per user |
| AI generate calls | 1000 req/hour | Per tenant |
| Publish initiations | 5 req/min | Per user |
| SCORM imports | 2 req/min | Per 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
v2URL prefix - Additive changes: minor version bump, signalled via
X-API-Versionheader - Deprecation:
Deprecation: true+Sunset: <date>headers, minimum 90-day notice