Errors
A stable machine-readable code, nested under `error`.
View as MarkdownEvery failure returns the same JSON shape.
{
"error": {
"code": "quota_exceeded",
"message": "this workspace already has 5 backups",
"request_id": "req_018f3c2a-9e11-7c4d-b0a1-2e6f5d3c9a70"
}
}The body is nested under error. It is not a flat {"code": ..., "message": ...}. A
client reading body.code will read undefined on every failure.
| Field | Always present | Notes |
|---|---|---|
code | Yes | Stable. Branch on this |
message | Yes | Human-readable. May change wording |
request_id | Yes | Quote it when asking for help |
detail | No | Only in non-production environments |
Request IDs
Every response carries X-Request-ID, whether it succeeded or not, and the same value appears
in error bodies.
curl -i https://api.saved.sh/v1/backups/... -H "Authorization: Bearer $SAVED_API_KEY"You may set the header yourself and it will be echoed back, which is how you correlate a request across your own logs and ours. If you do not set one, we generate it.
curl -H "X-Request-ID: my-job-4711" ...Status codes
| Status | Meaning |
|---|---|
400 | Malformed request: bad JSON, wrong content type, an unknown field, an invalid ID |
401 | Missing, invalid or revoked credential |
403 | Authenticated, but not permitted |
404 | No such object, or it belongs to another workspace |
409 | Conflict: a quota, a write-once field, a lock, or a state that forbids the action |
422 | The request is well-formed but the values are not acceptable |
500 | internal. Our fault. Quote the request ID |
502 | upstream_unavailable. A service we depend on failed |
A 404 deliberately covers "exists, but not yours". Distinguishing the two would leak whether
an ID exists in someone else's workspace.
Codes
Authentication and authorization
| Code | Status | Meaning |
|---|---|---|
unauthenticated | 401 | No bearer credential, or it is invalid or expired |
forbidden | 403 | Missing a permission. The message names the slug |
wrong_workspace | 403 | The credential is scoped to another workspace |
{ "error": { "code": "forbidden", "message": "missing permission backups:write", "request_id": "..." } }That message is deliberately specific. The catalog is public at GET /v1/permissions, so
naming the missing slug leaks nothing and makes a least-privilege key debuggable.
Requests
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | Bad JSON, a bad UUID, or a value out of range |
invalid_request | 400 | Content-Type is not application/json |
invalid_request | 400 | An unknown field in the body |
Unknown fields are rejected rather than ignored. That catches typos like expire_afterr
immediately, and it means adding a field to your client before we ship it will fail.
Quotas
| Code | Status | Meaning |
|---|---|---|
quota_exceeded | 409 | A limit was reached. The message names the resource and the count |
Check limits and usage before creating in bulk:
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/quotas" -H "Authorization: Bearer $SAVED_API_KEY"Backups
| Code | Status | Meaning |
|---|---|---|
retention_immutable | 409 | Retention is write-once. Re-sending the identical policy is accepted |
lock_exceeds_expiry | 422 | lock_for is longer than expire_after |
not_found | 404 | No such backup, or it is another workspace's |
Also refused, with a message rather than a dedicated code: a source type illegal for the kind,
a worker on a non-local backup, source or credentials on a local or manual backup, a
schedule on a manual backup, and any attempt to change kind.
Artifacts
| Code | Status | Meaning |
|---|---|---|
artifact_locked | 409 | Inside its lock_for window. Nothing removes it early |
last_artifact | 409 | The only artifact left for that backup |
not_in_our_storage | 409 | Delivered only to your own buckets. Fetch it from there |
conflict | 409 | Already deleted |
Workers and keys
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | The name is empty or over 100 characters |
quota_exceeded | 409 | The worker or key limit for the plan |
not_found | 404 | No such worker or key in this workspace |
Workspaces
| Code | Status | Meaning |
|---|---|---|
forbidden | 403 | select a workspace first: the credential is not scoped to one |
forbidden | 403 | A machine credential tried to create a workspace |
Server-side
| Code | Status | Meaning |
|---|---|---|
internal | 500 | Unhandled. Quote the request ID |
upstream_unavailable | 502 | A dependency of ours failed. Usually transient |
Handling errors well
import requests
response = requests.post(url, json=payload, headers=headers)
if not response.ok:
error = response.json()["error"]
if error["code"] == "quota_exceeded":
...
raise RuntimeError(
f'{error["code"]}: {error["message"]} (request {error["request_id"]})'
)Three rules that will save you time:
- Branch on
code, never onmessage. Wording changes; codes do not. - Log
request_idon every failure. It is the only thing that lets us find your request. - Retry
502and500, with backoff. Do not retry4xx. A409or403will fail identically forever.
There is no rate limiting on the v1 API today, so no 429 is produced. Do not treat its
absence as a licence to hammer the API: if limits are introduced, 429 with Retry-After is
what you should already be prepared to handle.