# Backup destinations API
URL: https://openship.io/docs/api/backup-destinations.md

Manage the storage targets (S3-compatible, SFTP, local, or another Openship server) that backups are written to, and run a connectivity preflight.

import { TypeTable } from 'fumadocs-ui/components/type-table';

A backup destination is a reusable storage target — an S3-compatible bucket, an SFTP server, a local
filesystem path, or another Openship server — that [backup policies](/docs/api/backups) write their archives
to. This API creates and manages those targets and runs a **preflight** that proves a destination is
actually reachable (it writes, reads back, and deletes a probe object). Credentials are encrypted at rest;
serialized destinations never return secret values, only `hasX` flags showing which are configured.

<Callout title="Base path & auth">
All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/backup-destinations`.
Send a personal access token as a bearer header (`Authorization: Bearer <token>`), created with
[`openship token create`](/docs/cli/access). The dashboard uses your session cookie instead. See the
[API overview](/docs/api) and the [auth model](/docs/security/auth) for details.
</Callout>

This module is available on both self-hosted and cloud instances. The `local` destination kind is
self-hosted only — it is rejected in cloud mode and, on self-hosted, must be explicitly enabled (see below).

## Endpoints

| Method & path | Permission | What it does |
|---|---|---|
| `GET /api/backup-destinations` | `backup_destination:list` | List the org's destinations (secrets masked). |
| `POST /api/backup-destinations` | `backup_destination:write` | Create a destination. |
| `GET /api/backup-destinations/:id` | `backup_destination:read` | Get a single destination. |
| `PATCH /api/backup-destinations/:id` | `backup_destination:write` | Update a destination's fields or credentials. |
| `DELETE /api/backup-destinations/:id` | `backup_destination:admin` | Delete a destination. |
| `POST /api/backup-destinations/:id/preflight` | `backup_destination:write` | Verify connectivity (write + read + delete a probe). |

## Create a destination

```
POST /api/backup-destinations
```

<TypeTable
  type={{
    name: { type: 'string', description: 'Display name, unique within the org (max 80 chars).', required: true },
    kind: { type: "'s3_compatible' | 'sftp' | 'openship_server' | 'local'", description: 'Storage backend. Determines which other fields are required.', required: true },
    endpoint: { type: 'string | null', description: 'S3 endpoint URL, or the absolute filesystem path for a local destination.' },
    region: { type: 'string | null', description: 'S3 region.' },
    bucket: { type: 'string | null', description: 'S3 bucket name. Required for s3_compatible.' },
    pathPrefix: { type: 'string | null', description: 'Key/path prefix under which archives are stored.' },
    sshHost: { type: 'string | null', description: 'SFTP host. Required for sftp.' },
    sshPort: { type: 'number | null', description: 'SFTP port.' },
    sshUser: { type: 'string | null', description: 'SFTP username. Required for sftp.' },
    serverId: { type: 'string | null', description: "An existing server's ID to reuse. Required for openship_server; must belong to your org." },
    accessKeyId: { type: 'string | null', description: 'S3 access key ID. Required (with secret) for s3_compatible.' },
    secretAccessKey: { type: 'string | null', description: 'S3 secret access key. Stored encrypted.' },
    sftpPassword: { type: 'string | null', description: 'SFTP password. Provide this or sftpPrivateKey. Stored encrypted.' },
    sftpPrivateKey: { type: 'string | null', description: 'SFTP private key (raw PEM). Provide this or sftpPassword. Stored encrypted.' },
    sftpKeyPassphrase: { type: 'string | null', description: 'Passphrase for the SFTP private key. Stored encrypted.' },
    isDefault: { type: 'boolean', description: "Mark as the org's default destination.", default: 'false' },
  }}
/>

Required fields depend on `kind`:

- **`s3_compatible`** — `bucket`, `accessKeyId`, and `secretAccessKey`.
- **`sftp`** — `sshHost`, `sshUser`, and one of `sftpPassword` / `sftpPrivateKey`.
- **`openship_server`** — `serverId` (a server in your org).
- **`local`** — `endpoint` (an absolute path inside `BACKUP_LOCAL_ROOT`).

```bash
curl -X POST https://your-host/api/backup-destinations \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-s3","kind":"s3_compatible","bucket":"my-backups","region":"us-east-1","accessKeyId":"AKIA...","secretAccessKey":"..."}'
```

```bash
# Same thing from the CLI:
openship backup destination create \
  --name prod-s3 --kind s3_compatible \
  --bucket my-backups --region us-east-1 \
  --access-key-id AKIA... --secret-access-key ...
```

## Update a destination

Send only the fields you want to change. For credentials, `undefined` (omitted) leaves the stored value
unchanged, `null` clears it, and a string replaces it. The `kind` cannot be changed after creation.

```
PATCH /api/backup-destinations/:id
```

<TypeTable
  type={{
    name: { type: 'string', description: 'New display name (max 80 chars).' },
    endpoint: { type: 'string | null', description: 'S3 endpoint URL, or absolute path for local kind (re-validated against BACKUP_LOCAL_ROOT).' },
    region: { type: 'string | null', description: 'S3 region.' },
    bucket: { type: 'string | null', description: 'S3 bucket name.' },
    pathPrefix: { type: 'string | null', description: 'Key/path prefix.' },
    sshHost: { type: 'string | null', description: 'SFTP host.' },
    sshPort: { type: 'number | null', description: 'SFTP port.' },
    sshUser: { type: 'string | null', description: 'SFTP username.' },
    accessKeyId: { type: 'string | null', description: 'S3 access key ID. undefined = keep, null = clear, string = replace.' },
    secretAccessKey: { type: 'string | null', description: 'S3 secret access key. undefined = keep, null = clear, string = replace.' },
    sftpPassword: { type: 'string | null', description: 'SFTP password. undefined = keep, null = clear, string = replace.' },
    sftpPrivateKey: { type: 'string | null', description: 'SFTP private key. undefined = keep, null = clear, string = replace.' },
    sftpKeyPassphrase: { type: 'string | null', description: 'SFTP key passphrase. undefined = keep, null = clear, string = replace.' },
    isDefault: { type: 'boolean', description: "Mark as the org's default destination." },
  }}
/>

```bash
curl -X PATCH https://your-host/api/backup-destinations/bkd_123 \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pathPrefix":"nightly/","isDefault":true}'
```

## Preflight (verify connectivity)

Proves the destination works by writing, reading back, and deleting a small probe object. On success it
stamps `lastVerifiedAt`; on failure it records the error in `lastVerifyError`.

```
POST /api/backup-destinations/:id/preflight
```

```bash
curl -X POST https://your-host/api/backup-destinations/bkd_123/preflight \
  -H "Authorization: Bearer $OPENSHIP_TOKEN"
```

```bash
# Same thing from the CLI:
openship backup destination preflight bkd_123
```

<Callout title="A failed check is still a 200" type="info">
Preflight returns `200` with `{"ok": false, "reason": "..."}` when the target is unreachable or
misconfigured — the reason (bad credentials, DNS failure, permission denied) is in the body, not the HTTP
status. A `404` here means the `:id` doesn't belong to your organization.
</Callout>

## Errors you might see

<Callout title="400 — validation failed" type="warn">
The body is missing a field required for its `kind` (e.g. an S3 destination without a `bucket` or
credentials), the `name` is blank / over 80 chars or duplicates an existing destination, or you passed an
unsupported `kind`. `http_upload` is defined but not yet supported.
</Callout>

<Callout title="400 — local destinations disabled" type="warn">
A `local` destination requires `BACKUP_ALLOW_LOCAL_DESTINATION=true` and a configured `BACKUP_LOCAL_ROOT`,
and the `endpoint` must resolve to an absolute path inside that root. Local destinations are always rejected
in cloud mode.
</Callout>

<Callout title="404 on a per-destination route" type="error">
The `:id` doesn't belong to your organization (or was deleted). List your destinations with
`GET /api/backup-destinations` to get valid IDs.
</Callout>
