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
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/v1/packages/{id} | Fetch PlayPackage metadata | Bearer + Tenant |
| GET | /api/v1/packages/{id}/manifest | Fetch PlayPackage manifest JSON | Bearer + Tenant |
| POST | /api/v1/packages/{id}/bundles | Create bundle for (enrollment, device) | Bearer + Tenant |
| GET | /api/v1/bundles/{id}/download | Get signed URL for bundle blob | Bearer + Tenant |
| POST | /api/v1/packages/{id}/revoke | Revoke PlayPackage and its bundles | Bearer + Tenant (admin) |
| POST | /api/v1/bundles/{id}/revoke | Revoke single bundle | Bearer + Tenant (admin) |
| POST | /api/v1/bundles/{id}/report-tamper | Client reports hash mismatch | Bearer + 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 export | Bearer + Tenant (author/admin) |
| POST | /api/v1/export/xapi/{courseVersionId} | Trigger xAPI export | Bearer + Tenant (author/admin) |
| POST | /api/v1/import/scorm | Import third-party SCORM zip | Bearer + Tenant (admin) |
| GET | /api/v1/import/scorm/{importId} | Poll SCORM import status | Bearer + 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
| Status | Code | When |
|---|---|---|
| 404 | package_not_found | No package with ID in this tenant |
| 403 | forbidden | Package belongs to different tenant (or insufficient scope) |
| 410 | package_revoked | Package 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
ETagheader matches PlayPackagehash.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
| Status | Code | When |
|---|---|---|
| 400 | invalid_expiry | expiresAt in past or too far future |
| 404 | package_not_found | Package does not exist |
| 404 | device_not_bound | Device not bound for offline in identity-service |
| 409 | package_revoked | Cannot create bundle for revoked package |
| 409 | enrollment_invalid | Enrollment is revoked or expired |
| 422 | features_not_permitted | Features 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
| Status | Code | When |
|---|---|---|
| 404 | bundle_not_found | Bundle does not exist |
| 403 | not_bundle_owner | Caller's userId differs from bundle's license.userId |
| 410 | bundle_revoked | Bundle is revoked |
| 410 | license_expired | expiresAt 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
| Status | Code | When |
|---|---|---|
| 404 | package_not_found | Package does not exist |
| 409 | already_revoked | Package already revoked (idempotent; 200 also acceptable) |
| 403 | insufficient_scope | Caller lacks content:revoke scope |
RBAC
Requires one of:
platform_admincompliance_officerorg_ownerfor 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_reportsrow - 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 zipmetadata(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
| Status | Code | When |
|---|---|---|
| 413 | payload_too_large | Zip exceeds 500MB |
| 415 | unsupported_media_type | Not a valid zip or not SCORM |
| 422 | invalid_scorm_manifest | imsmanifest.xml invalid or missing |
| 422 | banned_content | File contains banned patterns (eval, iframe with external src) |
| 403 | insufficient_scope | Caller 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
| Endpoint | Limit (per tenant) | Limit (per user) |
|---|---|---|
| GET /packages/{id} | 1000/min | 100/min |
| GET /packages/{id}/manifest | 1000/min | 100/min |
| POST /packages/{id}/bundles | 100/min | 20/min |
| GET /bundles/{id}/download | 500/min | 50/min |
| POST /packages/{id}/revoke | 10/min | 5/min |
| POST /bundles/{id}/report-tamper | 1000/min | 100/min (prevent flooding) |
| POST /export/* | 20/min | 5/min |
| POST /import/scorm | 5/min | 2/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.Nresponse header. - Breaking changes require
v2with 6-month dual-publish overlap.