Skip to main content

API Contracts

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

Companion: 05 API Design · APPLICATION_LOGIC · SECURITY_MODEL

All endpoints follow the platform conventions in 05 API Design: JWT auth, X-Tenant-Id header, Idempotency-Key on writes, ULID resource IDs, RFC 9457 error envelope.

1. Endpoint Summary

MethodPathPurposeAuth
GET/api/v1/packages/{id}Fetch PlayPackage metadataBearer + Tenant
GET/api/v1/packages/{id}/manifestFetch PlayPackage manifest JSONBearer + Tenant
POST/api/v1/packages/{id}/bundlesCreate bundle for (enrollment, device)Bearer + Tenant
GET/api/v1/bundles/{id}/downloadGet signed URL for bundle blobBearer + Tenant
POST/api/v1/packages/{id}/revokeRevoke PlayPackage and its bundlesBearer + Tenant (admin)
POST/api/v1/bundles/{id}/revokeRevoke single bundleBearer + Tenant (admin)
POST/api/v1/bundles/{id}/report-tamperClient reports hash mismatchBearer + Tenant
POST/api/v1/export/scorm/{courseVersionId}Trigger SCORM export (1.2 or 2004)Bearer + Tenant (author/admin)
POST/api/v1/export/html/{courseVersionId}Trigger HTML5 exportBearer + Tenant (author/admin)
POST/api/v1/export/xapi/{courseVersionId}Trigger xAPI exportBearer + Tenant (author/admin)
POST/api/v1/import/scormImport third-party SCORM zipBearer + Tenant (admin)
GET/api/v1/import/scorm/{importId}Poll SCORM import statusBearer + Tenant

2. GET /api/v1/packages/{id}

Returns PlayPackage metadata (without full manifest).

Request

GET /api/v1/packages/ppk_01HXYZ…ABC HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: ten_01HXYZ…DEF
Accept: application/json
If-None-Match: "sha256-abc123…"

Response (200)

{
"data": {
"id": "ppk_01HXYZ…ABC",
"tenantId": "ten_01HXYZ…DEF",
"courseVersionId": "cv_01HXYZ…GHI",
"locale": "en-US",
"builtAt": "2026-04-15T09:00:00Z",
"builtFrom": {
"draftVersion": 12,
"commitHash": "a1b2c3d4"
},
"hash": "sha256:a1b2c3…",
"signature": "eyJhbGciOi…",
"status": "built",
"formats": {
"offlineBundle": {
"bundleId": "bun_01HXYZ…JKL",
"url": "https://cdn.ghasi.io/..."
},
"scorm12": {
"zipUrl": "https://cdn.ghasi.io/...",
"sha256": "sha256:...",
"sizeBytes": 45000000
}
},
"assetsCount": 142,
"totalSizeBytes": 512000000
},
"meta": {
"requestId": "req_01HXYZ…",
"apiVersion": "v1.0",
"traceId": "00-abc-def-01"
}
}

Response (304)

If If-None-Match matches current hash, returns 304 with empty body.

Errors

StatusCodeWhen
404package_not_foundNo package with ID in this tenant
403forbiddenPackage belongs to different tenant (or insufficient scope)
410package_revokedPackage is revoked; metadata returned but usage blocked

3. GET /api/v1/packages/{id}/manifest

Returns the full PackageManifest JSON, typically consumed by the Player runtime.

Response (200)

{
"data": {
"version": "1.0",
"course": {
"id": "crs_01HXYZ…",
"versionLabel": "3.2.0",
"title": { "en-US": "Onboarding", "es-ES": "Incorporación" },
"durationMinutes": 240
},
"modules": [
{
"id": "mod_01HXYZ…",
"title": { "en-US": "Welcome" },
"durationMinutes": 30,
"lessons": [
{
"id": "les_01HXYZ…",
"title": { "en-US": "Intro" },
"durationMinutes": 10,
"blocks": [
{
"id": "blk_01HXYZ…",
"type": "media",
"assetRef": {
"id": "ast_01HXYZ…",
"sha256": "sha256:…",
"sizeBytes": 15000000,
"mime": "video/mp4"
},
"metadata": { "captionTrackId": "cap_01HXYZ…" }
}
]
}
]
}
],
"navigation": "linear",
"assistant": {
"enabled": true,
"promptId": "prompt_tutor_v3",
"promptVersion": "3.1.0",
"model": "claude-sonnet-4-20250514",
"features": {
"questionAnswering": true,
"summarization": true,
"translation": false,
"adaptiveHints": true
},
"constraints": {
"maxTokensPerSession": 20000,
"contextScope": "lesson",
"groundedOnly": true
}
},
"prerequisites": [
{
"type": "course_completion",
"targetId": "crs_01HXYZ…prereq"
}
]
},
"meta": {
"requestId": "req_01HXYZ…",
"apiVersion": "v1.0",
"contentHash": "sha256:…"
}
}

Caching

  • ETag header matches PlayPackage hash.
  • Cache-Control: public, max-age=31536000, immutable (manifest is immutable per PlayPackage).

4. POST /api/v1/packages/{id}/bundles

Creates an encrypted offline bundle for a specific enrollment + device.

Request

POST /api/v1/packages/ppk_01HXYZ…/bundles HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: ten_01HXYZ…
Idempotency-Key: 01HXYZ…
Content-Type: application/json
{
"enrollmentId": "enr_01HXYZ…",
"deviceId": "dev_01HXYZ…",
"features": {
"aiTutor": true,
"assessments": true,
"certificate": true,
"copyDownloadable": false
},
"expiresAt": "2026-10-15T00:00:00Z"
}

Response (202 Accepted)

{
"data": {
"bundleId": "bun_01HXYZ…",
"status": "building",
"estimatedCompletionSeconds": 45
},
"meta": {
"requestId": "req_01HXYZ…",
"pollUrl": "/api/v1/bundles/bun_01HXYZ…"
}
}

Response (201 Created) — When bundle already exists for this (enrollment, device, package)

{
"data": {
"bundleId": "bun_01HXYZ…",
"status": "available",
"existing": true
}
}

Errors

StatusCodeWhen
400invalid_expiryexpiresAt in past or too far future
404package_not_foundPackage does not exist
404device_not_boundDevice not bound for offline in identity-service
409package_revokedCannot create bundle for revoked package
409enrollment_invalidEnrollment is revoked or expired
422features_not_permittedFeatures requested exceed license grant

5. GET /api/v1/bundles/{id}/download

Returns a short-lived signed URL to download the encrypted bundle blob.

Response (200)

{
"data": {
"bundleId": "bun_01HXYZ…",
"downloadUrl": "https://cdn.ghasi.io/tenants/ten_…/bundles/bun_…bin?sig=…&exp=…",
"sha256": "sha256:…",
"signature": "eyJhbGciOi…",
"sizeBytes": 480000000,
"expiresAt": "2026-04-15T10:00:00Z"
},
"meta": {
"requestId": "req_01HXYZ…"
}
}

Signed URL Properties

  • TTL: 15 minutes
  • Scoped to the caller's user ID and IP (configurable per tenant)
  • Scoped to a specific HTTP verb (GET only)
  • Not cacheable; each call produces a fresh signed URL

Errors

StatusCodeWhen
404bundle_not_foundBundle does not exist
403not_bundle_ownerCaller's userId differs from bundle's license.userId
410bundle_revokedBundle is revoked
410license_expiredexpiresAt has passed

6. POST /api/v1/packages/{id}/revoke

Revokes a PlayPackage and all its bundles.

Request

POST /api/v1/packages/ppk_01HXYZ…/revoke HTTP/1.1
Authorization: Bearer {jwt-with-admin-scope}
X-Tenant-Id: ten_01HXYZ…
Idempotency-Key: 01HXYZ…
Content-Type: application/json
{
"reason": "content_error",
"notes": "Video asset contained sensitive information in frame 00:02:15"
}

Response (200)

{
"data": {
"packageId": "ppk_01HXYZ…",
"status": "revoked",
"revokedAt": "2026-04-15T09:30:00Z",
"revokedBy": "usr_01HXYZ…",
"bundlesRevoked": 247
}
}

Errors

StatusCodeWhen
404package_not_foundPackage does not exist
409already_revokedPackage already revoked (idempotent; 200 also acceptable)
403insufficient_scopeCaller lacks content:revoke scope

RBAC

Requires one of:

  • platform_admin
  • compliance_officer
  • org_owner for own tenant

7. POST /api/v1/bundles/{id}/report-tamper

Client reports a detected hash mismatch.

Request

POST /api/v1/bundles/bun_01HXYZ…/report-tamper HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: ten_01HXYZ…
Content-Type: application/json
{
"expectedHash": "sha256:a1b2…",
"actualHash": "sha256:a1b3…",
"detectedAt": "2026-04-15T09:15:00Z",
"context": {
"locationInBundle": "assets/video_001.mp4",
"deviceFingerprint": "fp_abc123",
"playerVersion": "1.4.2"
}
}

Response (204 No Content)

Always 204, regardless of whether the report is considered valid. Information leakage prevention.

Behavior

  • Creates tamper_reports row
  • Emits content.bundle.tamper_detected.v1
  • Triggers threshold-based auto-revocation per tenant policy

8. POST /api/v1/export/scorm/{courseVersionId}

Triggers SCORM export for a course version. Async; returns immediately.

Request

POST /api/v1/export/scorm/cv_01HXYZ… HTTP/1.1
Authorization: Bearer {jwt}
X-Tenant-Id: ten_01HXYZ…
Idempotency-Key: 01HXYZ…
Content-Type: application/json
{
"profile": "scorm_2004_4th",
"locale": "en-US",
"options": {
"includeXapiBridge": true,
"completionThreshold": 0.8
}
}

Valid profile values: scorm_1_2, scorm_2004_3rd, scorm_2004_4th.

Response (202 Accepted)

{
"data": {
"exportId": "exp_01HXYZ…",
"status": "building",
"estimatedCompletionSeconds": 60
},
"meta": {
"pollUrl": "/api/v1/export/exp_01HXYZ…"
}
}

Completion

When export completes, the PlayPackage is updated with formats.scorm12 (or equivalent) and event content.export.completed.v1 is emitted. The download URL is accessible via GET /api/v1/packages/{id}.

9. POST /api/v1/import/scorm

Imports a third-party SCORM zip and produces a new PlayPackage.

Request

POST /api/v1/import/scorm HTTP/1.1
Authorization: Bearer {jwt-with-admin-scope}
X-Tenant-Id: ten_01HXYZ…
Idempotency-Key: 01HXYZ…
Content-Type: multipart/form-data; boundary=…

Form fields:

  • file (binary, max 500MB): SCORM zip
  • metadata (JSON): { "targetCourseId": "crs_01HXYZ…", "locale": "en-US" }

Response (202 Accepted)

{
"data": {
"importId": "imp_01HXYZ…",
"status": "uploaded",
"estimatedCompletionSeconds": 120
},
"meta": {
"pollUrl": "/api/v1/import/scorm/imp_01HXYZ…"
}
}

Errors

StatusCodeWhen
413payload_too_largeZip exceeds 500MB
415unsupported_media_typeNot a valid zip or not SCORM
422invalid_scorm_manifestimsmanifest.xml invalid or missing
422banned_contentFile contains banned patterns (eval, iframe with external src)
403insufficient_scopeCaller lacks content:import scope

10. GET /api/v1/import/scorm/{importId}

Polls SCORM import status.

Response (200)

{
"data": {
"importId": "imp_01HXYZ…",
"status": "completed",
"playPackageId": "ppk_01HXYZ…",
"stages": [
{ "name": "extract", "status": "done", "durationMs": 1200 },
{ "name": "validate_manifest", "status": "done", "durationMs": 200 },
{ "name": "scan_content", "status": "done", "durationMs": 5000 },
{ "name": "ingest_assets", "status": "done", "durationMs": 30000 },
{ "name": "build_play_package", "status": "done", "durationMs": 8000 }
],
"errors": []
}
}

status values: uploaded, validating, scanning, ingesting, building, completed, failed.

11. Error Envelope

All error responses use RFC 9457 (problem+json):

{
"type": "https://errors.ghasi.io/content/package-revoked",
"title": "Package is revoked",
"status": 410,
"detail": "This PlayPackage was revoked on 2026-04-15 due to content error.",
"instance": "/api/v1/packages/ppk_01HXYZ…",
"code": "package_revoked",
"traceId": "00-abc-def-01",
"extensions": {
"revokedAt": "2026-04-15T09:30:00Z",
"revokeReason": "content_error"
}
}

12. Rate Limits

EndpointLimit (per tenant)Limit (per user)
GET /packages/{id}1000/min100/min
GET /packages/{id}/manifest1000/min100/min
POST /packages/{id}/bundles100/min20/min
GET /bundles/{id}/download500/min50/min
POST /packages/{id}/revoke10/min5/min
POST /bundles/{id}/report-tamper1000/min100/min (prevent flooding)
POST /export/*20/min5/min
POST /import/scorm5/min2/min

Exceeding a limit returns 429 with Retry-After header and X-RateLimit-* metadata.

13. Pagination

Collection endpoints (when added, e.g., admin list packages) follow cursor-based pagination per 05 API Design §4-5.

14. Contract Testing

  • Consumer: Player runtime (delivery-service, client apps) runs Pact tests against /packages/{id}/manifest.
  • Consumer: Catalog-service runs Pact tests against /packages/{id}.
  • Producer: Content-service verifies Pact contracts in CI before merge to main.
  • Contracts stored in Pact Broker; drift blocks deploy.

15. API Versioning

  • Current: v1 (frozen F15 for manifest, F05 for license envelope).
  • Additive changes signaled via X-API-Version: 1.N response header.
  • Breaking changes require v2 with 6-month dual-publish overlap.