Skip to main content

API Contracts

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

Companion: APPLICATION_LOGIC · ../../docs/05-api-design.md

All endpoints follow the platform API conventions: JSON envelope, RFC 9457 errors, ULID IDs, Idempotency-Key on writes, X-Tenant-Id on every request (except public surfaces).

1. Base URL & Surfaces

SurfaceBaseAuthn
Tenant RESThttps://api.ghasi.edu/api/v1Bearer JWT + X-Tenant-Id
Public Cataloghttps://api.ghasi.edu/public/v1unauthenticated, rate-limited 60 rpm/IP
Adminhttps://api.ghasi.edu/admin/v1Bearer JWT (platform-admin)

2. Endpoint Catalog

2.1 Courses

MethodPathPurpose
GET/api/v1/coursesList (paginated, filterable)
GET/api/v1/courses/{id}Get one by ID
GET/api/v1/courses/by-slug/{slug}Resolve by slug within tenant
PATCH/api/v1/courses/{id}/metadataUpdate mutable metadata
PATCH/api/v1/courses/{id}/visibilityChange visibility
POST/api/v1/courses/{id}/archiveArchive
GET/api/v1/courses/{id}/versionsList versions
GET/api/v1/courses/{id}/versions/{vid}Get version
GET/api/v1/versions/{vid}Get version (direct)
POST/api/v1/courses/{id}/versions/{vid}/deprecateDeprecate
POST/api/v1/courses/{id}/versions/{vid}/withdrawWithdraw

2.2 Taxonomies

MethodPathPurpose
GET/api/v1/taxonomiesList
GET/api/v1/taxonomies/{id}Get one
POST/api/v1/taxonomiesCreate
PATCH/api/v1/taxonomies/{id}Replace tree with optimistic lock

2.3 Public (unauthenticated) subset

MethodPathPurpose
GET/public/v1/coursesOnly visibility=public
GET/public/v1/courses/{id}Only visibility=public
GET/public/v1/taxonomies/ghasi:subjectsGlobal platform taxonomy

3. GET /api/v1/courses — List

Query params

ParamTypeDefaultNotes
cursorstringopaque base64url JSON
limitinteger50max 200
statusenumactiveactive / archived
visibilityCSVall visible to callerfiltered by RBAC
localeBCP-47filter by version locales
taxonomyNodestringe.g. tax_01HX…:/science/physics
tagCSV
qstringsubstring on title (weak; search-service for full-text)
sortCSV-publishedAtfields: publishedAt, title, createdAt

Response 200

{
"data": [
{
"id": "crs_01HXYZ…",
"tenantId": "ten_01HAB…",
"slug": "intro-physics",
"title": { "en": "Intro Physics", "ar": "مقدمة الفيزياء" },
"description": { "en": "…" },
"defaultLocale": "en",
"visibility": "org",
"status": "active",
"cover": { "assetId": "med_01H…", "variant": "image_lg", "url": "https://cdn…" },
"authors": [{ "userId": "usr_01H…", "displayName": "A. Rahman", "role": "author" }],
"taxonomy": [{ "taxonomyId": "tax_01H…", "nodePath": "/science/physics" }],
"tags": ["physics", "foundation"],
"latestVersion": {
"id": "crv_01H…",
"versionLabel": "2.1.0",
"durationMinutes": 420,
"locales": ["en", "ar"],
"publishedAt": "2026-03-02T09:04:00Z"
},
"versionCount": 3,
"etag": "01HYZ…"
}
],
"meta": {
"page": { "size": 50, "cursor": "eyJsIjo…", "nextCursor": "eyJsIjo…", "prevCursor": null, "totalApproximate": 1284 },
"filters": { "status": "active" },
"sort": [{ "field": "publishedAt", "dir": "desc" }],
"requestId": "req_01HYZ…",
"apiVersion": "v1.42"
}
}

Caching

  • ETag: weak, computed as W/"{sha256(jsonBody).slice(0,16)}".
  • Cache-Control: private, max-age=30.
  • 304 on If-None-Match match.

4. GET /api/v1/courses/{id} — Get

Response 200

Same shape as list items, plus:

{
"data": {
"…": "…",
"links": {
"self": "/api/v1/courses/crs_01H…",
"versions": "/api/v1/courses/crs_01H…/versions",
"play": "/api/v1/delivery/launch?courseVersionId=crv_01H…"
}
}
}

Headers: ETag: "01HYZ…", Cache-Control: private, max-age=300.

Errors

  • 404 not_found — unknown id or not visible to caller.
  • 403 forbidden — visibility rules (private across tenants).

5. PATCH /api/v1/courses/{id}/metadata

Request

Headers: Idempotency-Key, If-Match: "<etag>".

{
"title": { "en": "Intro to Physics", "ar": "مقدمة الفيزياء" },
"description": { "en": "…" },
"cover": { "assetId": "med_01H…" },
"taxonomy": [{ "taxonomyId": "tax_01H…", "nodePath": "/science/physics" }],
"tags": ["physics", "foundation"],
"defaultLocale": "en"
}

Response 200

Returns the updated Course plus new etag.

Errors

  • 412 precondition_failed (etag mismatch).
  • 422 validation (bad slug, locale not in versions, etc.).

6. PATCH /api/v1/courses/{id}/visibility

Request

{ "visibility": "marketplace", "reason": "Launch Q2 catalog" }

Response 200

{
"data": { "id": "crs_01H…", "visibility": "marketplace", "etag": "01HYZ…" },
"meta": { "requestId": "req_01H…", "apiVersion": "v1.42" }
}

Errors

  • 409 feature_disabled — tenant lacks feature.marketplace_publish.
  • 409 no_listing — attempting marketplace without a marketplace listing.

7. GET /api/v1/courses/{id}/versions

Response 200

{
"data": [
{
"id": "crv_01H…",
"courseId": "crs_01H…",
"versionLabel": "2.1.0",
"status": "published",
"publishedAt": "2026-03-02T09:04Z",
"publishedBy": "usr_01H…",
"durationMinutes": 420,
"locales": ["en", "ar"],
"moduleSummaries": [
{ "id": "mod_1", "title": { "en": "Kinematics" }, "lessonCount": 6, "durationMinutes": 60, "hasAssessments": true }
],
"playPackageRef": {
"playPackageId": "pkg_01H…",
"sha256": "a3f1…",
"format": "v1"
},
"changelog": { "en": "Fixed typos in module 2." }
}
],
"meta": { "page": { "size": 25 }, "sort": [{ "field": "publishedAt", "dir": "desc" }] }
}

8. POST /api/v1/courses/{id}/versions/{vid}/deprecate

Request

{ "reason": "Superseded by 3.0.0" }

Response 200

{ "data": { "id": "crv_01H…", "status": "deprecated", "deprecatedAt": "…" } }

Errors

  • 409 illegal_state — version already deprecated or withdrawn.

9. POST /api/v1/courses/{id}/versions/{vid}/withdraw

Request

{ "reason": "Legal complaint: copyright" }

reason is required (audit trail).

Response 200 — returns the now-withdrawn version.

10. Taxonomies

POST /api/v1/taxonomies

{
"namespace": "acme:role_ladder",
"title": { "en": "Role Ladder" },
"tree": [
{ "path": "/ic", "label": { "en": "Individual Contributor" }, "childPaths": ["/ic/l1","/ic/l2"] },
{ "path": "/ic/l1", "label": { "en": "L1" }, "childPaths": [] }
]
}

PATCH /api/v1/taxonomies/{id}

Headers: If-Match: "<version>" where version is integer.

{ "tree": [ "…new tree…" ] }

Response includes new version.

  • 409 version_mismatch if If-Match is stale; response body includes current version and diff hint.

11. Error Shape (RFC 9457 + platform)

{
"type": "https://errors.ghasi.edu/catalog/slug-already-exists",
"title": "Slug already exists",
"status": 409,
"code": "CATALOG_SLUG_EXISTS",
"detail": "A course with slug 'intro-physics' already exists in this tenant.",
"instance": "/api/v1/courses",
"tenantId": "ten_01H…",
"requestId": "req_01H…",
"traceId": "00-abc…-01"
}

Error code registry (catalog-specific)

CodeHTTP
CATALOG_NOT_FOUND404
CATALOG_ETAG_MISMATCH412
CATALOG_SLUG_EXISTS409
CATALOG_ILLEGAL_STATE409
CATALOG_VERSION_REGRESSION409
CATALOG_VISIBILITY_FORBIDDEN409
CATALOG_TAXONOMY_VERSION_STALE409
CATALOG_VALIDATION422
CATALOG_FORBIDDEN403
CATALOG_ARCHIVED_TARGET409

12. Pagination

Cursor payload (base64url-encoded JSON):

{ "l": { "publishedAt": "2026-03-02T09:04Z", "id": "crv_01H…" }, "d": "desc" }

Keyset pagination on (publishedAt DESC, id DESC). Always stable.

13. Rate Limiting

ScopeLimitHeaders
Per-tenant reads600 rpmX-RateLimit-Limit, -Remaining, -Reset
Per-tenant writes60 rpmsame
Public60 rpm/IPsame

429 on exceed, Retry-After header.

14. OpenAPI

Full OpenAPI 3.1 spec published at /openapi/catalog-v1.json, signed with X-Spec-Sha256. CI job validates generated code against this spec.

15. SDK Usage Example

const cat = new CatalogClient({ baseUrl, tenantId, token });
const page = await cat.listCourses({ visibility: 'org', limit: 25 });
const full = await cat.getCourse('crs_01H…');
await cat.updateCourseMetadata('crs_01H…', {
ifMatch: full.etag,
idempotencyKey: ulid(),
body: { title: { en: 'New title' } }
});

16. Webhooks (optional, out)

Tenants may register webhooks to receive:

  • catalog.course.registered.v1
  • catalog.course_version.published.v1
  • catalog.course_version.withdrawn.v1

Payload matches the event envelope; signed with HMAC-SHA256(signingSecret, body) in X-Ghasi-Signature.

17. Backwards Compatibility

  • Adding fields: safe.
  • Removing fields: requires /api/v2 and ≥ 1-release overlap.
  • Narrowing enums: forbidden.
  • Changing semantics of an existing field: forbidden.