---
title: "Errors"
description: "A stable machine-readable code, nested under `error`."
url: "https://saved.sh/docs/api/errors"
---

Every failure returns the same JSON shape.

```json
{
  "error": {
    "code": "quota_exceeded",
    "message": "this workspace already has 5 backups",
    "request_id": "req_018f3c2a-9e11-7c4d-b0a1-2e6f5d3c9a70"
  }
}
```

<Callout type="warn">
  **The body is nested under `error`.** It is not a flat `{"code": ..., "message": ...}`. A
  client reading `body.code` will read `undefined` on every failure.
</Callout>

| 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 [#request-ids]

Every response carries `X-Request-ID`, whether it succeeded or not, and the same value appears
in error bodies.

```bash
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.

```bash
curl -H "X-Request-ID: my-job-4711" ...
```

## Status codes [#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                             |

<Callout>
  A `404` deliberately covers "exists, but not yours". Distinguishing the two would leak whether
  an ID exists in someone else's workspace.
</Callout>

## Codes [#codes]

### Authentication and authorization [#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     |

```json
{ "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 [#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              |

<Callout type="warn">
  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.
</Callout>

### Quotas [#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:

```bash
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/quotas" -H "Authorization: Bearer $SAVED_API_KEY"
```

### Backups [#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 [#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 [#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 [#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 [#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 [#handling-errors-well]

```python
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:

1. **Branch on `code`, never on `message`.** Wording changes; codes do not.
2. **Log `request_id` on every failure.** It is the only thing that lets us find your request.
3. **Retry `502` and `500`, with backoff. Do not retry `4xx`.** A `409` or `403` will fail
   identically forever.

<Callout type="warn">
  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.
</Callout>

## Next [#next]

<Cards>
  <Card href="/docs/api/quotas" title="Quotas" description="Reading limits before you hit them." />

  <Card href="/docs/api/authentication" title="Authentication" description="Debugging a 401 or 403." />
</Cards>
