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.
Last updated: · Markdown version
Envelope
Every failure returns JSON in this shape:
{ "error": { "code": "invalid", "message": "Invalid input", "issues": [ … ] } }
codeis stable and meant for machines.messageis meant for people and may change. Never parsemessage.issuesis present only on validation errors. It is an array of zod issues, each withpath(field),codeandmessage.- 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) |
| 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 |
| 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 |
| 500 | internal |
Unexpected server error | yes, with backoff and an Idempotency-Key |
Contact support if it persists |
Real examples (captured)
401: no header
{ "error": { "code": "unauthenticated", "message": "Missing bearer token" } }
401: wrong or revoked key
{ "error": { "code": "unauthenticated", "message": "Invalid or revoked API key" } }
403: write with a read key
{ "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)
{ "error": { "code": "forbidden", "message": "Admin role required" } }
403: creating API keys with any API key (even an admin key)
{ "error": { "code": "forbidden", "message": "Not available with an API key" } }
404
{ "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
{ "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)
{
"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
{ "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
{ "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:
{ "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
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" } }.