Errors
The error envelope
Every error, at every status, uses one shape: the ApiError schema:
{
"code": "VALIDATION_ERROR",
"message": "Human-readable description.",
"details": [{ "field": "password", "issue": "too short" }],
"retryAfter": 60
}code: a stable machine-readable key (always present).message: human-readable text (always present).details[]: per-field validation issues ({ field, issue }), present on validation failures.retryAfter: seconds to wait, present on rate-limited responses.
HTTP statuses
| Status | When |
|---|---|
401 | Not authenticated · bad credentials · expired access token · invalid/rotated refresh token · invalid/expired quickCode · login brute-force lockout (with Retry-After) |
403 | Authenticated but lacking permission within your own org (e.g. a second register, wrong-scope) |
404 | Not found or cross-organization (privacy: never confirms a foreign resource exists) |
409 | Conflict: class has students on delete · teacher already assigned · quickCode collision |
422 | Validation: school.country ≠ org.country · grade outside 1-4 · bad slug · short password |
Login brute-force returns 401 + Retry-After, not 429. This API does not emit a bare 429
for the login limiter. Read the Retry-After header (and retryAfter in the body) and back off.
404 ≠ “doesn’t exist”. A real resource in another organization is reported as 404 by design.
A resource in your own org that your role can’t reach is 403. See
Core Concepts → Tenant isolation.
Error-code catalog
These are the error-code strings the API actually emits. If you need the precise codes a specific
endpoint can return, read that operation’s responses in the API Reference;
do not assume codes that aren’t listed here.
code | Where |
|---|---|
PERMISSION_DENIED | 403: registration closed; missing permission |
VALIDATION_ERROR | 422: body validation failed |
QUICKCODE_INVALID_OR_EXPIRED | 401: student quickCode invalid or expired |
QUICKCODE_RATE_LIMIT | 401: too many quickCode attempts |
Decision-engine “safe-stop” responses (not errors)
Several RTI-engine endpoints return 200 with a non-committal outcome rather than an error when
the data isn’t sufficient to decide. These are deliberate safety stops (V-3), not failures. Read the
outcome field and surface a “needs more data” state instead of retrying:
| Outcome | Where | Meaning |
|---|---|---|
do_not_decide_yet | skill-status, profile, monitoring | Evidence is split or insufficient; every downstream consumer must halt and wait. See The Decision Engine. |
blocked_insufficient_evidence | bundle recommendation | No bundle can be recommended yet; the response names the next data action to take. |
no_eligible_item | task delivery | No stored item matches the request; reported as a 200 with served: null (fail-loud, never a silent substitute). |
DATA_INCOMPLETE (PR2-00) | profile resolution | A system-guard profile, not a real educational profile. The student is excluded from clustering and bulk activation. |
Context flags use a closed dictionary: selecting a blocked flag (or any free-text note about a student) is rejected: there is no free-text path on a student record (V-12). See Language Safety.
Retry guidance
- On a rate-limited response, honor
Retry-Afterbefore retrying. - On an expired access token, call
POST /api/auth/refresh; don’t re-login. - Rate-limit windows reset on a successful request.