# Errors

> The TimeTracking API error envelope, every HTTP status and error code, real error messages captured from the API, and how a client should react to each.

URL: https://timetracking.tech/developers/api/errors
Last updated: 2026-10-24

## Envelope

Every failure returns JSON in this shape:

```json
{ "error": { "code": "invalid", "message": "Invalid input", "issues": [ … ] } }
```

- `code` is stable and meant for machines. `message` is meant for people and may change. Never parse `message`.
- `issues` is present only on validation errors. It is an array of [zod](https://zod.dev) issues, each with `path` (field), `code` and `message`.
- Database errors are never shown raw. Unexpected failures return `500 {"code":"internal","message":"Internal error"}`.

## Status codes and codes

| HTTP | `code` | Meaning | Retry? | What to do |
|---|---|---|---|---|
| 401 | `unauthenticated` | Missing, invalid or revoked credential | no | Fix the key or token |
| 403 | `forbidden` | Role or scope not allowed | no | Use a key with the right scope (`write`/`admin`) or an admin user token (see [authentication](https://timetracking.tech/developers/api/authentication#roles-and-what-a-credential-can-do)) |
| 404 | `not_found` | Unknown ID, or the record belongs to another org | no | Check the ID; resync your ID mapping |
| 409 | `conflict` | The current state does not allow this | no (re-read first) | Read the record, then decide |
| 409 | `invalid_transition` · `reason_required` · `forbidden` | Status machine refused the transition | no | See [status lifecycle](https://timetracking.tech/developers/concepts/status-lifecycle) |
| 422 | `invalid` | Validation failed, or the body is not valid JSON | no | Fix the request; see `issues` |
| 423 | `locked` | Day is exported or locked | no | Record a correction in an open period |
| 429 | `rate_limited` | More than 600 requests in the current minute for this key | **yes**, after `Retry-After` | See [rate limits](https://timetracking.tech/developers/api/rate-limits) |
| 500 | `internal` | Unexpected server error | yes, with backoff and an `Idempotency-Key` | Contact support if it persists |

## Real examples (captured)

**401: no header**
```json
{ "error": { "code": "unauthenticated", "message": "Missing bearer token" } }
```

**401: wrong or revoked key**
```json
{ "error": { "code": "unauthenticated", "message": "Invalid or revoked API key" } }
```

**403: write with a read key**
```json
{ "error": { "code": "forbidden", "message": "API key is read-only" } }
```

**403: admin-only endpoint with a write key** (`/webhooks`, `/periods/lock`, `/kiosk-devices`; use an admin key or admin user)
```json
{ "error": { "code": "forbidden", "message": "Admin role required" } }
```

**403: creating API keys with any API key** (even an admin key)
```json
{ "error": { "code": "forbidden", "message": "Not available with an API key" } }
```

**404**
```json
{ "error": { "code": "not_found", "message": "Placement not found" } }
{ "error": { "code": "not_found", "message": "Export mapping not found" } }
{ "error": { "code": "not_found", "message": "Not found" } }
```

**409**
```json
{ "error": { "code": "conflict", "message": "Conflict already resolved" } }
{ "error": { "code": "conflict", "message": "1 day(s) in this period are not exported yet" } }
{ "error": { "code": "conflict", "message": "Client has placements; deactivate those instead" } }
{ "error": { "code": "conflict", "message": "These hours are already submitted; ask the office to change them" } }
{ "error": { "code": "conflict", "message": "Already exists" } }
```

**422: validation** (`POST /claims` with a bad UUID, a bad date and no hours)
```json
{
  "error": {
    "code": "invalid",
    "message": "Invalid input",
    "issues": [
      { "origin": "string", "code": "invalid_format", "format": "uuid", "path": ["placementId"], "message": "Invalid UUID" },
      { "origin": "string", "code": "invalid_format", "format": "regex", "pattern": "/^\\d{4}-\\d{2}-\\d{2}$/", "path": ["workDate"], "message": "Invalid string: must match pattern /^\\d{4}-\\d{2}-\\d{2}$/" },
      { "code": "custom", "path": [], "message": "Provide either minutes or start and end time" }
    ]
  }
}
```

**422: other cases**
```json
{ "error": { "code": "invalid", "message": "Body is not valid JSON" } }
{ "error": { "code": "invalid", "message": "Nothing approved to export in this period" } }
{ "error": { "code": "invalid", "message": "Invalid cursor" } }
{ "error": { "code": "invalid", "message": "Idempotency-Key was already used for a different request" } }
{ "error": { "code": "invalid", "message": "Person and client must exist (by id or external ref) before the placement" } }
{ "error": { "code": "invalid", "message": "PIN must be 6 digits" } }
```

**423**
```json
{ "error": { "code": "locked", "message": "This day is exported or locked; book a correction in an open period" } }
```

## Errors inside batch responses

Batch endpoints (`POST /people/batch`, `/clients/batch`, `/placements/batch`, `/claims/batch`) return **200** even when some rows fail. Each row reports its own error:

```json
{ "data": { "ok": 1, "failed": 1, "results": [
  { "index": 0, "ok": true, "data": { "id": "02595da9-06ea-45d1-a5e8-927d854a2c46", "…": "…" } },
  { "index": 1, "ok": false, "error": { "code": "invalid", "message": "firstName: Too small: expected string to have >=1 characters; lastName: Invalid input: expected string, received undefined" } }
] } }
```

The batch itself fails with 422 only when `items` is missing, empty or has more than 500 entries.

## Client handling pattern

```ts
switch (res.status) {
  case 429: await sleep(Number(res.headers.get("retry-after") ?? 60) * 1000); return retry();
  case 500: case 502: case 503: case 504: return retryWithBackoff(); // keep the same Idempotency-Key
  case 401: case 403: throw new ConfigError(json.error.message);   // alert a human
  case 409: case 423: return handleState(json.error);              // re-read, or correct in an open period
  case 422: throw new ValidationError(json.error.issues ?? json.error.message);
}
```

In the CLI, an API error makes `tt` exit with code 1 and prints `error: <message> [<code> <status>]`. The MCP server returns `{ "error": { "code", "message", "status" } }`.
