---
title: "Backups"
description: "Creating, configuring and controlling backup definitions."
url: "https://saved.sh/docs/api/backups"
---

A 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 [#create]

```bash
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"}'
```

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

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

Permission: `backups:write`. Refused with `409 quota_exceeded` at the plan limit.

## Configure [#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.

```bash
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 [#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                                            |

<Callout type="warn">
  Unknown fields are **rejected**, not ignored. `expire_afterr` is a `400`, not a silent no-op.
</Callout>

### Source and secrets [#source-and-secrets]

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

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

A `local` or `manual` backup takes neither, and sending them is refused.

### Retention is write-once [#retention-is-write-once]

```json
{ "retention": { "keep_last": 10, "expire_after": "90d", "lock_for": "30d" } }
```

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

`lock_for` may not exceed `expire_after`, which returns `422 lock_exceeds_expiry`.

Durations take a unit suffix: `90d`, `720h`, `36h`.

### Delivery [#delivery]

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

```bash
curl -fsS "https://api.saved.sh/v1/backups/$BID" -H "Authorization: Bearer $SAVED_API_KEY"
```

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

<Callout>
  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.
</Callout>

No `source` comes back. The whole connection is in the vault and no endpoint returns it.

## List [#list]

```bash
curl -fsS "https://api.saved.sh/v1/workspaces/$WID/backups?limit=50" \
  -H "Authorization: Bearer $SAVED_API_KEY"
```

```json
{ "data": [ { "id": "...", "name": "prod-db", "..." : "..." } ], "next_cursor": null }
```

## States [#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 [#control]

```bash
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](/docs/api/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 [#delete]

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

## Next [#next]

<Cards>
  <Card href="/docs/api/runs" title="Runs" description="Executions, and the manual upload flow." />

  <Card href="/docs/api/artifacts" title="Artifacts" description="What a run produced." />

  <Card href="/docs/backups" title="Backups in depth" description="What each field means." />
</Cards>
