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
| Surface | Base | Authn |
|---|---|---|
| Tenant REST | https://api.ghasi.edu/api/v1 | Bearer JWT + X-Tenant-Id |
| Public Catalog | https://api.ghasi.edu/public/v1 | unauthenticated, rate-limited 60 rpm/IP |
| Admin | https://api.ghasi.edu/admin/v1 | Bearer JWT (platform-admin) |
2. Endpoint Catalog
2.1 Courses
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/courses | List (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}/metadata | Update mutable metadata |
| PATCH | /api/v1/courses/{id}/visibility | Change visibility |
| POST | /api/v1/courses/{id}/archive | Archive |
| GET | /api/v1/courses/{id}/versions | List 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}/deprecate | Deprecate |
| POST | /api/v1/courses/{id}/versions/{vid}/withdraw | Withdraw |
2.2 Taxonomies
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/taxonomies | List |
| GET | /api/v1/taxonomies/{id} | Get one |
| POST | /api/v1/taxonomies | Create |
| PATCH | /api/v1/taxonomies/{id} | Replace tree with optimistic lock |
2.3 Public (unauthenticated) subset
| Method | Path | Purpose |
|---|---|---|
| GET | /public/v1/courses | Only visibility=public |
| GET | /public/v1/courses/{id} | Only visibility=public |
| GET | /public/v1/taxonomies/ghasi:subjects | Global platform taxonomy |
3. GET /api/v1/courses — List
Query params
| Param | Type | Default | Notes |
|---|---|---|---|
cursor | string | — | opaque base64url JSON |
limit | integer | 50 | max 200 |
status | enum | active | active / archived |
visibility | CSV | all visible to caller | filtered by RBAC |
locale | BCP-47 | — | filter by version locales |
taxonomyNode | string | — | e.g. tax_01HX…:/science/physics |
tag | CSV | — | |
q | string | — | substring on title (weak; search-service for full-text) |
sort | CSV | -publishedAt | fields: 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-Matchmatch.
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 lacksfeature.marketplace_publish.409 no_listing— attemptingmarketplacewithout 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 alreadydeprecatedorwithdrawn.
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_mismatchifIf-Matchis stale; response body includes currentversionand 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)
| Code | HTTP |
|---|---|
CATALOG_NOT_FOUND | 404 |
CATALOG_ETAG_MISMATCH | 412 |
CATALOG_SLUG_EXISTS | 409 |
CATALOG_ILLEGAL_STATE | 409 |
CATALOG_VERSION_REGRESSION | 409 |
CATALOG_VISIBILITY_FORBIDDEN | 409 |
CATALOG_TAXONOMY_VERSION_STALE | 409 |
CATALOG_VALIDATION | 422 |
CATALOG_FORBIDDEN | 403 |
CATALOG_ARCHIVED_TARGET | 409 |
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
| Scope | Limit | Headers |
|---|---|---|
| Per-tenant reads | 600 rpm | X-RateLimit-Limit, -Remaining, -Reset |
| Per-tenant writes | 60 rpm | same |
| Public | 60 rpm/IP | same |
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.v1catalog.course_version.published.v1catalog.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/v2and ≥ 1-release overlap. - Narrowing enums: forbidden.
- Changing semantics of an existing field: forbidden.