REST API
One uniform surface, bearer-authenticated, versioned under /v1.
Everything the dashboard and the CLI do goes through this API. There is no private back channel and no second surface: they are clients of the same endpoints you get.
Base URL and auth
https://api.saved.sh/v1Every request carries a bearer token. There is exactly one authentication header, whether the caller is a person or a machine:
curl https://api.saved.sh/v1/workspaces \
-H "Authorization: Bearer $SAVED_API_KEY"| Caller | Credential |
|---|---|
| A person, from the dashboard or the CLI | A session token, obtained by signing in |
| A machine: CI, a script, the SDK, a worker | An API key, scoped to one workspace |
Create a key with the least permission that does the job:
saved apikey create ci-backups --permissions backups:trigger,runs:readA key belongs to one workspace and carries a fixed set of permissions. A call outside
them is refused with 403 naming the permission that was missing, not a bare denial.
Conventions
- IDs are UUIDs. Ours, not a vendor's.
- Timestamps are RFC 3339, UTC.
- Every mutation is audited, with the actor recorded.
- Every creating endpoint checks a quota first and refuses with
409 quota_exceededwhen one is reached.
Health
| Method | Path | Returns |
|---|---|---|
GET | /healthz | Liveness |
GET | /readyz | Readiness |
These are the only unauthenticated endpoints, along with GET /v1/auth/cli/config, which
bootstraps the CLI's device-code sign-in.
Workspaces, members and roles
| Method | Path |
|---|---|
POST GET | /v1/workspaces |
GET PATCH DELETE | /v1/workspaces/{wid} |
GET | /v1/workspaces/{wid}/members |
PATCH DELETE | /v1/workspaces/{wid}/members/{id} |
POST GET | /v1/workspaces/{wid}/invitations |
GET POST PATCH DELETE | /v1/workspaces/{wid}/roles[/{slug}] |
GET | /v1/permissions |
GET | /v1/workspaces/{wid}/quotas |
GET /v1/workspaces/{wid}/quotas returns the effective limits and current usage, as a
complete set with defaults filled in. A limit of 0 means unlimited.
Workers and API keys
| Method | Path |
|---|---|
POST GET | /v1/workers |
GET DELETE | /v1/workers/{id} |
POST | /v1/workers/{id}/rotate |
POST GET | /v1/api-keys |
GET DELETE | /v1/api-keys/{id} |
Backups
A backup is created in two steps. POST reserves the name, the kind and a UUID in
draft; PATCH supplies the source and configuration and moves it to active.
| Method | Path | Permission |
|---|---|---|
POST | /v1/workspaces/{wid}/backups | backups:write |
GET | /v1/workspaces/{wid}/backups | backups:read |
GET | /v1/backups/{bid} | backups:read |
PATCH | /v1/backups/{bid} | backups:write |
POST | /v1/backups/{bid}/pause, /resume | backups:write |
POST | /v1/backups/{bid}/trigger | backups:write |
DELETE | /v1/backups/{bid} | backups:write |
// POST /v1/workspaces/{wid}/backups → 201
{ "name": "prod-db", "kind": "local" }
// response
{ "id": "8f14e45f-...", "name": "prod-db", "state": "draft", "kind": "local" }Kind is immutable. It decides who holds your source credentials, so changing it would
silently move a secret across a trust boundary. PATCH has no kind field. The name
is not write-once and may be changed.
Runs and artifacts
| Method | Path | Permission |
|---|---|---|
POST | /v1/runs | artifacts:upload |
POST | /v1/runs/{run_id}/upload-url | artifacts:upload |
POST | /v1/runs/{run_id}/confirm | artifacts:confirm |
GET | /v1/runs/{run_id} | backups:read |
GET | /v1/workspaces/{wid}/artifacts | artifacts:read |
POST | /v1/artifacts/{aid}/download-url | artifacts:download |
DELETE | /v1/artifacts/{aid} | artifacts:delete |
Uploads and downloads never pass through this API. You ask for a presigned URL and move the bytes directly to and from object storage, so the payload is not relayed by us and large artifacts are not bounded by an HTTP request.
Only manual backups call POST /v1/runs. A local or cloud worker already holds a run
id from the scheduler and starts at upload-url.
Errors
Failures return a JSON body with a stable machine-readable code. Read the code, not the
prose.
{ "code": "quota_exceeded", "message": "backups_per_workspace limit reached (5)" }| Status | Meaning |
|---|---|
400 | Malformed request |
401 | Missing or invalid credential |
403 | Authenticated, but lacking the required permission |
404 | No such object, or it belongs to another workspace |
409 | Conflict: a duplicate name, or quota_exceeded |
429 | Rate limited |
A 404 deliberately covers "exists, but not yours". Distinguishing the two would leak
whether an id exists in someone else's workspace.