# Backup destinations
URL: https://synquic.tech/docs/api/backup-destinations.md

Configure and verify storage for backups.

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 Synq server — that [backup policies](/docs/api/backups) write their archives
to. FTP/FTPS servers, Dropbox and Google Drive are destinations too; the last two connect through OAuth with
your own app (see [Connect Dropbox or Google Drive](#connect-dropbox-or-google-drive)). 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.

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).

## Create a destination

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.backupDestinations.create({
  name: "prod-s3",
  kind: "s3_compatible",
  bucket: "my-backups",
  region: "us-east-1",
  accessKeyId: "AKIA...",
  secretAccessKey: "...",
});
console.log(result);
```

</Tab>
<Tab value="REST API">

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

</Tab>
</Tabs>

<TypeTable
  type={{
    name: { type: 'string', description: 'Display name, unique within the org (max 80 chars).', required: true },
    kind: { type: "'s3_compatible' | 'sftp' | 'synq_server' | 'local' | 'ftp' | 'dropbox' | 'google_drive'", 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.' },
    storageClass: { type: "'STANDARD' | 'STANDARD_IA' | 'ONEZONE_IA' | 'INTELLIGENT_TIERING' | 'GLACIER_IR' | null", description: 'S3 only: StorageClass sent on every upload. Omit or null for the provider default. Some S3-compatible providers ignore or reject non-standard classes.', default: 'null' },
    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 synq_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.' },
    ftpHost: { type: 'string | null', description: 'FTP host. Required for ftp.' },
    ftpPort: { type: 'number | null', description: 'FTP port.', default: '21' },
    ftpUser: { type: 'string | null', description: 'FTP username. Required for ftp.' },
    ftpPassword: { type: 'string | null', description: 'FTP password. Required for ftp. Stored encrypted.' },
    ftpTls: { type: "'explicit' | 'none' | null", description: "'explicit' is FTPS (AUTH TLS on the control port, protected data channels). null or 'none' is plain FTP." },
    ftpAllowSelfSigned: { type: 'boolean', description: "FTPS only: accept a self-signed server certificate. Otherwise the certificate must be valid for ftpHost. Ignored (stored false) without TLS.", default: 'false' },
    oauthClientId: { type: 'string | null', description: 'Dropbox app key or Google OAuth client ID. Required for dropbox and google_drive.' },
    oauthClientSecret: { type: 'string | null', description: 'Dropbox app secret or Google OAuth client secret. Required for dropbox and google_drive. 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`.
- **`synq_server`** — `serverId` (a server in your org).
- **`local`** — `endpoint` (an absolute path inside `BACKUP_LOCAL_ROOT`).
- **`ftp`**: `ftpHost`, `ftpUser` and `ftpPassword`. `pathPrefix` is the folder (absolute, or relative to the
  login folder).
- **`dropbox`** / **`google_drive`**: `oauthClientId` and `oauthClientSecret`. `pathPrefix` is the Dropbox
  folder (for example `/Synq Backups`) or the Google Drive folder name (default `Synq Backups`). The
  destination can't store backups until it is connected.

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

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.backupDestinations.update("bkd_123", {
  pathPrefix: "nightly/",
  isDefault: true,
});
console.log(result);
```

</Tab>
<Tab value="REST API">

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

</Tab>
</Tabs>

<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.' },
    storageClass: { type: "'STANDARD' | 'STANDARD_IA' | 'ONEZONE_IA' | 'INTELLIGENT_TIERING' | 'GLACIER_IR' | null", description: 'S3 only. undefined = keep, null = provider default, string = replace.' },
    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.' },
    ftpHost: { type: 'string | null', description: 'FTP host.' },
    ftpPort: { type: 'number | null', description: 'FTP port.' },
    ftpUser: { type: 'string | null', description: 'FTP username.' },
    ftpPassword: { type: 'string | null', description: 'FTP password. undefined = keep, null = clear, string = replace.' },
    ftpTls: { type: "'explicit' | 'none' | null", description: 'FTP encryption. Turning TLS off also turns off ftpAllowSelfSigned.' },
    ftpAllowSelfSigned: { type: 'boolean', description: 'FTPS only: accept a self-signed server certificate.' },
    oauthClientId: { type: 'string | null', description: 'Dropbox app key or Google OAuth client ID. A different value disconnects the destination (its connection belonged to the old app).' },
    oauthClientSecret: { type: 'string | null', description: 'Dropbox app secret or Google OAuth client secret. undefined = keep, string = replace; the connection is kept.' },
    isDefault: { type: 'boolean', description: "Mark as the org's default destination." },
  }}
/>

## Connect Dropbox or Google Drive

Dropbox and Google Drive destinations use an OAuth app you own: a Dropbox app (scoped access, with
`files.metadata.read`, `files.content.read` and `files.content.write`) or a Google Cloud OAuth client of type
**Web application** with the `https://www.googleapis.com/auth/drive.file` scope. After creating the
destination with the app's credentials, connect it in two calls:

1. **Start**: send the redirect URI, which is the panel's own callback page (`<panel origin>/backups/oauth/callback`,
   HTTPS or localhost, registered in the app). The response has the provider URL to open in a browser.
2. **Complete**: the provider sends the browser back to that page with `code` and `state`. Post both. Synq
   exchanges the code for a refresh token (stored encrypted), records the approving account, creates or
   reuses the Google Drive folder, runs a preflight, and returns the destination.

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const { authorizeUrl } = await ship.backupDestinations.oauthStart("bkd_123", {
  redirectUri: "https://panel.example.com/backups/oauth/callback",
});
console.log(authorizeUrl); // open it, approve access

// Later, with the query parameters the callback page received:
const destination = await ship.backupDestinations.oauthComplete({ code: "...", state: "..." });
console.log(destination.oauthConnected, destination.oauthAccount);
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$SYNQ_URL/api/backup-destinations/bkd_123/oauth/start" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"redirectUri":"https://panel.example.com/backups/oauth/callback"}'

curl -X POST "$SYNQ_URL/api/backup-destinations/oauth/complete" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code":"...","state":"..."}'
```

</Tab>
</Tabs>

The `state` is sealed (encrypted and authenticated) by Synq and expires after 15 minutes. It only completes
for the user and workspace that started the connection. The destination's read model shows the connection as
`oauthConnected`, `oauthAccount` and `oauthConnectedAt`, and for Google Drive the folder as `remoteFolderId`;
the refresh token and the app secret are never returned. When the provider stops honoring the refresh token
(access removed, or a Google app left in **Testing** for 7 days), runs and preflights fail with a message that
the access "was revoked or has expired"; start and complete the connection again to fix it.

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

<Tabs items={['SDK', 'REST API']} groupId="api-transport" persist>
<Tab value="SDK">

```ts
const result = await ship.backupDestinations.preflight("bkd_123");
console.log(result);
```

</Tab>
<Tab value="REST API">

```bash
curl -X POST "$SYNQ_URL/api/backup-destinations/bkd_123/preflight" \
  -H "Authorization: Bearer $SYNQ_TOKEN"
```

</Tab>
</Tabs>

<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

<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. On the OAuth routes: a redirect URI that is
not HTTPS (or localhost) or does not end in `/backups/oauth/callback`, or a `state` that is invalid, expired,
or was started by another user.
</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. Both the root and the endpoint are
symlink-resolved before the check, so a symlink that escapes the root is rejected. `BACKUP_LOCAL_ROOT`
itself must be a dedicated directory — not `/`, `/var`, `/var/lib`, Synq's own state directory, or
anything inside a system tree such as `/etc` or `/var/lib/docker`. 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>

## Operations

{/* api-operations:start */}

| Operation | SDK | REST API |
| --- | --- | --- |
| List backup storage destinations. | `backupDestinations.list()` | `GET /api/backup-destinations`<br />`backup_destination:list` |
| Create a backup destination. | `backupDestinations.create(input)` | `POST /api/backup-destinations`<br />`backup_destination:write` |
| Test destination configuration before saving it. | `backupDestinations.preflightDraft(input)` | `POST /api/backup-destinations/preflight`<br />`backup_destination:write` |
| List every volume backup schedule with its destination and last run. | `backupDestinations.volumeBackups()` | `GET /api/backup-destinations/volume-backups`<br />`backup_destination:list` |
| Read a destination with credentials protected. | `backupDestinations.get(id)` | `GET /api/backup-destinations/:id`<br />`backup_destination:read` |
| Read destination storage usage. | `backupDestinations.usage(id)` | `GET /api/backup-destinations/:id/usage`<br />`backup_destination:read` |
| Update destination settings. | `backupDestinations.update(id, input)` | `PATCH /api/backup-destinations/:id`<br />`backup_destination:write` |
| Remove an eligible destination. | `backupDestinations.remove(id)` | `DELETE /api/backup-destinations/:id`<br />`backup_destination:admin` |
| Test a saved destination and record its verification state. | `backupDestinations.preflight(id)` | `POST /api/backup-destinations/:id/preflight`<br />`backup_destination:write` |
| Start connecting a Dropbox or Google Drive destination; returns the provider URL to open. | `backupDestinations.oauthStart(id, input)` | `POST /api/backup-destinations/:id/oauth/start`<br />`backup_destination:write` |
| Finish connecting with the code and state the provider sent back. | `backupDestinations.oauthComplete(input)` | `POST /api/backup-destinations/oauth/complete`<br />`backup_destination:write` |

{/* api-operations:end */}
