Error Reference
All Lathe Studio API errors use one envelope, so you can handle them in one place.
Error Envelope#
Every 4xx and 5xx response returns JSON with this shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"field": "name"
}
}
}
| Field | Type | Description |
|---|---|---|
error.code | string | Machine-readable error code. Branch on this, and on details.reason where present. |
error.message | string | Human-readable description. It can change between releases; don't parse it. |
error.details | object | Optional. Extra context, such as the field that failed or a reason. |
An endpoint's code for a given failure never changes. New detail is added in details.reason.
Error Codes#
| Status | Code | When it occurs |
|---|---|---|
| 400 | BAD_REQUEST | A parameter or body value is missing or malformed (bad UUID, unknown field value, …) |
| 400 | VALIDATION_ERROR | The body failed schema validation |
| 400 | INVALID_JSON | The body is not valid JSON |
| 401 | AUTH_REQUIRED | Missing, malformed, revoked or expired API key |
| 403 | FORBIDDEN | The key lacks the required scope, is scoped to a different project, or the organization is locked (preview ended or subscription cancelled) |
| 404 | RESOURCE_NOT_FOUND | The resource does not exist, or belongs to another organization |
| 409 | CONFLICT | The request conflicts with existing state (for example an idempotency key reused with a different body) |
| 413 | PAYLOAD_TOO_LARGE | The request body is over the endpoint's size limit |
| 422 | VALIDATION_ERROR | The request is well-formed but semantically invalid |
| 429 | RATE_LIMITED | Per-minute rate limit or monthly quota exceeded |
| 500 | INTERNAL_ERROR | Unexpected server error |
Error Examples#
401 — Missing or invalid key
{
"error": {
"code": "AUTH_REQUIRED",
"message": "Missing or invalid Authorization header. Use: Bearer <api_key>"
}
}
Check that your header is formatted as Authorization: Bearer pt_live_... (with a space after Bearer).
403 — Missing scope
{
"error": {
"code": "FORBIDDEN",
"message": "API key missing required scope: builds:create"
}
}
Create a new API key with the required scopes. Scopes cannot be added to existing keys.
403 — Organization locked
When the organization's 90-day preview has ended, every request is refused with 403 FORBIDDEN. When a paid subscription was cancelled, the organization is read-only: GET requests keep working and writes get 403 FORBIDDEN. An organization admin can subscribe from Settings → Billing in the app.
404 — Resource not found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Build not found: 6f1c…",
"details": { "resource": "Build", "id": "6f1c…" }
}
}
The resource either doesn't exist or belongs to a different organization. The two cases get the same response.
429 — Rate limited
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded",
"details": { "reset_at": 1790400000 }
}
}
reset_at and the X-RateLimit-Reset header are Unix timestamps in seconds. See Rate Limits for retry guidance.
500 — Internal error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}
Retry with exponential backoff. If the error persists, contact support@lathe.studio with the timestamp and endpoint.
400 — Validation details
Some 400 responses carry a machine-readable details.reason. One example: POST /api/v1/testruns accepts both release_id and build_id, but a run's release is its build's release — when the two disagree the request is refused rather than silently overridden:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "release_id does not match the build's release",
"details": { "reason": "release_build_mismatch" }
}
}
Send only build_id to place the run in the build's release (or no release, for a release-less build).
Assignment endpoints (POST /testruns with assignee_id/assignee_email, POST /testruns/{id}/assign, and splitting with an assignee) branch on details.reason too (touchless T4, 2026-09-24): assignee_not_found (the email matches no one in your organization — the identical response whether it belongs to another org or to no one), assignee_not_assignable (the member cannot be assigned test runs in this project), assignee_ambiguous (the email matches more than one user — retry with assignee_id), assignee_conflict (both assignee_id and assignee_email sent), assignee_and_queue (an assignee combined with queue: true), and invalid_assignee_email (malformed email address).
Idempotency#
For POST requests, include an Idempotency-Key header to safely retry without creating duplicate resources:
Idempotency-Key: my-pipeline-run-12345
If a request with the same key succeeds, subsequent identical requests return the original response (200 with the original resource). If the body differs, a 409 Conflict is returned (details.reason: idempotency_key_reused); a concurrent request with the same key still in flight returns 409 with details.reason: idempotency_request_in_progress.
Idempotency keys expire after 24 hours.
Endpoints that honour Idempotency-Key:
| Endpoint | Since |
|---|---|
POST /api/v1/builds | 2026-09-24 |
Other POST endpoints currently ignore the header (today's behaviour — no error is raised for sending one). This table is updated as endpoints gain support.