Backups
Creating, configuring and controlling backup definitions.
View as MarkdownA backup is created in two steps. POST reserves the name, the kind and a UUID in
draft; PATCH supplies the configuration and the backend activates it once complete.
Create
curl -fsS -X POST "https://api.saved.sh/v1/workspaces/$WID/backups" \
-H "Authorization: Bearer $SAVED_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name": "prod-db", "kind": "local"}'{
"id": "018f3c2a-9e11-7c4d-b0a1-2e6f5d3c9a70",
"name": "prod-db",
"state": "draft",
"kind": "local",
"created_at": "2026-08-08T09:14:02Z"
}| Field | Values |
|---|---|
name | 1 to 100 characters |
kind | local, cloud or manual |
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.
Permission: backups:write. Refused with 409 quota_exceeded at the plan limit.
Configure
PATCH is a merge: omitted fields are left alone. The backend activates the backup itself
as soon as the definition is complete for its kind.
curl -fsS -X PATCH "https://api.saved.sh/v1/backups/$BID" \
-H "Authorization: Bearer $SAVED_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"source_type": "postgres",
"worker_id": "0f2c9a1e-...",
"schedule": "0 2 * * *",
"compression": { "enabled": true, "algo": "gzip", "level": 6 },
"retention": { "keep_last": 10, "expire_after": "90d", "lock_for": "30d" }
}'Body
| Field | Type | Applies to |
|---|---|---|
name | string | All |
source_type | string | local, cloud |
worker_id | UUID | local only, and required before it activates |
schedule | cron string | local, cloud. UTC |
source | object | cloud only. Non-secret facts |
secrets.credentials | object | cloud only. Routed to the vault |
secrets.encryption_public_key | armoured string | All |
compression | {enabled, algo, level} | All |
retention | {keep_last, expire_after, lock_for} | All. Write-once |
delivery | see below | All |
Unknown fields are rejected, not ignored. expire_afterr is a 400, not a silent no-op.
Source and secrets
{
"source_type": "postgres",
"source": { "host": "db.example.com", "port": 5432, "database": "app", "user": "backup" },
"secrets": { "credentials": { "password": "...", "ssl_mode": "verify-full" } }
}The two objects are one payload; splitting them is a convenience, not a boundary. The whole connection goes to the vault, host and port included, because a hostname describes how to reach your database and is worth nothing apart from the password that opens it.
No endpoint returns a source. We cannot read one back, so configuring a source is replace, not patch: send every field each time. A field you leave out is a field you cleared, not one left alone.
A local or manual backup takes neither, and sending them is refused.
Retention is write-once
{ "retention": { "keep_last": 10, "expire_after": "90d", "lock_for": "30d" } }Setting retention a second time with different values returns 409 retention_immutable,
including loosening it. Re-sending the identical policy is accepted, so a client that PATCHes
the whole definition repeatedly is fine.
lock_for may not exceed expire_after, which returns 422 lock_exceeds_expiry.
Durations take a unit suffix: 90d, 720h, 36h.
Delivery
{
"delivery": {
"destinations": ["<destination-id>"],
"skip_permanent": false,
"keep_permanent_on_failure": false,
"second_copy": true
}
}destinations takes IDs, unlike a CLI manifest which takes names. Combination rules are
enforced: skip_permanent needs at least one destination, keep_permanent_on_failure needs
skip_permanent, and second_copy cannot combine with skip_permanent. second_copy is
accepted but not yet acted on: the second space is still being built.
Read
curl -fsS "https://api.saved.sh/v1/backups/$BID" -H "Authorization: Bearer $SAVED_API_KEY"{
"id": "018f3c2a-...",
"name": "prod-db",
"state": "active",
"kind": "local",
"source_type": "postgres",
"worker_id": "0f2c9a1e-...",
"schedule": "0 2 * * *",
"encryption": { "enabled": true, "key_id": "3AA5C34371567BD2" },
"compression": { "enabled": true, "algo": "gzip", "level": 6 },
"retention": { "keep_last": 10, "expire_after": "2160h0m0s", "lock_for": "720h0m0s" },
"delivery": { "destinations": [], "skip_permanent": false, "keep_permanent_on_failure": false, "second_copy": false },
"created_at": "2026-08-08T09:14:02Z",
"updated_at": "2026-08-08T09:15:44Z"
}Retention durations come back in Go's duration format (2160h0m0s), not as the 90d you
sent. They are the same value. Normalise on your side if you round-trip the definition.
No source comes back. The whole connection is in the vault and no endpoint returns it.
List
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/backups?limit=50" \
-H "Authorization: Bearer $SAVED_API_KEY"{ "data": [ { "id": "...", "name": "prod-db", "..." : "..." } ], "next_cursor": null }States
| State | Meaning |
|---|---|
draft | Created, not yet complete. Nothing is scheduled |
active | Complete and scheduled |
paused | Schedule suspended. Definition and artifacts untouched |
deleting | On its way out. No longer configurable |
Control
curl -fsS -X POST "https://api.saved.sh/v1/backups/$BID/trigger" -H "Authorization: Bearer $SAVED_API_KEY"
curl -fsS -X POST "https://api.saved.sh/v1/backups/$BID/pause" -H "Authorization: Bearer $SAVED_API_KEY"
curl -fsS -X POST "https://api.saved.sh/v1/backups/$BID/resume" -H "Authorization: Bearer $SAVED_API_KEY"trigger returns 202 Accepted: the run is started asynchronously, and its outcome is read
from runs. A trigger always runs, even when a scheduled run is in flight.
Pause and resume are refused on a manual backup, which has no schedule.
Delete
curl -fsS -X DELETE "https://api.saved.sh/v1/backups/$BID" -H "Authorization: Bearer $SAVED_API_KEY"Returns 204. Refused while the backup still has artifacts, so a definition cannot be
removed out from under the things it produced. A locked artifact blocks it until the lock
lapses.