API Design
:::info Source
Sourced from docs/05-api-design.md in the documentation repo.
:::
Companion: 03 Microservices · 04 Event-Driven · 13 Security & Tenancy · ADR 0001 — Kong edge gateway
This is the authoritative API contract document. Every service (03) conforms to these conventions. Deviations require explicit architectural review.
1. Surfaces
| Surface | Style | Purpose |
|---|---|---|
| Tenant REST | REST + JSON | App ↔ services (authenticated) |
| Public REST | REST + JSON | Verification endpoints (unauthenticated, rate-limited) |
| Streaming | SSE (Server-Sent Events) | AI completions, sync live tail, long authoring jobs |
| Real-time collab | WebSocket | Yjs awareness in authoring |
| Webhooks (out) | Signed JSON POST | Tenant-configurable on events |
| Webhooks (in) | Signed JSON POST | Payment processors, email providers |
| xAPI / cmi5 | REST (xAPI 1.0.3) | Progress-service LRS surface |
| GraphQL (optional) | GraphQL | Analytics-heavy reads, optional |
| LTI 1.3 | LTI Advantage | Embedding into 3rd-party LMSs |
| Inter-service | NATS req/reply + HTTP | Bounded ≤ 2 hops per user request |
North-south ingress: External callers (web, mobile, partners) reach these surfaces through Kong Gateway at the platform’s public base URL. Kong terminates TLS, applies edge auth and rate limits, and routes by path to the owning NestJS service upstream. See ADR 0001.
2. Versioning
- URL prefix major:
/api/v1/…. Major bumps via/api/v2/…; ≥ 1-release overlap. - Minor: additive only; signalled via
X-API-Version: 1.42response header. - Deprecation:
Deprecation: true,Sunset: <RFC 7231 date>,Link: <doc-url>; rel="deprecation". - Content negotiation (optional):
Accept: application/json; profile="…course/v1". - Never: silently changing field types or enum narrowing.
3. Resource Naming
- Plural-noun collection:
/courses,/enrollments,/assignments. - Sub-resource:
/courses/{id}/versions/{vid}. - Action on resource when REST shape doesn't fit: POST to
/courses/{id}/publish,/orders/{id}/refund. - IDs are stable, opaque, URL-safe ULIDs.
- No trailing slashes; canonical URIs are lowercase; collections plural.
4. Request Envelope
Mandatory headers on writes (with few documented exceptions):
| Header | Purpose |
|---|---|
Authorization: Bearer <jwt> | Access token. X-API-Key for S2S integrations. |
X-Tenant-Id: <tenantId> | Tenant context; must equal JWT tid. Rejected if mismatched. |
Idempotency-Key: <ulid> | Required on every write endpoint (§10). |
Accept-Language: <bcp47> | Locale negotiation. |
traceparent: … | W3C trace context. |
If-Match: "<version>" | Optimistic concurrency for PATCH/PUT (§11). |
If-None-Match: "<etag>" | Cache validation for GETs. |
Content-Type: application/json; charset=utf-8 | JSON only. |
X-Client-Mutation-Id (sync only) | Required on POST /sync/push mutations (§17). |
5. Response Envelope
Success — single resource:
{
"data": { "id": "crs_01H…", "title": "Onboarding", "…": "…" },
"meta": { "requestId": "req_01H…", "apiVersion": "v1.42", "traceId": "00-…-…-01" }
}
Success — collection:
{
"data": [ /* resources */ ],
"meta": {
"page": { "size": 50, "cursor": "eyJsIjp…", "nextCursor": "eyJsIjp…", "prevCursor": null, "totalApproximate": 1284 },
"filters": { "status": "active" },
"sort": [ { "field": "createdAt", "dir": "desc" } ],
"requestId": "req_01H…",
"apiVersion": "v1.42"
}
}
Error — problem+json (RFC 9457 extended):
{
"error": {
"type": "https://errors.ghasi.io/validation/field_required",
"code": "validation.field_required",
"title": "Missing required field",
"status": 422,
"detail": "Field 'email' is required.",
"instance": "/api/v1/users",
"errors": [ { "field": "email", "code": "required" } ],
"traceId": "00-…-…-01",
"requestId": "req_01H…",
"retriable": false,
"retryAfter": null,
"docUrl": "https://docs.ghasi.io/errors/validation/field_required"
}
}
6. Pagination (Cursor-Only)
Collections always expose cursor pagination. Offset pagination is forbidden (prevents "page 1000" pathologies).
6.1 Cursor Format
Opaque base64url-encoded JSON:
{ "v": 1, "k": { "createdAt": "2026-04-15T10:00:00Z", "id": "crs_01H…" }, "d": "desc", "f": { "status": "active" } }
v— cursor version (bump on shape change).k— sort-key tuple of the last row.d— sort direction.f— filter fingerprint; if request filters differ, server returns410 cursor.stale.
6.2 Request / Response
GET /api/v1/courses?page[size]=50&page[cursor]=eyJ2…
- Hard cap
page.size = 200. meta.page.nextCursorabsent → end of list.meta.page.totalApproximateis best-effort; may be omitted for > 1 M rows.
6.3 Stability
- Results stable under concurrent inserts: new rows appear only at sort boundaries; existing cursors remain valid.
- Deletes do not invalidate cursors; page sizes may drop.
7. Filtering, Sorting, Fields
7.1 Filter grammar
filter[field]=value # equality
filter[field][op]=value # op ∈ {eq, ne, gt, gte, lt, lte, in, nin, contains, starts, ends}
filter[field]=null | "" # null / empty
filter[tag][in]=compliance,safety # list
- Unsupported fields →
422 filter.field.unsupported. - Unsupported ops →
422 filter.op.unsupported. - Conflicting filters →
422 filter.conflict.
7.2 Sort
sort=field # asc
sort=-field # desc
sort=-createdAt,title
- Multi-sort up to 3 fields; beyond →
422 sort.too_many. - Unknown field →
422 sort.field.unsupported.
7.3 Sparse fieldsets
fields[course]=id,title,locale
fields[author]=id,name
Unknown type → 422 fields.type.unknown. Unknown field → silently ignored (forward-compat).
8. Errors — Canonical Codes
Namespace: {category}.{reason} or {category}.{reason}.{detail}. Each has a stable HTTP status + message template. Full registry lives at /openapi/errors.json.
| Code | HTTP | Retriable | Meaning |
|---|---|---|---|
validation.field_required | 422 | no | Required field missing |
validation.field_invalid | 422 | no | Field fails type/format |
validation.conflict | 422 | no | Two fields inconsistent |
auth.invalid_token | 401 | no | JWT invalid or expired |
auth.unauthenticated | 401 | no | Missing credentials |
auth.email_unverified | 403 | no | Email not yet verified |
auth.mfa_required | 401 | no | Step-up required |
authz.forbidden | 403 | no | ABAC denies |
authz.tenant_not_a_member | 403 | no | Caller is not a member of the requested tenant |
authz.insufficient_scope | 403 | no | Token scope too narrow |
resource.not_found | 404 | no | Resource missing or hidden |
resource.gone | 410 | no | Resource permanently removed |
resource.conflict | 409 | rare | Write conflict (version mismatch or uniqueness) |
resource.locked | 423 | yes | Aggregate locked (e.g. publish in progress); retry after short delay |
precondition.failed | 412 | no | If-Match mismatch |
precondition.required | 428 | no | If-Match header missing on safe-write route |
rate.limited | 429 | yes | Rate limit exceeded; respect Retry-After |
tenant.region.unsupported | 400 | no | Unsupported region choice |
tenant.slug.duplicate | 422 | no | Slug already taken |
idempotency.key_missing | 428 | no | Required Idempotency-Key header missing |
idempotency.key_conflict | 409 | no | Same key, different payload |
cursor.stale | 410 | no | Cursor filters diverged from current request |
cursor.invalid | 400 | no | Cursor payload corrupt |
ai.refused.safety | 422 | no | AI call refused by moderation |
ai.refused.budget | 429 | yes | AI budget exhausted; retry next period |
ai.refused.provider | 502 | yes | All AI providers unavailable |
ai.refused.policy | 403 | no | Caller lacks AI scope or policy denies |
sync.conflict.detected | 409 | no | Client mutation conflicts with server state |
sync.mutation.rejected | 409 | no | Policy rejected mutation (server-authoritative aggregate) |
sync.cursor.out_of_range | 410 | no | Cursor too old; full resync required |
sync.payload.too_large | 413 | no | Push batch > 10 MB |
content.bundle.tampered | 409 | no | Local bundle hash mismatch |
content.bundle.revoked | 410 | no | Bundle revoked server-side |
content.license.expired | 403 | no | License envelope expired |
content.license.device_unbound | 403 | no | Device not bound to license |
export.quota_exceeded | 429 | yes | Export quota exceeded |
export.scope.cross_region_denied | 403 | no | Data residency forbids |
legal.hold | 451 | no | Legal / compliance hold |
internal.unhandled | 500 | maybe | Unhandled server error — report traceId |
upstream.unavailable | 502 | yes | Upstream provider failed |
service.unavailable | 503 | yes | Maintenance / circuit open; respect Retry-After |
upstream.timeout | 504 | yes | Upstream timed out |
unsupported_media_type | 415 | no | Non-JSON body |
method_not_allowed | 405 | no | Wrong method on route |
retriable: true codes include a Retry-After header (seconds or RFC 1123 date).
9. Idempotency — Formal Semantics
Every write endpoint requires Idempotency-Key. Server stores the mapping (tenantId, userId, route, key) → { requestHash, responseSnapshot, status, expiresAt } for 24 hours.
Algorithm on POST/PUT/PATCH/DELETE-with-body:
- Compute
requestHash = SHA-256(normalize(body)). - Lookup
(tenantId, userId, route, key). - If found:
- Same
requestHash: return the stored response snapshot (same status + headers + body). - Different
requestHash:409 idempotency.key_conflict.
- Same
- If not found:
- Acquire a short advisory lock on the key.
- Execute the handler.
- Persist
{requestHash, responseSnapshot, status, expiresAt}. - Return result.
- Keys are single-user + single-route to avoid cross-contamination.
- Keys never decrement quotas twice; quota + retry work together.
Client semantics:
- Generate ULID per logical operation.
- Safe to retry with network errors/5xx/timeout.
- Do not reuse keys across different intents.
10. Concurrency — Optimistic Locking
- Mutable aggregates expose
version: integerin responses +ETag: "<version>". - PATCH/PUT must carry
If-Match: "<version>"— missing →428 precondition.required. - Version mismatch →
412 precondition.failedwith current version in body for client reconciliation.
11. Authentication
| Caller | Mechanism |
|---|---|
| User app (browser / mobile / desktop) | OAuth 2.1 + PKCE → access JWT (15 min) + rotating refresh (30 d single-use-rotation) |
| External system (server-to-server) | API Key + HMAC request signing (§14) |
| Inter-service | mTLS + service JWT |
| Public verify (certificates) | Unauthenticated; heavily rate-limited |
JWT claims (excerpt):
{
"iss": "https://auth.ghasi.io",
"aud": "ghasi-platform",
"sub": "usr_01H…",
"tid": "ten_01H…",
"tids": ["ten_01H…","ten_02H…"],
"roles": ["org_admin"],
"scope": "openid profile catalog:read enrollment:write",
"did": "dev_01H…",
"amr": ["pwd","webauthn"],
"iat": 1755100000,
"exp": 1755100900,
"jti": "jti_01H…"
}
Rotation: asymmetric signing keys (EdDSA Ed25519) stored in KMS with kid in header; public JWKS at /.well-known/jwks.json per auth domain.
12. Authorization — RBAC + ABAC
- Coarse RBAC roles:
platform_admin,compliance_officer,org_owner,org_admin,org_manager,provider_admin,author,reviewer,publisher,learner,individual. - Fine-grained ABAC predicates over
tenant_id,org_unit,resource.created_by,resource.visibility. - Decision endpoint:
POST /api/v1/authz/checkallows UIs to ask before rendering actions; body{ resource: { type, id? }, action }, response{ allow: boolean, reason?: string }. - All decisions logged with
decisionId(linked into AI HITL audit chains where applicable).
13. Rate Limiting & Quotas
- Per API key (S2S): sliding window Token Bucket (Redis).
- Per user + route: per-tenant plan-tied caps.
- AI quotas: enforced in ai-gateway-service; client may preflight via
POST /api/v1/ai/budgets/check. - Sync quotas: push payload ≤ 10 MB per batch; per-device per-minute rate.
- Public verify: IP + path rate limit (strong).
Response: 429 rate.limited with Retry-After + X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
14. HMAC Signing (Webhooks + API Keys)
Signature format (shared convention):
X-Webhook-Signature: t=<unix>,v1=<hex-hmac-sha256>
X-Webhook-Nonce: <128-bit random, single-use within 5 min>
X-Webhook-Id: <eventId>
X-Webhook-Event: <eventType>
Verification (pseudocode):
const now = Math.floor(Date.now()/1000);
if (Math.abs(now - parseInt(t, 10)) > 300) reject('stale');
if (nonceSeenWithin(5min, nonce)) reject('replay');
const signedPayload = `${t}.${nonce}.${rawBody}`;
const expected = hmacSha256(secret, signedPayload);
if (!timingSafeEqual(expected, v1)) reject('bad_signature');
rememberNonce(nonce, ttl = 6min);
15. Streaming (SSE)
Used for AI completions, sync live tail, long authoring jobs.
15.1 Request
POST /api/v1/play-sessions/{id}/assistant/turns
Accept: text/event-stream
Idempotency-Key: <ulid>
15.2 Frame format
: heartbeat every 15s as a comment frame
event: token
id: 17
data: {"delta":"Photosynthesis","done":false}
event: tool_call
id: 18
data: {"name":"lookupBlock","args":{"id":"blk_…"}}
event: tool_result
id: 19
data: {"name":"lookupBlock","ok":true,"result":{"…":"…"}}
event: safety
id: 20
data: {"action":"warn","category":"violence"}
event: done
id: 99
data: {"completionId":"cmp_…","aiProvenance":{…}}
event: error
id: 100
data: {"code":"ai.refused.safety","title":"Refused","category":"self_harm"}
id:enablesLast-Event-IDresume on reconnect.event: erroris terminal; no further frames.event: doneis terminal; connection closes after this.- Client must ignore unknown
event:types (forward-compat).
16. Webhooks (Out)
Tenants subscribe per topic via notification-service. Payload is exactly the internal EventEnvelope (04 §4). Headers as §14.
Retry: exponential backoff 1s, 5s, 30s, 2min, 10min, 1h, 6h, 24h then DLQ + alert. Replay endpoint POST /api/v1/notifications/webhooks/replay/{id}.
Subscriptions:
url,topics[](list of subjects or wildcards),secret,activeFromTs,notificationEmail.- Limit 100 subscriptions per tenant; 100 topics per subscription.
17. Sync Contract (normative)
Formal echo of sync-service.
17.1 Pull
GET /api/v1/sync/pull?since={lamport}&scope={scope}&limit={50..500}
Authorization: Bearer <jwt>
200 OK
{
"data": {
"entities": [ { "type":"Block", "id":"blk_…", "version":12, "vectorClock":{"dev_…":8}, "data":{…} }, … ],
"deletes": [ { "type":"Block", "id":"blk_…" } ],
"cursor": { "lamport": 1024, "scope": "all" },
"hasMore": false
}
}
17.2 Push
POST /api/v1/sync/push
Authorization: Bearer <jwt>
Idempotency-Key: <ulid>
Content-Type: application/json
{
"deviceId": "dev_01H…",
"mutations": [
{
"clientMutationId": "9a3f…",
"service": "authoring",
"entityType": "Block",
"entityId": "blk_…",
"op": "update",
"baseVersion": 14,
"vectorClock": { "dev_01H…": 22 },
"payload": { /* … */ },
"occurredAt": "2026-04-15T10:00:00Z"
}
]
}
200 OK
{
"data": {
"appliedCount": 7,
"conflicts": [ { "clientMutationId": "9a3f…", "code": "sync.conflict.detected", "serverState": {…}, "policy": "lww", "resolution": "pending" } ],
"rejected": [ { "clientMutationId": "…", "code": "sync.mutation.rejected", "reason": "server_authoritative", "serverState": {…} } ],
"newServerEntities": [ … ],
"cursor": { "lamport": 1055, "scope": "all" }
}
}
17.3 Resolve
POST /api/v1/sync/resolve
Content-Type: application/json
{ "conflictId": "cfl_…", "resolution": "kept_client" | "kept_server" | "merged", "mergedPayload"?: {…} }
17.4 Conflicts
GET /api/v1/sync/conflicts?state=pending&limit=50&cursor=…
18. AI APIs (normative)
Canonical endpoints (details in ai-gateway-service):
POST /api/v1/ai/complete # sync (≤ 30 s)
POST /api/v1/ai/complete/stream # SSE
POST /api/v1/ai/generate # structured output, schema-validated
POST /api/v1/ai/embeddings
POST /api/v1/ai/embeddings/search
POST /api/v1/ai/image/generate
POST /api/v1/ai/audio/tts
POST /api/v1/ai/audio/stt
POST /api/v1/ai/moderate
POST /api/v1/ai/redact-pii
POST /api/v1/ai/budgets/check
POST /api/v1/ai/local-inference/telemetry
GET /api/v1/ai/prompts # catalog
Response headers on every AI call:
X-AI-Model: <modelId>X-AI-Prompt: <promptId@semver>X-AI-Cache: hit | missX-AI-Cost-MicroUSD: <int>X-AI-Safety: allow | warn | blockX-AI-Decision: <decisionId?>
19. xAPI / cmi5
xAPI 1.0.3 endpoints under /api/v1/lrs/… (progress-service). cmi5 launch /api/v1/cmi5/launch?.... Both preserve tenant context via signed launch parameters.
20. LTI 1.3
/api/v1/lti/launch, /api/v1/lti/deeplink, /api/v1/lti/jwks, /api/v1/lti/score for AGS. Platform keys managed per-tenant by tenant-service.
21. OpenAPI
- Each service ships
/openapi.jsonversioned by API major. - Platform-aggregated doc
openapi/platform.jsonbuilt in CI. - Breaking-change detection:
openapi-diffin CI; blocks PR without major bump.
22. Contract Testing
- Pact consumer-driven between every producer/consumer pair.
- Pact broker as platform infra; CI gate on producer before merge.
- Event schemas separately validated against registry (04 §17).
23. Examples
23.1 Place an order
POST /api/v1/orders
Authorization: Bearer …
X-Tenant-Id: ten_01H…
Idempotency-Key: 01H8N…
If-Match: none
Content-Type: application/json
{
"buyerTenantId": "ten_…",
"lines": [ { "listingId": "lst_…", "planId": "plan_…", "qty": 1 } ],
"couponCode": "WELCOME10"
}
23.2 Stream AI tutor turn
POST /api/v1/play-sessions/ps_.../assistant/turns
Authorization: Bearer …
Idempotency-Key: 01H8N…
Accept: text/event-stream
Content-Type: application/json
{
"prompt": "Why is photosynthesis important?",
"contextRefs": { "lessonId": "lsn_…", "blockIds": ["blk_…"] }
}
23.3 Push offline mutations
POST /api/v1/sync/push
Authorization: Bearer …
Idempotency-Key: 01H8N…
Content-Type: application/json
{
"deviceId": "dev_01H…",
"mutations": [ /* LocalMutation[] per §17.2 */ ]
}
23.4 Problem+json error
422 Unprocessable Entity
Content-Type: application/problem+json
{
"error": {
"type": "https://errors.ghasi.io/validation/field_required",
"code": "validation.field_required",
"title": "Missing required field",
"status": 422,
"detail": "Field 'email' is required.",
"errors": [ { "field": "email", "code": "required" } ],
"requestId": "req_01H…",
"traceId": "00-…-…-01",
"retriable": false,
"docUrl": "https://docs.ghasi.io/errors/validation/field_required"
}
}
24. Observability & Headers
Every response includes:
X-Request-Id: <ulid>X-API-Version: <major.minor>traceresponse: <traceparent>(echo)X-RateLimit-*on throttled routesDeprecation: true/Sunset: …when applicable
25. Why This Design
REST + JSON keeps the surface familiar to integrators. Problem+JSON keeps errors machine-readable. Cursor-only pagination avoids O(N) deep-paging. Mandatory idempotency keys make retries safe. SSE keeps streaming simple without bespoke WebSocket framing for one-way data. A single sync contract prevents service-by-service drift in offline behavior. AI is an explicit surface so safety + cost + provenance can be enforced at the boundary. All error codes are machine-readable and tied to documentation URLs, so consumers can build recovery logic from metadata, not copy-paste.