Skip to main content

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

SurfaceStylePurpose
Tenant RESTREST + JSONApp ↔ services (authenticated)
Public RESTREST + JSONVerification endpoints (unauthenticated, rate-limited)
StreamingSSE (Server-Sent Events)AI completions, sync live tail, long authoring jobs
Real-time collabWebSocketYjs awareness in authoring
Webhooks (out)Signed JSON POSTTenant-configurable on events
Webhooks (in)Signed JSON POSTPayment processors, email providers
xAPI / cmi5REST (xAPI 1.0.3)Progress-service LRS surface
GraphQL (optional)GraphQLAnalytics-heavy reads, optional
LTI 1.3LTI AdvantageEmbedding into 3rd-party LMSs
Inter-serviceNATS req/reply + HTTPBounded ≤ 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.42 response 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):

HeaderPurpose
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-8JSON 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 returns 410 cursor.stale.

6.2 Request / Response

GET /api/v1/courses?page[size]=50&page[cursor]=eyJ2…
  • Hard cap page.size = 200.
  • meta.page.nextCursor absent → end of list.
  • meta.page.totalApproximate is 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.

CodeHTTPRetriableMeaning
validation.field_required422noRequired field missing
validation.field_invalid422noField fails type/format
validation.conflict422noTwo fields inconsistent
auth.invalid_token401noJWT invalid or expired
auth.unauthenticated401noMissing credentials
auth.email_unverified403noEmail not yet verified
auth.mfa_required401noStep-up required
authz.forbidden403noABAC denies
authz.tenant_not_a_member403noCaller is not a member of the requested tenant
authz.insufficient_scope403noToken scope too narrow
resource.not_found404noResource missing or hidden
resource.gone410noResource permanently removed
resource.conflict409rareWrite conflict (version mismatch or uniqueness)
resource.locked423yesAggregate locked (e.g. publish in progress); retry after short delay
precondition.failed412noIf-Match mismatch
precondition.required428noIf-Match header missing on safe-write route
rate.limited429yesRate limit exceeded; respect Retry-After
tenant.region.unsupported400noUnsupported region choice
tenant.slug.duplicate422noSlug already taken
idempotency.key_missing428noRequired Idempotency-Key header missing
idempotency.key_conflict409noSame key, different payload
cursor.stale410noCursor filters diverged from current request
cursor.invalid400noCursor payload corrupt
ai.refused.safety422noAI call refused by moderation
ai.refused.budget429yesAI budget exhausted; retry next period
ai.refused.provider502yesAll AI providers unavailable
ai.refused.policy403noCaller lacks AI scope or policy denies
sync.conflict.detected409noClient mutation conflicts with server state
sync.mutation.rejected409noPolicy rejected mutation (server-authoritative aggregate)
sync.cursor.out_of_range410noCursor too old; full resync required
sync.payload.too_large413noPush batch > 10 MB
content.bundle.tampered409noLocal bundle hash mismatch
content.bundle.revoked410noBundle revoked server-side
content.license.expired403noLicense envelope expired
content.license.device_unbound403noDevice not bound to license
export.quota_exceeded429yesExport quota exceeded
export.scope.cross_region_denied403noData residency forbids
legal.hold451noLegal / compliance hold
internal.unhandled500maybeUnhandled server error — report traceId
upstream.unavailable502yesUpstream provider failed
service.unavailable503yesMaintenance / circuit open; respect Retry-After
upstream.timeout504yesUpstream timed out
unsupported_media_type415noNon-JSON body
method_not_allowed405noWrong 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:

  1. Compute requestHash = SHA-256(normalize(body)).
  2. Lookup (tenantId, userId, route, key).
  3. If found:
    • Same requestHash: return the stored response snapshot (same status + headers + body).
    • Different requestHash: 409 idempotency.key_conflict.
  4. If not found:
    • Acquire a short advisory lock on the key.
    • Execute the handler.
    • Persist {requestHash, responseSnapshot, status, expiresAt}.
    • Return result.
  5. Keys are single-user + single-route to avoid cross-contamination.
  6. 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: integer in responses + ETag: "<version>".
  • PATCH/PUT must carry If-Match: "<version>" — missing → 428 precondition.required.
  • Version mismatch → 412 precondition.failed with current version in body for client reconciliation.

11. Authentication

CallerMechanism
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-servicemTLS + 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/check allows 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: enables Last-Event-ID resume on reconnect.
  • event: error is terminal; no further frames.
  • event: done is 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 | miss
  • X-AI-Cost-MicroUSD: <int>
  • X-AI-Safety: allow | warn | block
  • X-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.json versioned by API major.
  • Platform-aggregated doc openapi/platform.json built in CI.
  • Breaking-change detection: openapi-diff in 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 routes
  • Deprecation: 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.