Conventions
Error envelope
Every error response — whatever the status code — uses the same shape:
{
"error": {
"code": "STUDENT_NOT_FOUND",
"message": "Student not found.",
"details": {
"studentId": "0190a1b2-..."
},
"requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
}message is always English and meant for logs, not end users — translate by code in your own UI. details is present only when it adds something a client can act on (which field failed validation, which id was looked up). requestId matches the value the API also logs server-side — include it when reporting an issue.
Error codes
Every code the API can return, generated directly from packages/shared/src/errors.ts — the single source every error factory in the codebase constructs from, so this table can never drift from what the API actually sends.
| Code | HTTP status | Description |
|---|---|---|
| VALIDATION_ERROR | 422 | The request parsed, but its content is not acceptable: a schema failure at the boundary (body, query, path params or headers — always carrying `details.issues`) or a domain rule rejecting a well-formed value. Every validation failure in the API uses this one status; a 400 (`MALFORMED_REQUEST`) means only that the request could not be read at all. |
| MALFORMED_REQUEST | 400 | The request could not be read at all, before any schema ran: an unparseable body, an unusable content type, a payload over the size limit. Deliberately not `VALIDATION_ERROR`, which always means 422, so a client can tell "I could not read your request" apart from "I read it and its content is not acceptable" from the code alone. |
| UNAUTHORIZED | 401 | No valid credentials (JWT, API key, or session) were presented. |
| FORBIDDEN | 403 | The caller is authenticated but lacks the permission this action requires. |
| NOT_FOUND | 404 | The requested resource does not exist (or is not visible to this tenant). |
| CONFLICT | 409 | The request conflicts with the resource’s current state. |
| INTERNAL_ERROR | 500 | An unexpected server error. Never leaks a stack trace or SQL to the client. |
| PAYMENT_REQUIRED | 402 | The tenant's subscription does not currently permit this action. |
| SUBSCRIPTION_SUSPENDED | 402 | Subscription/license lifecycle has reached `suspended`/`stopped` (docs/ARCHITECTURE.md §1). |
| RATE_LIMITED | 429 | The caller exceeded a rate limit (per-IP, per-user, or per-API-key sliding window). See the `RateLimit-*` response headers. |
| INVALID_CREDENTIALS | 401 | Email/password did not match any account. |
| ACCOUNT_LOCKED | 423 | Account temporarily locked out after repeated failed logins. |
| ACCOUNT_DISABLED | 403 | The user account itself has been disabled (distinct from a locked-out but active account). |
| SESSION_REVOKED | 401 | A refresh token that was already rotated away was presented again (reuse detected). |
| TOKEN_INVALID | 401 | A refresh/reset/verification token is missing, malformed, expired, or already used. |
| REGISTRATION_DISABLED | 403 | Self-registration is disabled for this tenant/deployment. |
| ROLE_IMMUTABLE | 403 | Attempted to edit the immutable system `owner` role. |
| PERMISSION_ESCALATION | 403 | Caller tried to grant a permission (directly, or via a role assignment) they do not themselves hold. |
| INVITATION_INVALID | 400 | The invitation link is invalid, expired, or already used. |
| MEMBERSHIP_INACTIVE | 403 | The caller's membership in the active tenant is not `active` (invited or disabled). |
| TENANT_SUSPENDED | 403 | The active tenant itself is not usable (archived). Not used for billing suspension — see `subscriptionSuspended`. |
| TENANT_NOT_FOUND | 404 | No tenant matches the given id/slug, or it is not visible to this caller. |
| TENANT_SLUG_TAKEN | 409 | The requested tenant slug is already in use. |
| LICENSE_INVALID | 400 | A license JWT failed signature/shape verification (self-hosted `LicenseService`). |
| VERSION_CONFLICT | 409 | `common/optimistic-lock.ts`'s `updateWithVersion` matched zero rows: the caller's `expectedVersion` no longer matches the row's current `version` (someone else wrote it first). Distinct from the generic `conflict()` (`CONFLICT`) so clients can special-case "reload and retry" instead of showing a generic conflict message. |
| IDEMPOTENCY_KEY_REQUIRED | 428 | An API-key caller sent a POST/PUT/PATCH/DELETE with no `Idempotency-Key` header (docs/specs/phase4-integration-mcp.md §A). 428 (not 400) so clients can tell "you forgot a required precondition" apart from a generic validation failure — documented choice, see `docs/build-log/phase4-integration.md`. |
| IDEMPOTENCY_KEY_REUSED | 409 | The same `Idempotency-Key` was reused with a different method/path/body than the first request that used it. |
| OAUTH_CLIENT_NOT_FOUND | 404 | `POST /api/v1/oauth/authorize/decision` (or `/context`) referenced an unknown or disabled OAuth client. |
| OAUTH_INVALID_REDIRECT_URI | 400 | The `redirect_uri` does not exactly match one registered for this OAuth client. |
| OAUTH_ACCESS_DENIED | 403 | The tenant/membership selected on the consent screen does not hold `mcp.access`. |
| FEATURE_DISABLED | 403 | `FeatureGuard` (docs/specs/phase6-courses-video.md §3.1) found the resolved tenant's effective feature flags do not grant the `@RequireFeature(...)` key the route declares. Runs after `PermissionsGuard`, so a caller lacking the permission still sees `FORBIDDEN`, never this. |
| QUOTA_EXCEEDED | 403 | A plan-quota check (docs/specs/phase6-courses-video.md §3.2, mirrors the notification quota) rejected a create before any write happened. `details` carries `{ limit, current, key }`. |
| VIDEO_URL_INVALID | 422 | `parseVideoUrl()` (`packages/shared/src/video.ts`) could not recognize or validate a pasted video URL. `details.reason` names the specific cause (e.g. `'password_protected'`, `'playlist_not_supported'`, `'unknown_host'`). |
| VIDEO_PROVIDER_UNSUPPORTED | 422 | The URL's provider was recognized but is not usable: either it is outside `ENABLED_VIDEO_PROVIDERS` (e.g. `vimeo`, `bunny`) or it is `terabox` while the tenant's `curriculum.teraboxEnabled` setting is off (`details.reason = 'terabox_not_enabled'`). |
400 vs 422
Validation is always 422. Anything the API reads but will not accept — a body, query string, path parameter or header that fails its schema, or a domain rule rejecting a well-formed value — comes back as 422 VALIDATION_ERROR, and a schema failure carries the failing fields in details.issues:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input: expected string, received undefined",
"details": {
"issues": [
{
"path": [
"branchId"
],
"message": "Invalid input"
}
]
},
"requestId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
}400 MALFORMED_REQUEST means only that the request could not be read at all — unparseable JSON, an unusable Content-Type, an oversized payload. In short: a 400 is a bug in how you serialise the request, a 422 is a bug in what you put in it. A handful of endpoints also use 400 for one specific named rejection (INVITATION_INVALID, LICENSE_INVALID, OAUTH_INVALID_REDIRECT_URI) — which is why you branch on error.code, never on the status.
Cursor pagination
Every list endpoint is cursor-paginated — never offset/page-number, so results stay correct even while rows are being inserted concurrently. Pass limit (max 100, default varies by endpoint) and, for every page after the first, the cursor from the previous page's response:
{
"data": [
"…"
],
"nextCursor": "eyJpZCI6IjAxOTAwMDAwLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwMSJ9"
}nextCursor is string | null — null means you've reached the last page. Treat the cursor as opaque: it's a base64url-encoded pointer, not a row offset, and its internal shape may change.
Money
Every monetary value is a nested object — { amountMinor, currency } — an integer in the currency's minor unit (cents, agorot, fils) paired with its own currency field on that same object, e.g. an invoice's total.amountMinor + total.currency, a payment's amountMinor + currency. It is never a bare number with the currency living somewhere else on the parent object. Never a float. { amountMinor: 1250, currency: "USD" } is $12.50.
Timestamps
Every timestamp is ISO-8601 in UTC (2026-03-02T09:00:00.000Z). Dates with no time component (a due date, a date of birth) are plain YYYY-MM-DD. Convert to the tenant's timezone (default Asia/Jerusalem) for display — the API never does this for you.
Multi-tenancy
You never pass a tenant id yourself. A first-party JWT carries the tenant chosen at select-tenant time; an API key is permanently bound to the tenant it was created in. Every query the API runs is scoped to that tenant at both the application layer and the database's own row-level security — there is no way to address another tenant's data by guessing an id.
requestId is your friend
Log error.requestId alongside your own request logs. It's the fastest way to get a fast, specific answer when something looks wrong server-side.