---
title: "Errors"
url: https://docs.growamal.com/en/errors/
language: en
---

# Errors

## The error envelope

Every error, at every status, uses one shape: the `ApiError` schema:

```json
{
  "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 |

> **Warning:** **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.

> **Note:** **`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](https://docs.growamal.com/en/core-concepts/).

## Error-code catalog

The key error codes an API consumer must handle are listed below. For the precise codes a specific
endpoint can return, read that operation's `responses` in the [API Reference](https://docs.growamal.com/en/api-reference/).

| `code` | HTTP status | When it fires |
|---|---|---|
| `INVALID_CREDENTIALS` | `401` | Wrong email or password on login |
| `TOKEN_MISSING` | `401` | No `Authorization` header on a protected route |
| `TOKEN_INVALID` | `401` | Malformed or unrecognized access token |
| `TOKEN_EXPIRED` | `401` | Access token has passed its 15-minute lifetime; call `POST /api/auth/refresh` |
| `SESSION_REVOKED` | `401` | Refresh token was rotated-away or explicitly revoked |
| `BRUTE_FORCE_LOCKED` | `401` | Too many failed login attempts; honor the `Retry-After` header before retrying |
| `QUICKCODE_INVALID_OR_EXPIRED` | `401` | Student quickCode or class code is invalid, unknown or expired (one answer for all, so nothing can be probed) |
| `QUICKCODE_RATE_LIMIT` | `401` | Too many quickCode attempts from the same source |
| `CLASS_CODE_EXPIRED` | `410` | `GET /api/auth/student/class-preview` only: the class's login link was renewed or lapsed; ask the teacher for the new link |
| `PERMISSION_DENIED` | `403` | Authenticated but the caller's permission set does not cover the operation |
| `CONTEXT_FLAG_BLOCKED` | `403` | The selected context flag is on the blocked list; no free-text note path exists |
| `NOT_FOUND` | `404` | Resource does not exist in this organization (also used cross-org to avoid disclosing foreign IDs) |
| `EMAIL_EXISTS` | `409` | User creation attempted with an email that already exists |
| `CONFLICT` | `409` | General conflict: class has students on delete, quickCode collision, or other state clash |
| `DO_NOT_DECIDE_YET` | `200` | Decision-engine safe-stop: evidence is split or insufficient; see the safe-stop table below |
| `VALIDATION_ERROR` | `422` | Request body failed schema validation; `details[]` names each failing field |
| `RATE_LIMITED` | `429` | General rate limit exceeded (e.g. `POST /api/logs/client`); honor `Retry-After` |
| `ENGINE_UNAVAILABLE` | `503` | The diagnostic or scoring engine could not be reached |
| `STATE_EXPIRED` | `401` | OAuth state or single-use handoff code has expired or was already consumed |
| `INTERNAL` | `500` | Unexpected server error |

## 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](https://docs.growamal.com/en/how-it-works/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](https://docs.growamal.com/en/guides/language-safety/).

## Retry guidance

- On a rate-limited response, honor `Retry-After` before retrying.
- On an **expired access token**, call `POST /api/auth/refresh`; don't re-login.
- Rate-limit windows reset on a successful request.
