Errors
The error envelope, HTTP status mapping and every error code the SAT API returns.
When a request fails, the API answers with an HTTP status and a JSON envelope with a stable machine-readable code. Every error uses the envelope, including validation errors, unknown paths, rate limiting and unexpected server errors. The only non-envelope failure is a 503 from /health, which returns the health report with status: "unhealthy". Branch on code. The message is for people and may change. This page lists the envelope and every code in the supported API.
The envelope
{
"error": {
"code": "RUN_LEASE_LOST",
"message": "Run is cancelled",
"details": {}
}
}| Field | Type | Meaning |
|---|---|---|
code | string | Stable identifier in upper snake case. See Error codes |
message | string | A sentence for people. Do not parse it |
details | object | Extra context, often empty. For example {"email": "..."} with USER_EXISTS, or {"errors": [...]} with a validation error |
@sat/api-client turns the envelope into an ApiError with status, code and message; if a body has no envelope (for example an error page from a proxy in front of the API), code is HTTP_ERROR and message is the HTTP status text.
Framework errors
Errors raised by the web framework itself, before or around your endpoint, use the same envelope with these codes:
| Status | Code | When |
|---|---|---|
| 422 | VALIDATION_ERROR | The request does not match the schema: a missing field, a wrong type, a value out of range, an invalid UUID in the path |
| 401 | AUTH_REQUIRED | No Authorization header, or a scheme other than Bearer. Message Not authenticated |
| 404 | NOT_FOUND | No route matches the path. Message Not Found |
| 405 | METHOD_NOT_ALLOWED | The path exists, the method does not. Message Method Not Allowed |
| 429 | RATE_LIMITED | A rate limit was hit. Message Too many requests: 5 per 1 minute (the limit that was exceeded) |
| 500 | INTERNAL_ERROR | An unhandled exception. Message An unexpected error occurred. Unless ENVIRONMENT is exactly production, details.error carries the exception text |
A schema validation error looks like this:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "runner_id: Field required",
"details": {
"errors": [
{"loc": ["body", "runner_id"], "message": "Field required", "type": "missing"}
]
}
}
}message describes the first problem as field path: message, with the field path joined by dots and without the leading body, query or path. details.errors lists every problem, each with loc (where the bad value is: body, query or path, then the field), message and type (the validator's error type, such as missing or uuid_parsing).
Rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) are added to a 429 only when the limiter's headers are turned on with RATELIMIT_HEADERS_ENABLED=true. They are off by default, and no SAT deployment turns them on.
HTTP status codes
| Status | Meaning in SAT |
|---|---|
| 200 | OK, with a body |
| 201 | Created: registration, a company, agent, task, comment, goal, project, routine, wake, routine trigger or API key |
| 202 | Accepted: password reset email requested |
| 204 | No body: a delete, a revoked key, or a claim with nothing to run |
| 400 | The request is valid but not allowed in the current state, for example INVALID_STATE, RUN_FINISHED, ALREADY_DECIDED or TASK_CYCLE |
| 401 | No credential (AUTH_REQUIRED), or the credential is invalid, expired or revoked (INVALID_TOKEN) |
| 403 | Authenticated, but not allowed: FORBIDDEN (admin required) or SESSION_REQUIRED |
| 404 | Not found, including any company you are not a member of and anything inside it |
| 405 | Wrong method for the path (METHOD_NOT_ALLOWED) |
| 409 | Conflict: USER_EXISTS, API_KEY_EXISTS, RUN_LEASE_LOST |
| 422 | Validation failed (VALIDATION_ERROR) |
| 429 | Rate limited (RATE_LIMITED) |
| 500 | Unexpected server error (INTERNAL_ERROR) |
| 503 | /health only: the database is unreachable or migrations failed |
404, not 403, for non-members
Every company endpoint first checks that you are a member of the company in the path. If you are not, the answer is 404 NOT_FOUND with the message Company not found, exactly as if the company did not exist. Resources inside a company work the same way: a task, agent, run, approval, goal, project or routine id that belongs to another company answers 404. This keeps company ids and resource ids from being probed.
403 means you are a member but lack a role: only owner and admin can update a company (FORBIDDEN).
Error codes
Company resources
| Code | Status | When |
|---|---|---|
NOT_FOUND | 404 | The company (or you are not a member), or the task, agent, run, approval, goal, project, routine or API key does not exist in it. The message names the entity: Task not found, Assignee not found, Parent goal not found |
FORBIDDEN | 403 | PATCH /companies/{company_id} by a member who is not owner or admin |
INVALID_STATE | 400 | Pausing a terminated or pending_approval agent; resuming an agent that is not paused; terminating an agent that is already terminated; waking a dormant agent (paused, pending_approval, terminated); triggering a routine whose agent is dormant; approving an approval whose agent is terminated (reject it instead); PATCH /runs/{run_id} with status: "queued" |
ORG_CYCLE | 400 | Setting an agent's reports_to_id to itself or to one of its reports |
GOAL_CYCLE | 400 | Nesting a goal under itself or its descendants |
GOOGLE_DISABLED | 404 | POST /api/login/google while GOOGLE_OAUTH_CLIENT_ID is not set |
GOOGLE_SIGN_IN_FAILED | 401 | The Google ID token was rejected: bad signature, wrong audience or issuer, expired, or the email is not verified |
TASK_CYCLE | 400 | PATCH /tasks/{task_ref} with a parent_id that is one of the task's own subtasks, at any depth. Setting parent_id to the task itself is ignored, not an error |
ALREADY_DECIDED | 400 | Approving or rejecting an approval that is not pending |
RUN_FINISHED | 400 | Updating or cancelling a run that is succeeded, failed or cancelled |
RUN_LEASE_LOST | 409 | A heartbeat for a run that is not running, or with another runner_id; a PATCH /runs/{run_id} with a runner_id other than the claiming runner's |
RUN_NOT_RUNNING | 400 | run_id on a task create, task update or comment, when that run is not running |
INVALID_MONTH | 400 | GET /costs with a month such as 2026-13 or 2026-00: the YYYY-MM shape is right but the month is not 01 to 12. A value that is not YYYY-MM at all answers 422 VALIDATION_ERROR |
VALIDATION_ERROR | 422 | GET /dashboard with days other than 7, 14 or 30, besides the schema checks every endpoint makes |
Authentication and the current user
| Code | Status | When |
|---|---|---|
AUTH_REQUIRED | 401 | No Authorization header, or a scheme other than Bearer (message Not authenticated) |
INVALID_TOKEN | 401 | An access token or API key that is invalid, expired or revoked, an API key created before the user's last password reset, or a refresh token used as a bearer credential |
INACTIVE_USER | 400 or 401 | The authenticated user is deactivated (401 on the endpoints that require a session). In practice a deactivated user gets INVALID_TOKEN, because token and key checks already reject inactive users |
USER_EXISTS | 409 | Registering an email that already has an account. details.email holds it |
REGISTRATION_FAILED | 400 | Registration failed for another reason |
INVALID_CREDENTIALS | 401 | Sign-in with an unknown email or a wrong password |
ACCOUNT_LOCKED | 400 | Sign-in while the account is locked after five failed attempts, even with the right password |
ACCOUNT_INACTIVE | 400 | Sign-in to a deactivated account |
INVALID_REFRESH_TOKEN | 401 | Refresh with a token that is expired, revoked (sign-out, password reset), malformed or not a refresh token |
INVALID_RESET_TOKEN | 400 | A password reset link that is invalid, already used or expired |
VALIDATION_ERROR | 422 | PATCH /api/me with a blank name; PATCH /api/me/preferences with invalid preferences. For preferences, details.errors lists the problems in the validator's own shape (type, loc, msg, input), not the loc, message, type shape of schema errors |
INTERNAL_ERROR | 500 | An unexpected failure inside a registration, sign-in, refresh, sign-out or reset handler |
SESSION_REQUIRED | 403 | POST /api/logout called with an API key. Sign out with a session access token |
API keys
| Code | Status | When |
|---|---|---|
SESSION_REQUIRED | 403 | Creating or revoking a key with an API key instead of a session access token. This applies to /api/me/api-keys and to the legacy /api/users/{username}/api-keys routes |
API_KEY_EXISTS | 409 | An active key (not revoked, not expired) already has this name |
API_KEY_LIMIT | 400 | The user already has 20 active keys. Revoked and expired keys do not count |
NOT_FOUND | 404 | Revoking a key that does not exist, is already revoked or expired, or belongs to another user |
Any endpoint
| Code | Status | When |
|---|---|---|
VALIDATION_ERROR | 422 | The request does not match the schema. See Framework errors |
AUTH_REQUIRED | 401 | No bearer credential |
NOT_FOUND | 404 | No route matches the path |
METHOD_NOT_ALLOWED | 405 | The method is not allowed on this path |
RATE_LIMITED | 429 | A rate limit was hit |
INTERNAL_ERROR | 500 | An unhandled exception. Unless ENVIRONMENT is exactly production, details.error carries the exception text |
HTTP_ERROR | any | Another framework error raised with a plain message instead of a code |
Handling errors
- Retry network errors, 429, 500 and 503 with backoff. Do not retry other 4xx answers unchanged.
- On 401, refresh the session once and retry; see Refresh on 401. With an API key, a 401 means the key is revoked, expired or was created before the owner's last password reset.
- On 404 for a company, check that the credential's owner is a member. The API cannot tell you which.
- Runners treat
RUN_LEASE_LOST,RUN_FINISHEDandRUN_NOT_RUNNINGas "stop this run now". See Build your own runner. - The CLI maps errors to exit codes: 3 for 401 and 403, 4 for 404, 5 for conflicts and validation errors such as
ORG_CYCLEorRUN_FINISHED. See CLI.