-
-
Notifications
You must be signed in to change notification settings - Fork 25
API
BBS provides a REST API for automated provisioning of clients, repositories, and backup plans. This enables infrastructure-as-code workflows with tools like Ansible, Terraform, or CI pipelines.

All API requests require a Bearer token in the Authorization header:
Authorization: Bearer bbs_tok_...
BBS issues tokens in two ways, and they behave differently:
| Admin tokens | Session tokens | |
|---|---|---|
| Created by | Settings → API, or bbs-token create
|
POST /api/v1/auth/login (password or SSO) |
kind |
user |
mobile |
| Who can hold one | Admins only | Any user, including non-admins |
| Sees | Everything | Only the clients that user can access |
| Can read secrets | Optionally (see below) | Never |
Most endpoints accept either. Endpoints that expose server-wide configuration — settings, tokens, updates — require an admin regardless of token kind, and return 403 otherwise.
For a session token belonging to a non-admin, every client-scoped endpoint is filtered to the clients that user has been granted. A user with three clients sees three clients, and requesting an id outside that set returns 404, not a redacted record.
Tokens default to full admin access except for secret material — repository passphrases and S3 credentials. Endpoints that can return secrets accept an opt-in ?include_secrets=1 flag; calling them without the flag returns the same response minus the secret fields. Tokens created without the Display Secrets capability get HTTP 403 when they request ?include_secrets=1 — they can still call the endpoint, they just don't get secrets back.
To allow a token to read secrets, check Display Secrets when creating it (Settings → API → Create Token).
This is intentional defense in depth — most automation (provisioning, monitoring, inventory) doesn't need passphrases, so leaving the flag off limits the blast radius if that token leaks. Only mint a Display Secrets token when you actually need one (escrow exports, disaster recovery tooling), and prefer rotating it after each use.
Via Web UI: Settings > API tab > Create Token
Via CLI (useful for headless bootstrap):
sudo /var/www/bbs/bin/bbs-token create --name "ansible-provisioner"
# Output: bbs_tok_abc123...
sudo /var/www/bbs/bin/bbs-token list
sudo /var/www/bbs/bin/bbs-token revoke "ansible-provisioner"Tokens have full admin access. The token value is shown once at creation and cannot be retrieved later.
All endpoints accept and return JSON. Base URL: https://your-bbs-server
GET /api/v1/clients
Response:
{
"clients": [
{
"id": 1,
"name": "web-server-01",
"hostname": "web01.example.com",
"ip_address": "10.0.1.5",
"status": "online",
"agent_version": "2.18.7",
"borg_version": "1.4.3",
"last_heartbeat": "2026-03-30 12:00:00",
"created_at": "2026-03-01 10:00:00",
"owner": "admin"
}
]
}POST /api/v1/clients
Request:
{
"name": "web-server-01"
}Response (201):
{
"id": 42,
"name": "web-server-01",
"api_key": "a5b8c9d0e1f2...",
"status": "setup",
"install_command": "curl -s https://your-server/get-agent | sudo bash -s -- --server https://your-server --key a5b8c9d0e1f2..."
}The api_key is the agent install key. Use the install_command to install the agent on the target machine.
GET /api/v1/clients/{id}
Returns client info including repositories and plans arrays.
PUT /api/v1/clients/{id}
Request:
{
"name": "new-client-name"
}DELETE /api/v1/clients/{id}
Blocked (409) if any of the client's repositories contains locked recovery points — unlock them first.
Removes the client, deprovisions SSH access, and deletes storage.
GET /api/v1/clients/{id}/install
The command to run on the machine being backed up, assembled server-side so a caller never builds it from parts and gets a flag wrong. The PowerShell form is returned for Windows clients, the curl form otherwise.
Admin only. This is the one endpoint that returns a client's api_key. It is an install-time enrolment credential, already shown on the client page to any admin who opens it, so withholding it here would break an install screen without protecting anything — but it is a deliberate exception, not a loosening: GET /api/v1/clients/{id} still omits it, and no list endpoint returns it.
{
"command": "curl -s https://backups.example.com/get-agent | sudo bash -s -- --server https://backups.example.com --key bbs_agent_ab12…",
"api_key": "bbs_agent_ab12…",
"server_url": "https://backups.example.com",
"os_hint": "linux",
"status": "online"
}os_hint is linux, windows or macos.
GET /api/v1/clients/{id}/stats
The figures the client page shows, computed server-side so two consumers can't disagree about the same client through different windowing.
{
"next_backup": { "at": "2026-08-12 03:00:00", "plan_name": "Nightly" },
"avg_duration_seconds": 452,
"sample_size": 30,
"success_rate": { "succeeded": 23, "total": 30 },
"errors_7d": 5
}Windows used: the average is over the last 30 completed backups (sample_size says how many were found), the success rate over 30 days, errors_7d over 7 days, and next_backup is the soonest next_run among enabled schedules on enabled plans.
GET /api/v1/clients/{id}/repositories
Response:
{
"repositories": [
{
"id": 15,
"name": "daily-backup",
"path": "ssh://bbs-user@host/./daily-backup",
"encryption": "repokey-blake2",
"storage_type": "local",
"size_bytes": 5368709120,
"archive_count": 42,
"created_at": "2026-03-01 10:00:00",
"s3_sync_enabled": true,
"s3_last_sync_at": "2026-05-25 02:14:00"
}
]
}s3_sync_enabled reflects the per-repository S3 off-site sync toggle. s3_last_sync_at is null until the first successful sync.
POST /api/v1/clients/{id}/repositories
Request:
{
"name": "daily-backup",
"encryption": "repokey-blake2",
"passphrase": "optional-custom-passphrase"
}If passphrase is omitted, one is auto-generated and returned in the response.
Optional fields:
-
storage_location_id- Use a specific storage location (default: server default) -
encryption- One of:none,repokey,repokey-blake2,authenticated,authenticated-blake2(default:repokey-blake2)
Response (201):
{
"id": 15,
"name": "daily-backup",
"path": "ssh://bbs-user@host/./daily-backup",
"encryption": "repokey-blake2",
"storage_type": "local",
"passphrase": "A1B2-C3D4-E5F6-G7H8-I9J0"
}POST /api/v1/clients/{id}/repositories
Request:
{
"name": "offsite-backup",
"encryption": "repokey-blake2",
"storage_type": "remote_ssh",
"remote_ssh_config_id": 3
}Use GET /api/v1/storage to find available remote_ssh_config_id values.
PUT /api/v1/clients/{id}/repositories/{repo_id}
Request:
{
"name": "new-repo-name"
}Only local repos can be renamed. Blocked while jobs are active.
DELETE /api/v1/clients/{id}/repositories/{repo_id}
Blocked if backup plans reference the repo, if jobs are active, or if the repository contains locked recovery points (see Recovery Points below — unlock them first).
GET /api/v1/clients/{id}/repositories/{repo_id}/archives
Lists a repository's recovery points (borg archives), newest first, including each one's lock state. Use this to find the id of the archive you want to lock.
Response:
{
"archives": [
{
"id": 128,
"name": "plan5-2026-07-16_02-00-01",
"file_count": 84213,
"original_size": 5368709120,
"deduplicated_size": 214748364,
"locked": false,
"created_at": "2026-07-16 02:00:14"
}
]
}POST /api/v1/clients/{id}/repositories/{repo_id}/archives/{archive_id}/lock
Locks or unlocks a specific recovery point (legal hold, #314). A locked recovery point is never pruned by retention rules and cannot be deleted — nor can its repository or client be deleted while it exists. Protection is enforced inside borg itself, so it holds on local and Remote SSH repositories alike.
Request:
{ "locked": true }true locks, false unlocks. The call is idempotent — locking an already-locked archive returns 200 with result: "already".
Response (applied immediately):
{
"status": "ok",
"result": "locked",
"message": "Archive locked — it will never be pruned or deleted until unlocked.",
"name": "locked.plan5-2026-07-16_02-00-01",
"locked": true
}result is one of locked, unlocked, already, or queued.
Response (repository busy — 202 Accepted): if a backup or other job is running on the repository, the change is queued and applied automatically when that job finishes:
{
"status": "ok",
"result": "queued",
"message": "Lock queued — the repository is busy, so it will apply automatically when the current job finishes.",
"name": "plan5-2026-07-16_02-00-01",
"locked": true
}Locking renames the archive with a locked. prefix so borg's prune filter can never select it — that prefix is why a locked archive's name changes. Unlocking renames it back.
GET /api/v1/repositories
Returns every repository on the server, grouped under their owning client. Useful for inventory, capacity reporting, or escrow tooling.
Query parameters:
-
include_secrets=1— also returns the decryptedpassphrasefield per repo. Intended for operator escrow ("export every repo passphrase to safe storage in case of disaster"). Each call that includes this flag is logged toserver_logwith the token name + source IP for audit. Requires a token created with the Display Secrets capability — other tokens get HTTP 403.
Response:
{
"repositories": [
{
"id": 5,
"agent_id": 2,
"agent_name": "web-server-01",
"name": "daily",
"path": "ssh://bbs-user@host//path/to/repo",
"encryption": "repokey-blake2",
"storage_type": "local",
"size_bytes": 5368709120,
"archive_count": 42,
"created_at": "2026-03-01 10:00:00",
"s3_sync_enabled": true,
"s3_last_sync_at": "2026-05-25 02:14:00",
"passphrase": "ABCD-1234-EFGH-5678-IJKL"
}
],
"include_secrets": true
}passphrase is only present when include_secrets=1 is set. It's null for repos with encryption=none.
PUT /api/v1/repositories/{repo_id}/s3-sync
Enable or disable off-site S3 sync for a single repository. Global S3 credentials must already be configured (Settings > S3 Offsite Sync) for the sync to actually run.
Request:
{ "enabled": true }Response:
{
"id": 15,
"name": "daily-backup",
"s3_sync_enabled": true
}POST /api/v1/clients/{id}/repositories/{repo_id}/maintenance
POST /api/v1/clients/{id}/repositories/{repo_id}/check
Queues one of the maintenance actions the web page offers. Requires the repo_maintenance permission.
Request: {"action": "check"} — one of:
| Action | What it does |
|---|---|
check |
Verify repository integrity |
compact |
Reclaim disk space freed by earlier prunes |
repair |
Attempt repair of a damaged repository |
break_lock |
Clear a stale lock left by an interrupted operation |
catalog_rebuild |
Rebuild file catalog entries that are missing |
catalog_rebuild_full |
Re-read every archive and rebuild from scratch |
POST .../check is a shorthand for {"action": "check"}.
Response (202):
{ "status": "queued", "job_id": 19865, "action": "compact", "task_type": "compact" }One job of each kind at a time — different kinds may queue together. A second request while one is pending returns 409:
{ "status": "already_queued", "job_id": 19865, "action": "compact",
"message": "A Compact job is already queued or running for this repository" }An unknown action returns 400 listing the valid ones.
POST /api/v1/clients/{id}/repositories/{repo_id}/catalog/sync
Requests a catalog rebuild — the action to offer when browsing reports a catalog as pending. Body {"full": true} re-reads every archive; omitted fills in what is missing. Same response shape as maintenance.
DELETE /api/v1/clients/{id}/repositories/{repo_id}/archives/{archive_id}
Queues deletion of one recovery point. Requires the manage_repos permission. Refused with 409 while the archive is locked:
{ "error": "This recovery point is locked — unlock it first", "reason": "locked" }GET /api/v1/clients/{id}/repositories/{repo_id}/archives/{archive_id}/databases
What a restore point contains — the step between choosing one and starting a database restore.
{
"archive_id": 7995,
"archive_name": "plan2-2026-08-11_06-30-29",
"backed_up_at": "2026-08-11 06:39:34",
"has_databases": true,
"databases": ["app_db", "wordpress"],
"groups": [
{ "connector_id": 4, "connector_name": "Primary MySQL", "engine": "mysql_dump",
"databases": ["app_db", "wordpress"], "per_database": true, "compress": true,
"dumped_at": { "app_db": "2026-08-11 02:29:16" } }
]
}Groups mirror how the dumps are stored: several database configurations — including two of the same engine — restore independently. Each group carries its connector_id so it can be pre-selected for the restore rather than matched up by name. An archive with no dumps returns has_databases: false and empty arrays, not an error.
Archive listings also carry has_databases, so only usable restore points need be offered.
GET /api/v1/clients/{id}/plans
POST /api/v1/clients/{id}/plans
Request:
{
"name": "daily-full",
"repository_id": 15,
"directories": "/home\n/etc\n/var/www",
"excludes": "*.tmp\n*.log",
"advanced_options": "--compression lz4 --exclude-caches --noatime",
"frequency": "daily",
"times": "02:00",
"prune_days": 7,
"prune_weeks": 4,
"prune_months": 6,
"plugins": [
{"plugin_config_id": 5}
]
}Schedule fields:
| Field | Description | Default |
|---|---|---|
frequency |
hourly, daily, weekly, monthly, manual
|
daily |
times |
Time(s) to run, comma-separated (e.g. 02:00,14:00) |
02:00 |
day_of_week |
0-6 (Sun-Sat), required for weekly
|
- |
day_of_month |
1-31 or last, required for monthly
|
- |
Prune retention fields:
| Field | Default |
|---|---|
prune_minutes |
0 |
prune_hours |
0 |
prune_days |
7 |
prune_weeks |
4 |
prune_months |
6 |
prune_years |
0 |
Plugin attachment:
Two formats supported:
"plugins": [{"plugin_config_id": 5}, {"plugin_config_id": 8}]or map format (plugin_id: config_id):
"plugins": {"1": 5, "2": 8}PUT /api/v1/clients/{id}/plans/{plan_id}
All fields are optional — only provided fields are updated:
{
"name": "updated-name",
"directories": "/home\n/etc",
"excludes": "*.tmp",
"advanced_options": "--compression zstd --noatime",
"repository_id": 15,
"frequency": "daily",
"times": "03:00,15:00",
"day_of_week": null,
"day_of_month": null,
"timezone": "America/New_York",
"prune_days": 14,
"prune_weeks": 8,
"plugins": [{"plugin_config_id": 5}]
}DELETE /api/v1/clients/{id}/plans/{plan_id}
POST /api/v1/clients/{id}/plans/{plan_id}/pause
Disables the schedule. The plan can still be triggered manually.
POST /api/v1/clients/{id}/plans/{plan_id}/resume
Re-enables the schedule.
POST /api/v1/clients/{id}/plans/{plan_id}/trigger
Queues an immediate backup. Blocked if a backup is already queued/running for this plan.
Response:
{
"status": "ok",
"job_id": 456,
"message": "Backup queued for plan \"daily-full\""
}GET /api/v1/clients/{id}/jobs
Query parameters:
-
limit— max results (default 50, max 200) -
offset— pagination offset -
status— filter by status:queued,sent,running,completed,failed,cancelled. Since v2.78.0 this accepts a comma-separated list (?status=completed,failed), which matters because results are ordered byqueued_at— a client with a backlog would otherwise return queued rows ahead of the finished ones you asked for. An unrecognised value returns400naming the valid ones.
Response:
{
"jobs": [
{
"id": 123,
"task_type": "backup",
"status": "completed",
"plan_name": "daily-full",
"repository_name": "main-repo",
"files_total": 15000,
"files_processed": 15000,
"bytes_total": 5368709120,
"bytes_processed": 5368709120,
"duration_seconds": 342,
"queued_at": "2026-03-30 02:00:00",
"started_at": "2026-03-30 02:00:05",
"completed_at": "2026-03-30 02:05:47"
}
],
"total": 150,
"limit": 50,
"offset": 0
}GET /api/v1/clients/{id}/jobs/{job_id}
Returns full job record including error_log and status_message.
GET /api/v1/jobs/{job_id}
The same job without needing to know which client it belongs to, plus everything the web job page shows. The job's own fields stay at the top level; the extras are sibling keys.
Response:
{
"id": 19644,
"task_type": "prune",
"status": "completed",
"client_name": "web-01",
"client_status": "online",
"client_last_heartbeat": "2026-08-09 21:02:11",
"plan_name": "daily-full",
"repository_name": "main-repo",
"logs": [
{ "id": 1, "level": "info", "message": "Auto-prune queued (job #19605)", "created_at": "2026-08-09 13:55:49" }
],
"queue": { "active": 1, "max": 4, "position": null },
"current_file": null,
"prune_stats": {
"existing": 214, "kept": 180, "deleted": 34,
"keep_rules": { "24h": 24, "7d": 7, "4w": 4, "6m": 6 },
"deleted_names": ["..."]
}
}-
queue.positionis the job's place in line whilequeued,nullotherwise. -
current_fileis only populated while abackupis running. -
prune_statsis only populated forprunejobs. -
client_statusandclient_last_heartbeatare what let a caller explain why a job is stuck — agent offline, agent online but waiting to poll, or queue full.
GET /api/v1/queue
Jobs in flight across every client the caller can see, plus recent history and capacity. Since v2.74.0 the response carries recent, slots and stats alongside the original queue and count.
Query parameters:
-
recent— number of finished jobs to include (default 10, max 50)
Response:
{
"queue": [ "...jobs currently queued, sent or running..." ],
"count": 2,
"recent": [
{
"id": 19605, "task_type": "prune", "status": "completed",
"client_id": 7, "client_name": "web-01",
"plan_name": null, "repository_name": "repo-web-01",
"duration_seconds": 141, "had_warnings": 0,
"queued_at": "...", "started_at": "...", "completed_at": "..."
}
],
"slots": { "active": 1, "max": 4 },
"stats": { "queued": 0, "running": 1, "completed_24h": 118, "failed_24h": 1, "avg_seconds_24h": 297 }
}slots is server-wide capacity (the max_queue setting). stats is scoped to the caller's own clients. had_warnings distinguishes a clean success from "completed with warnings".
POST /api/v1/queue/{id}/cancel
Mirrors the web queue's cancel, including the automatic break-lock cleanup when a running borg job is interrupted. Requires the trigger_backup permission on the job's client.
POST /api/v1/queue/{id}/retry
Re-queues a failed job. Requires the trigger_backup permission on the job's client.
GET /api/v1/plugins
Returns all available plugins (mysql_dump, pg_dump, shell_hook, s3_sync).
GET /api/v1/plugins/schema
Returns field definitions for each plugin type, useful for building config forms programmatically.
GET /api/v1/clients/{id}/plugin-configs
Returns named plugin configurations for a client. Sensitive fields (passwords, keys) are masked.
POST /api/v1/clients/{id}/plugin-configs
Request (MySQL example):
{
"plugin": "mysql_dump",
"name": "Production MySQL",
"config": {
"host": "localhost",
"port": 3306,
"user": "bbs_backup",
"password": "secret123",
"databases": "*",
"dump_dir": "/home/bbs/mysql",
"compress": true,
"cleanup_after": true
}
}Response (201):
{
"id": 12,
"plugin": "mysql_dump",
"name": "Production MySQL"
}The plugin is automatically enabled for the client when a config is created.
PUT /api/v1/clients/{id}/plugin-configs/{configId}
DELETE /api/v1/clients/{id}/plugin-configs/{configId}
POST /api/v1/clients/{id}/plugin-configs/{configId}/test
PUT takes {"name": "…", "config": { … }} and applies only what is present.
Secret fields are write-only. Send a value to set it; they are never returned, appearing instead as {field}_set booleans. An omitted or empty field keeps the stored value rather than clearing it — so saving a form with a blank password box does not wipe the password.
DELETE returns 204, or 409 naming the repositories still using the configuration:
{ "error": "In use by 1 repository/repositories", "reason": "repositories_attached",
"repositories": [ { "id": 12, "name": "main" } ] }test answers immediately for S3 configurations — {"status":"completed"} or 502 with the failure. Everything else has to run on the client, so it queues a job and returns 202 {"status":"queued","job_id":N}; poll it like any other.
GET /api/v1/storage
Returns both local storage locations and remote SSH configurations:
{
"local": [
{"id": 1, "name": "Default", "path": "/var/bbs/home", "is_default": 1}
],
"remote_ssh": [
{"id": 3, "name": "rsync.net", "remote_host": "ch-s011.rsync.net", "remote_user": "12345", ...}
]
}POST /api/v1/storage
Register a new local storage path that BBS can place repositories on. The path must exist as a directory before calling — BBS validates with is_dir() but does NOT create the path for you (mount the volume or mkdir it on the server first). Updates /etc/bbs/allowed-storage-paths so bbs-ssh-helper will accept repo operations there.
Request:
{
"label": "Secondary Disk",
"path": "/mnt/extra",
"is_default": false
}is_default: true demotes any other existing default. is_default defaults to false when omitted.
Response (201):
{
"id": 2,
"label": "Secondary Disk",
"path": "/mnt/extra",
"is_default": false
}Errors:
- 400 —
labelandpathrequired, path must be absolute, path must exist as a directory - 409 — a storage location already exists at that path
PUT /api/v1/storage/{id}
Rename a location or make it the default. The path is not editable — repositories are stored under it, so relocating one is a filesystem operation, not a settings change. Sending a different path is rejected rather than ignored.
Request: (both fields optional, at least one required)
{
"label": "Main Array",
"is_default": true
}is_default: true demotes any other default. Refreshes /etc/bbs/allowed-storage-paths.
Errors:
- 404 — no such location
- 422 —
pathdiffers from the stored one,labelis empty, nothing to update, or an attempt to unset the default (make another location the default instead)
DELETE /api/v1/storage/{id}
Unregisters the location. Borg data on disk is left exactly where it is — the response returns "data_removed": false so a client can say so before asking for confirmation.
Response:
{"status": "ok", "deleted": 2, "data_removed": false}Errors:
- 404 — no such location
- 409 — repositories still live there (the response includes a
repositoriesarray of their names), the location is the default, or it is the only one left
GET /api/v1/remote-ssh-configs
POST /api/v1/remote-ssh-configs
GET /api/v1/remote-ssh-configs/{id}
PUT /api/v1/remote-ssh-configs/{id}
DELETE /api/v1/remote-ssh-configs/{id}
Manage the remote SSH hosts that repositories can be stored on — BorgBase, Hetzner Storage Box, rsync.net, or any plain SSH host with borg installed.
Create request:
{
"name": "Offsite (Hetzner)",
"provider": "hetzner",
"remote_host": "u123456.your-storagebox.de",
"remote_port": 23,
"remote_user": "u123456",
"remote_base_path": "./",
"ssh_private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"borg_remote_path": null,
"append_repo_name": true
}provider is one of borgbase, hetzner, rsync.net, or omitted for a plain SSH host. A *.repo.borgbase.com hostname is always treated as borgbase, since the quota lookup depends on it.
ssh_private_key is write-only. It is stored encrypted and never returned; responses carry ssh_private_key_set (bool) instead. On PUT, an absent or empty value leaves the stored key untouched, so a client can round-trip a config it fetched without re-entering the key.
Response:
{
"id": 3,
"name": "Offsite (Hetzner)",
"provider": "hetzner",
"remote_host": "u123456.your-storagebox.de",
"remote_port": 23,
"remote_user": "u123456",
"remote_base_path": "./",
"borg_remote_path": null,
"append_repo_name": true,
"ssh_private_key_set": true,
"disk_total_bytes": 1099511627776,
"disk_used_bytes": 33181138944,
"disk_free_bytes": 1066330488832,
"disk_checked_at": "2026-08-18 10:00:03",
"disk_check_error": null,
"repository_count": 2,
"created_at": "2026-08-18 09:14:00"
}Disk figures come from the scheduler's 15-minute poll. BorgBase reports through its API; other hosts use df, and a host that refuses df (a borg-only shell) leaves the figures null with the reason in disk_check_error.
Errors:
- 404 — no such config
- 409 — repositories still use this host; the response includes a
repositoriesarray of their names - 422 — missing
name/remote_host/remote_user/ssh_private_key, an unrecognisedprovider, or nothing to update
POST /api/v1/remote-ssh-configs/test # an unsaved config
POST /api/v1/remote-ssh-configs/{id}/test # a saved one
Opens an SSH connection and asks the far end for its borg version. The unsaved form takes the same body as create (remote_host, remote_user, ssh_private_key, optional remote_port and borg_remote_path) so a key can be checked before it is stored, rather than discovering it was wrong when a backup fails hours later.
Response:
{"status": "ok", "version": "borg 1.4.0", "quota_changed": true}{"status": "error", "error": "Permission denied (publickey)."}Testing a saved config re-reads its quota on success, so a host that has just been fixed stops showing stale figures instead of waiting for the next poll. quota_changed is absent on the unsaved form.
GET /api/v1/storage/capacity
Provisioned, used, and free bytes for the default storage location (the row with is_default = 1). Reads df server-side; useful for billing, monitoring dashboards, or capacity alerts.
Response:
{
"provisioned_bytes": 10995116277760,
"used_bytes": 4617089564672,
"free_bytes": 6378026713088
}Errors:
- 404 — no default storage location is configured
POST /api/v1/s3-credentials
Write the global S3 sync configuration. All five credential fields are required; path_prefix is optional.
Request:
{
"endpoint": "https://s3.us-west-1.amazonaws.com",
"region": "us-west-1",
"bucket": "bbs-offsite",
"access_key": "AKIA…",
"secret_key": "…",
"path_prefix": ""
}Response:
{ "status": "ok", "fields": ["s3_endpoint", "s3_region", "s3_bucket", "s3_access_key", "s3_secret_key", "s3_path_prefix"] }DELETE /api/v1/s3-credentials
Removes the global S3 configuration and sets enabled = 0 on every per-repository S3 sync row.
Response:
{ "status": "ok", "disabled_repositories": 12 }GET /api/v1/s3-credentials
Returns the current global S3 sync configuration that's shared across all repositories whose plugin config is set to "Use Global S3 Settings".
Query parameters:
-
include_secrets=1— also returnsaccess_keyandsecret_key. Requires a token created with the Display Secrets capability; other tokens get HTTP 403. Each call with this flag is logged toserver_logfor audit.
Response (without include_secrets):
{
"endpoint": "https://s3.us-west-1.amazonaws.com",
"region": "us-west-1",
"bucket": "bbs-offsite",
"path_prefix": "",
"configured": true
}Response (with ?include_secrets=1):
{
"endpoint": "https://s3.us-west-1.amazonaws.com",
"region": "us-west-1",
"bucket": "bbs-offsite",
"path_prefix": "",
"access_key": "AKIA…",
"secret_key": "…",
"configured": true
}Fields return null when unset. configured is a convenience boolean — true only when endpoint, bucket, and access_key are all populated; computed server-side so it works without include_secrets.
GET /api/v1/maintenance
Response:
{ "enabled": true }POST /api/v1/maintenance
When enabled, the scheduler stops creating new agent jobs and the queue skips agent dispatch. Server-side promotions (catalog, prune, etc.) still run.
Request:
{ "enabled": true }Response:
{ "enabled": true }GET /api/v1/users
Response:
{
"users": [
{
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "admin",
"all_clients": true,
"auth_provider": "local",
"oidc_status": "active",
"totp_enabled": true,
"timezone": "America/New_York",
"time_format": "24h"
}
]
}Password hashes and TOTP secrets are never returned.
GET /api/v1/users/{id}
Returns the same shape as a single entry in the list.
POST /api/v1/users
Request:
{
"username": "alice",
"email": "alice@example.com",
"password": "at-least-eight-chars",
"role": "user"
}role is "user" or "admin" and defaults to "user". Returns the new user (201) on success.
PUT /api/v1/users/{id}
All fields optional — only the provided keys are updated:
{
"email": "alice@new-domain.com",
"password": "rotated-password",
"role": "admin",
"all_clients": true,
"timezone": "UTC",
"time_format": "24h",
"reset_totp": true
}reset_totp: true clears the user's stored 2FA secret (account recovery). Re-enrolling 2FA still has to happen via the UI's QR-code flow.
Username changes are not supported via this endpoint — do it in the UI if needed.
DELETE /api/v1/users/{id}
Refuses (409) to delete the last admin or the user the calling API token belongs to.
GET /api/v1/log
Query parameters:
-
level—info,warning, orerror -
agent_id— filter to one client -
since— ISO-style timestamp, e.g.2026-05-27 00:00:00 -
limit— max 500, default 100 -
offset— pagination
Response:
{
"log": [
{
"id": 4815,
"agent_id": 7,
"agent_name": "web-01",
"backup_job_id": 1623,
"level": "info",
"message": "Backup completed for plan \"daily\"",
"created_at": "2026-05-27 02:00:13"
}
],
"total": 12042,
"limit": 100,
"offset": 0
}GET /api/v1/schedules
Flat aggregated view of every backup plan + its schedule + most recent status across all clients. Useful for monitoring tools or oncall dashboards that need a single endpoint instead of fanning out per-client.
Response:
{
"schedules": [
{
"plan_id": 5,
"plan_name": "daily",
"plan_enabled": true,
"agent_id": 7,
"agent_name": "web-01",
"agent_status": "online",
"repository_id": 12,
"repository_name": "main",
"frequency": "daily",
"times": "02:00",
"day_of_week": null,
"day_of_month": null,
"timezone": "America/New_York",
"schedule_enabled": true,
"next_run": "2026-05-28 02:00:00",
"last_run": "2026-05-27 02:00:00",
"last_status": "completed",
"last_completed_at": "2026-05-27 02:04:32"
}
]
}schedule_enabled is null if the plan has no schedule row (a "manual" plan that's never triggered automatically).
GET /api/v1/schedules/day
Concrete backup occurrences for one day across the whole server — what already ran and what is still coming — rather than the schedule rules GET /api/v1/schedules returns. Built for a calendar-style view.
Query parameters:
-
date—YYYY-MM-DD, interpreted in the caller's timezone (default: today) -
client_id— restrict occurrences to one client
Response:
{
"date": "2026-08-09",
"timezone": "America/New_York",
"time_format": "24h",
"is_today": true,
"now_minute": 1063,
"days": [
{ "date": "2026-08-03", "weekday": "Mon", "count": 47, "is_today": false }
],
"clients": [ { "id": 3, "name": "web-01" } ],
"occurrences": [
{
"key": "101@2026-08-09T02:00",
"plan_id": 101,
"plan_name": "System nightly",
"client_id": 3,
"client_name": "web-01",
"repository_name": "daily",
"frequency": "daily",
"minute_of_day": 120,
"time_label": "02:00",
"scheduled_at": "2026-08-09 06:00:00",
"estimated_duration_seconds": 900,
"duration_estimated": true,
"state": "completed",
"had_warnings": 0,
"job_id": 9101,
"started_at": "2026-08-09 06:00:11",
"completed_at": "2026-08-09 06:19:41",
"duration_seconds": 1170
}
],
"interval_schedules": [
{ "plan_id": 140, "plan_name": "Docs", "client_id": 3, "client_name": "web-01", "frequency": "hourly" }
]
}Everything positional is pre-resolved into the caller's timezone, so a client never has to do timezone arithmetic:
-
minute_of_day— 0–1439 intimezone. Group byminute_of_day / 60to build an hour scale; don't derive the hour from a timestamp. -
time_label— already formatted for the caller's 12h/24h preference. -
now_minute— minutes since midnight, ornullwhendateisn't today. Draws the "now" line. -
scheduled_at— naive UTC, like every other datetime in this API. Use it for ordering, never for the hour.
An occurrence is placed on the day the viewer sees it. A schedule declared in another timezone can therefore land on the previous or next day: a 03:15 UTC Wednesday schedule appears on Tuesday at 23:15 for a viewer in America/New_York, with scheduled_at still the true UTC instant.
state is the single field a row renders from:
state |
Meaning |
|---|---|
queued, running
|
A job for this occurrence is in the queue now |
completed, failed, cancelled
|
It ran; job_id links to the job detail |
upcoming |
Scheduled later than now, or on a future day |
missed |
Its time has passed and no job was ever created |
days covers the Monday–Sunday week containing date with per-day counts, and clients lists every client that has a schedule. Neither is narrowed by client_id, so a filter UI doesn't shift underneath the user. interval_schedules holds 10min/15min/30min/hourly plans, which are too dense to place on a day list. Monthly plans appear only on the day they fall.
Note: changing a user's profile timezone only changes how times are displayed. It never moves a schedule — a 02:00 New York backup keeps running at 02:00 New York and simply shows as 15:00 to a viewer in Tokyo.
GET /api/v1/metrics
A single machine-readable snapshot for health checks, dashboards and time-series tools. Timestamps are returned both as datetime strings and unix epochs so a collector can consume them without parsing.
{
"generated_at": "2026-08-09T12:00:00-04:00",
"clients": { "total": 12, "online": 11, "offline": 1, "error": 0, "setup": 0 },
"queue": { "queued": 0, "running": 1 },
"backup_jobs_total": [ { "status": "completed", "count": 4821 } ],
"plans": [
{ "id": 5, "client_id": 3, "name": "daily",
"last_success_at": "2026-08-09 06:19:41", "last_success_ts": 1786371581,
"last_success_duration_seconds": 1170, "last_success_bytes": 5368709120 }
],
"repositories": [
{ "id": 12, "client_id": 3, "name": "main", "storage_type": "local", "size_bytes": 128849018880 }
]
}last_success_ts is the field to alert on — "no successful backup in N hours" is the check most people actually want.
GET /health (no credentials)
GET /api/v1/health (token)
For monitoring. The two differ on purpose: /health is a liveness probe with no detail, safe to expose and cheap enough to poll every few seconds; /api/v1/health is the full picture and needs a token.
GET /health → {"status":"ok"} 200, or 503 when the app cannot serve
GET /api/v1/health
{
"status": "warning",
"version": "2.78.0",
"checked_at": "2026-08-11T06:29:23-04:00",
"problems": ["clients: 1 of 24 client(s) offline"],
"checks": {
"database": { "status": "ok", "message": "Reachable" },
"scheduler": { "status": "ok", "message": "Running", "last_run": "…", "seconds_ago": 14 },
"storage": { "status": "ok", "message": "2 location(s) with space available",
"locations": [ { "name": "Default", "status": "ok", "used_percent": 62.4,
"total_bytes": 0, "free_bytes": 0 } ] },
"catalog": { "status": "ok", "message": "Reachable" },
"clients": { "status": "warning", "message": "1 of 24 client(s) offline",
"counts": { "online": 23, "offline": 1, "error": 0, "setup": 0 } },
"backups": { "status": "warning",
"message": "1 client(s) overdue: laptop-04 (59h, allows 48h)",
"overdue_clients": 1,
"overdue": [ { "client_id": 19, "client": "laptop-04",
"last_success": "2026-08-13 16:24:21",
"hours_since": 59, "allowed_hours": 48 } ],
"default_overdue_hours": 48,
"failed_24h": 0, "stalled": 0, "in_queue": 2 },
"maintenance": { "status": "ok", "message": "Off", "enabled": false }
}
}Each check is ok, warning or critical, and the overall status is the worst of them. The HTTP status follows it: 200 for ok and warning, 503 for critical — so a check that only reads the status code still alerts on the things that stop backups and stays quiet for the things that don't.
What raises each:
| Check | Warning | Critical |
|---|---|---|
| scheduler | 5 minutes late | 15 minutes late, or never run |
| storage | 85% full | 95% full |
| database | — | not reachable |
| catalog | not reachable (browsing and restore only) | — |
| clients | offline or reporting an error | — |
| backups | a client overdue for a successful backup, or jobs with no progress past the stall timeout | — |
| maintenance | maintenance mode left on | — |
Storage thresholds default to 85 and 95 percent; override with the health_storage_warn_percent and health_storage_critical_percent settings.
This used to warn whenever any backup had failed in the last 24 hours, which made the endpoint unusable for a fleet containing anything that gets switched off: a laptop away for the weekend fails its scheduled runs, and the endpoint went yellow every Monday.
It now asks, per client, whether that machine has gone longer than its profile allows without a successful backup — see Client Profiles. Two weeks for laptops, a day for a database server, falling back to the backup_overdue_hours setting (default 48).
-
overdue[]names each client that is past its allowance, withclient_idso a row can link straight toGET /api/v1/clients/{id}, how long it has been, and what it was allowed. -
failed_24hno longer affects the status. It is still reported and still accurate, but a failure inside a client's allowance is exactly what the allowance is for. Anything that raises an alert from that number will disagree withstatus. -
stalledstill warns on its own — a job making no progress is a different problem from a client that is switched off. - Clients with no enabled backup plan are excluded. One of those is not overdue, it is unconfigured.
This is deliberately more than "is the web app up" — agents keep backing up on their own poll, so a dead scheduler leaves the site perfectly responsive while every server-side job silently stops being queued.
GET /api/v1/server-stats
CPU, memory, network and disk for the machine BBS runs on. Admin-only (403 otherwise).
Its own endpoint rather than more keys on /api/v1/dashboard, for the reason the web splits them: this is the part worth polling on a short timer, and it shells out to read the system.
{
"cpu": { "percent": 27.8, "load_1min": 1.11, "cores": 4 },
"memory": { "percent": 8, "used_bytes": 2679644160, "total_bytes": 33656217600 },
"network": { "rx_bytes_per_sec": 42220, "tx_bytes_per_sec": 5673 },
"partitions": [
{ "mount": "/", "used_bytes": 59654615040, "total_bytes": 209064382464, "percent": 30 },
{ "mount": "/var/bbs", "used_bytes": 4219641307136, "total_bytes": 5283294392320, "percent": 84 }
],
"featured_mount": "/var/bbs"
}- Numbers, not formatted strings. The web's equivalent formats server-side because its JavaScript only swaps text; anything drawing a meter needs the figures.
-
networkvalues arenull, not0, when throughput cannot be read on the host — so "idle" and "unavailable" stay distinguishable. Render a dash rather than "0 B/s" for null. -
featured_mountis the partition worth showing first: the default storage location in hosted mode, else/var/bbs, else/. It is sent rather than described so a client doesn't reimplement that fallback and pick a different one. - A partition that
dflists but cannot be measured in bytes returnsnullbyte figures withpercentstill filled, so a meter can render either way.
GET /api/v1/summary
Every client with its plans and each plan's most recent backup result, nested — one request instead of fanning out per client.
{
"clients": [
{ "id": 3, "name": "web-01", "status": "online",
"backup_plans": [
{ "id": 5, "name": "daily", "enabled": true,
"repository_id": 12, "repository_name": "main",
"last_backup": { "job_id": 9101, "result": "completed",
"duration_seconds": 1170,
"queued_at": "...", "started_at": "...", "completed_at": "..." } }
] }
]
}last_backup is null for a plan that has never run.
GET /api/v1/dashboard
Everything a status screen needs in one round trip, scoped to the caller's clients.
Response:
{
"clients": { "total": 12, "online": 11, "offline": 1, "error": 0, "setup": 0 },
"jobs": { "running": 2, "queued": 3, "failed_24h": 1, "completed_24h": 47 },
"storage": { "used_bytes": 0, "total_bytes": 0, "locations": 2 },
"active": [
{ "id": 88, "client": "web-01", "client_id": 3, "repo": "daily",
"task_type": "backup", "status": "running",
"bytes_processed": 0, "bytes_total": 0,
"files_processed": 0, "files_total": 0, "started_at": "..." }
],
"notifications_unread": 4,
"maintenance_mode": false,
"updates": { "server_available": false, "agents_outdated": 3 },
"archives": {
"recovery_points": 912,
"original_bytes": 171639683998541,
"deduplicated_bytes": 4178435768320,
"on_disk_bytes": 4226652281692,
"dedup_savings_percent": 97.5,
"last_backup_at": "2026-08-16 11:42:07"
},
"jobs_24h": [
{ "hour": "2026-08-15 13:00:00", "backup": 5, "restore": 0, "s3_sync": 0, "failed": 0 },
{ "hour": "2026-08-15 14:00:00", "backup": 1, "restore": 0, "s3_sync": 0, "failed": 0 }
],
"generated_at": "2026-08-09T12:00:00-04:00"
}updates is admin-only and is null for everyone else. It exists so a client can show an "upgrade available" indicator without a second request. Where both are set, treat a server update as taking precedence over the agent count — that's what the web UI does.
archives — v2.91.1. Totals for the caller's clients, for a "backup summary" panel.
-
on_disk_bytesis what the repositories occupy, which is not the sum ofdeduplicated_bytes: compaction and pruning move one without the other, so expect them to differ by a percent or so. -
dedup_savings_percentis computed and clamped server-side at 99.9. Rounding can otherwise reach 100 while dedup is still non-zero, and "100% saved" reads as a bug. Use the number rather than dividing, so every client rounds identically. -
last_backup_atis null on an install that has never completed one.
jobs_24h — v2.91.1. Twenty-four hourly buckets for a bar chart, oldest first.
- Always 24 entries, including hours with nothing in them. A chart with gaps is a different shape from a chart with zeroes, and filling the gaps client-side means re-deriving them against a clock the client does not share with the server.
-
houris the naive-UTC datetime the bucket starts, like every other datetime in this API. - A failed job is counted in
failedonly, never also in its own type — the four series are meant to stack, and counting a job twice would overstate the bar. - Note this counts failed jobs. The web dashboard's red bars are a different measure: unresolved
errorrows in the server log, so that chart agrees with the "Errors (24h)" tile and the log page it links to (#240). The two will not match, and neither is wrong.
GET /api/v1/notifications
Same visibility rules as the web notification bell: global notifications, the caller's own, and client-scoped ones for clients they can access.
Query parameters: limit (default 50, max 200), offset
Response:
{
"notifications": [
{ "id": 91, "agent_id": 3, "level": "critical", "title": "Backup failed",
"message": "...", "occurrence_count": 2,
"read_at": null, "resolved_at": null, "created_at": "..." }
],
"unread": 4,
"limit": 50,
"offset": 0
}POST /api/v1/notifications/{id}/read
POST /api/v1/notifications/read-all
GET /api/v1/clients/{id}/repositories/{repo_id}/archives/{archive_id}/files
Paginated catalog browse. Archives routinely hold millions of rows, so this is keyset-paginated rather than offset-paginated.
Query parameters:
-
path— directory prefix to list (default: the archive root) -
limit— page size -
cursor— opaque cursor from the previous page'snext_cursor
Response:
{
"path": "/var/www",
"dirs": ["/var/www/html", "/var/www/logs"],
"files": [
{ "file_name": "index.php", "path": "/var/www/index.php", "file_size": 4096, "mtime": "2026-05-30 20:34:33" }
],
"next_cursor": "aW5kZXgucGhw"
}next_cursor is present only while more rows exist — pass it back as cursor for the next page. Directories are returned on the first page only (when cursor is unset); subsequent pages contain files alone.
Since v2.78.0 the response also carries the catalog's state, so an archive that has not been catalogued yet is distinguishable from one that is genuinely empty:
{ "catalog": { "status": "pending", "synced_at": null, "job_id": 4188 } }ready · pending (still building — job_id is set when a rebuild is already queued) · unavailable (the catalog engine is not reachable; browsing and search are unavailable, backups are unaffected). Request a rebuild with POST .../repositories/{repo_id}/catalog/sync.
POST /api/v1/clients/{id}/restore
Queues a file restore. Requires the restore permission on the client.
Request:
{
"archive_id": 812,
"paths": ["/var/www/html", "/etc/nginx/nginx.conf"],
"destination": "/restore/2026-08-09"
}Response: the queued job, so the caller can poll GET /api/v1/jobs/{id} for progress.
GET /api/v1/clients/{id}/catalog/search?q=nginx.conf&archive_id=88&limit=100
Finds files without walking the tree — the practical way to locate something when you don't know the path. Reads the pre-built catalog, so it is a lookup rather than a scan of the archive.
archive_id is optional; omitted searches every restore point, which answers "which backup still has this file?".
{
"results": [
{ "file_path": "/etc/nginx/nginx.conf", "file_name": "nginx.conf",
"file_size": 2048, "mtime": "2026-08-09 20:34:33",
"archive_id": 88, "archive_name": "plan12-2026-08-09_02-00-01" }
],
"truncated": false,
"catalog": { "status": "ready" }
}truncated is accurate rather than inferred — the query fetches one row more than requested. limit defaults to 100, maximum 200.
POST /api/v1/clients/{id}/download
Extracts a selection and streams it back as a .tar.gz. Requires the restore permission. This is the same extraction the web page performs.
Request: {"archive_id": 88, "paths": ["/etc/nginx", "/var/www/site"]} — empty paths takes the whole archive.
Errors are JSON and arrive before streaming begins; once the body is flowing the status has already been sent. When the server's staging area can't hold the selection it returns 507 with the figures:
{ "error": "Not enough space to prepare this download…",
"reason": "insufficient_space",
"needed_bytes": 184549376, "free_bytes": 52428800 }GET /api/v1/clients/{id}/db-connectors
The database configurations a restore can target. Credentials are never returned.
{ "connectors": [ { "id": 3, "name": "Primary MySQL", "type": "mysql",
"host": "localhost", "port": 3306, "database": "app" } ] }type is mysql, postgres or mongo.
POST /api/v1/clients/{id}/restore-db
One endpoint for every engine — the connector's type decides which task runs. Requires the restore permission.
{ "connector_id": 3, "archive_id": 88,
"databases": [ { "name": "app_db", "mode": "replace" },
{ "name": "wordpress", "mode": "rename", "target_name": "wordpress_copy" } ] }mode is replace or rename; target_name applies to rename and is sanitised server-side. Returns 202 {"job_id": 4212, "task_type": "restore_mysql", "status": "queued"}.
Restoring from an archive that holds no dumps returns 409. Use the databases endpoint above to see what a restore point contains.
Endpoints for signing a user in, rather than using a pre-minted admin token. Anything that can hold a bearer token can use them.
GET /api/v1/auth/discover
Unauthenticated. Confirms a hostname really is a BBS server and reports which sign-in methods it offers, so a client can render the right controls before asking for a password.
Response:
{
"product": "Borg Backup Server",
"server_name": "Acme Backups",
"version": "2.74.0",
"api_version": 1,
"local_login_enabled": true,
"oidc_enabled": true,
"oidc_button_label": "Login with SSO",
"logo_url": "/branding/icon/180",
"default_theme": "dark"
}Returns nothing an anonymous visitor can't already see on the login page. Rate limited.
POST /api/v1/auth/login
Request:
{ "username": "jsmith", "password": "...", "device_name": "Ops phone", "device_id": "5F2C..." }device_id is a stable identifier the client generates once and keeps. Signing in again from the same device replaces that device's previous token instead of accumulating rows.
Response (signed in):
{
"status": "ok",
"token": "bbs_tok_...",
"expires_at": null,
"user": { "id": 1, "username": "jsmith", "email": "jsmith@example.com",
"role": "admin", "timezone": "America/New_York",
"time_format": "12h", "theme": "dark", "all_clients": true }
}Response (two-factor required):
{ "status": "2fa_required", "challenge": "<opaque>", "expires_in": 300 }Errors: 401 bad credentials · 403 local login disabled · 429 rate limited (5 attempts / 5 minutes, same limiter as the web form).
POST /api/v1/auth/2fa
The challenge is the credential; it is single-use and expires in 300 seconds.
Request:
{ "challenge": "<opaque>", "code": "123456", "device_name": "...", "device_id": "..." }code is either a 6-digit TOTP or an XXXX-XXXX recovery code. On success the response matches the login success shape, plus recovery_codes_remaining when a recovery code was spent.
Errors: 401 bad code · 410 challenge expired or already used · 429 rate limited.
GET /api/v1/auth/oidc/start?device_id=...&device_name=...&redirect=...
POST /api/v1/auth/oidc/exchange
SSO is brokered by the server, so no identity-provider configuration changes are needed — BBS remains the confidential OIDC client and the calling client never becomes a second one.
Open oidc/start in a system browser. After the provider returns, the server redirects to the client's registered scheme with a one-time code (60s, single use), which is exchanged over TLS for the real token:
{ "code": "...", "state": "...", "device_id": "...", "device_name": "..." }The extra hop keeps the long-lived token out of a redirect URL, which is not a private channel on most platforms. redirect must be an allow-listed application scheme; anything else is rejected rather than followed.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/auth/me |
The current user plus capabilities.{is_admin, all_clients}. Use it on start-up to validate a stored token. |
| POST | /api/v1/auth/logout |
Deletes the calling token. Always 204, even if already gone. |
| GET | /api/v1/auth/sessions |
This user's session tokens: id, device_name, created_at, last_used_at, last_seen_ip, current. |
| DELETE | /api/v1/auth/sessions/{id} |
Revokes one — including remotely wiping a lost device. |
POST /api/v1/push/register
DELETE /api/v1/push/register
Registers a device for push notification of backup failures.
Request: {device_id, push_token, platform, device_name, events} — apns_token is accepted as an alias for push_token. events selects which notifications this device receives; omitted, it defaults to failures only.
GET /api/v1/push/devices?device_id=…
PATCH /api/v1/push/devices/{device_id}
The calling user's own devices — not admin data, and scoped to that user.
{ "devices": [ { "device_id": "…", "device_name": "Ops phone", "platform": "ios",
"events": { "backup_failed": true, "backup_warning": false },
"enabled": true, "created_at": "…", "updated_at": "…",
"is_current": false } ] }is_current marks the device whose id was passed as the device_id query parameter — a session token is per-user, not per-device, so that parameter is the only way the server can tell which handset is asking. Push tokens are never returned.
PATCH takes {"events": {…}} or {"enabled": false} and returns the updated device. Disabling keeps the registration and stops delivery.
The account settings of the calling user. Available since v2.74.0. Every endpoint works with either token kind and needs no special role — a user administers their own account.
GET /api/v1/profile
Response:
{
"user": {
"id": 1, "username": "admin", "email": "admin@example.com",
"role": "admin", "all_clients": true,
"timezone": "America/New_York", "time_format": "24h", "theme": "dark",
"auth_provider": "local",
"created_at": "2026-02-07 14:22:10"
},
"storage_alerts": { "mode": "percent", "value": 90 },
"two_factor": { "enabled": true, "enabled_at": "2026-03-01 09:00:00", "recovery_codes_remaining": 6 },
"reports": { "enabled": true, "hour": 6, "frequency": "daily", "day": 1, "smtp_enabled": true }
}auth_provider is local or oidc. SSO accounts have no password, so password-backed actions (password change, 2FA disable, recovery-code regeneration) return 400 for them.
PATCH /api/v1/profile
Partial — send only what changed. Returns the updated user object.
{ "email": "...", "timezone": "America/Denver", "time_format": "24h", "theme": "dark" }Errors: 422 for an unknown timezone, an invalid time_format/theme, or an email already in use.
Changing
timezoneaffects display only. Schedules keep their own declared timezone and their own next run — a timezone change never moves when a backup fires.
GET /api/v1/profile/timezones
Every zone with the offset in effect now, so a client with no timezone database can render and sort a picker.
{
"timezones": [
{ "id": "America/New_York", "label": "New York", "region": "America",
"offset_minutes": -240, "offset_label": "GMT-4" }
],
"detected": null
}POST /api/v1/profile/password
{ "current_password": "...", "new_password": "..." }Minimum 6 characters. Changing the password revokes the user's other session tokens and reports how many; the calling token stays alive, so the client that made the change is not signed out of itself.
{ "status": "ok", "other_sessions_revoked": 3 }Errors: 401 wrong current password · 422 new password too short.
PUT /api/v1/profile/storage-alerts
{ "mode": "percent", "value": 90 }mode is percent, gb_free or disabled. value is clamped to 1–100 for percent, and ≥1 otherwise.
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/v1/profile/2fa/setup |
— | { "secret": "...", "otpauth_uri": "otpauth://totp/...", "expires_in": 600 } |
| POST | /api/v1/profile/2fa/enable |
{ "code": "123456" } |
{ "recovery_codes": ["ABCD-1234", ...] } |
| POST | /api/v1/profile/2fa/disable |
{ "password": "..." } |
{ "status": "ok" } |
| POST | /api/v1/profile/2fa/recovery-codes |
{ "password": "..." } |
{ "recovery_codes": [...] } |
-
setupreturns theotpauthURI, not a rendered QR image, so a client can draw its own QR and offer the key for manual entry. - The pending enrolment secret is held server-side for 10 minutes.
enabletherefore needs only thecode— it never trusts a secret sent back by the client. Enabling after that window returns410. - Recovery codes are shown once and cannot be retrieved again. Capture them from the response.
- Regenerating recovery codes requires the account password, unlike the web page — an unlocked device should not be able to rotate them silently.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/profile/reports |
Preferences, smtp_enabled, and recent reports |
| PUT | /api/v1/profile/reports/preferences |
{enabled, hour, frequency, day} |
| GET | /api/v1/reports/{id} |
The stored report data |
| POST | /api/v1/reports/generate |
Generates one now; returns {"id": 13}
|
| POST | /api/v1/reports/{id}/email |
Optional {"email": "..."}; defaults to the caller |
hour is 0–23 in the user's timezone; day is 0–6 with Sunday = 0 and only applies when frequency is weekly.
GET /api/v1/reports/{id} returns the report's structured data rather than rendered HTML, so a client can present it natively. It is filtered to the clients the caller can access — the stored report covers every client on the server, and the summary is recomputed over the caller's own scope. Whole-server storage figures are included for admins only.
Unreleased. These endpoints are on
mainand will ship in the next release. CheckGET /api/v1/auth/discoverfor a server's version before relying on them.
Server-wide configuration, so every endpoint in this section is admin-only and returns 403 otherwise.
Three rules hold throughout:
-
PATCHis genuinely partial. Only keys present in the body are written; absent keys are left alone. A client can save one section without round-tripping fields it never displayed. -
Values are typed. The settings store keeps everything as strings; the API casts on the way out, so
"1"becomestrueand"587"becomes587. -
Secrets are write-only. Send a value to set it, omit it to keep the stored one. Secrets are never returned — they appear as
*_setbooleans instead. -
Values are effective, not raw. A setting that has never been saved is returned as the default the server actually uses, not as
0/false. What you read is what will happen.
GET /api/v1/settings
Returns all four sections at once.
{
"general": {
"max_queue": 4, "agent_poll_interval": 30, "stall_timeout_minutes": 120,
"agent_offline_notify_minutes": 30, "auto_retry_failed_backups": true,
"auto_retry_max_attempts": 3, "auto_update_agents": false,
"job_offline_grace_minutes": 5, "auto_retry_backoff_minutes": 5,
"precount_files": true,
"auto_compact_day": 0, "auto_compact_hour": 3,
"self_backup_enabled": true, "self_backup_catalogs": false, "self_backup_retention": 14,
"maintenance_mode": false, "debug_mode": false, "default_theme": "dark",
"server_host": "backup.example.com", "url_protocol": "https", "ssh_port": 22,
"session_timeout_hours": 24, "notification_retention_days": 30,
"storage_alert_threshold": 90,
"force_2fa": false, "telemetry_opt_out": false
},
"email": {
"smtp_host": "mail.example.com", "smtp_port": 587, "smtp_user": "bbs@example.com",
"smtp_secure": "tls", "smtp_from": "bbs@example.com", "smtp_pass_set": true,
"inapp_notify_success_events": false,
"email_on_backup_failed": true, "email_on_backup_warning": true,
"email_on_agent_offline": true, "email_on_storage_low": true,
"email_on_missed_schedule": false,
"apprise_urls": "", "apprise_on_backup_failed": true,
"apprise_on_backup_warning": false, "apprise_on_agent_offline": true,
"apprise_on_storage_low": true, "apprise_on_missed_schedule": false
},
"auth": {
"oidc_enabled": false, "oidc_provider_url": "", "oidc_client_id": "",
"oidc_client_secret_set": false, "oidc_redirect_url": "",
"oidc_scopes": "openid profile email", "oidc_button_label": "Sign in with SSO",
"oidc_new_user_policy": "deny", "oidc_template_user_id": null,
"oidc_logout_enabled": false
},
"branding": {
"login_theme": "dark", "icon_url": "/branding/icon/180",
"app_icon_url": null, "login_logo_url": null,
"navbar_icon_set": true, "login_logo_set": false
}
}PATCH /api/v1/settings/{section}
section is general, email, auth or branding. Returns the updated section.
PATCH /api/v1/settings/general
{ "maintenance_mode": true, "max_queue": 6 }
Write-only secrets: smtp_pass on the email section, oidc_client_secret on auth. Send either to set it; omit it to leave the stored value alone.
Changing server_host has side effects. Repository paths bake the host in at creation time, so changing it rewrites the repository paths of every client without a per-client host override, and rewrites APP_URL. The response reports what happened:
{ "general": { "...": "..." }, "side_effects": { "repo_paths_rewritten": 24 } }POST /api/v1/settings/email/test
Optional {"to": "..."}; defaults to the caller's address. Returns {"status":"ok"}, 409 if SMTP isn't configured, or 502 with the failure.
Unreleased.
A profile is a named kind of machine — Laptops, DB Servers, Registers — carrying the settings a new client of that kind starts with. See Client Profiles for what they are and how they behave.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/v1/client-profiles |
— | profiles, each with client_count
|
| POST | /api/v1/client-profiles |
profile fields | the created profile, 201
|
| GET | /api/v1/client-profiles/{id} |
— | one profile, plus apply_impact
|
| PATCH | /api/v1/client-profiles/{id} |
partial | the updated profile |
| DELETE | /api/v1/client-profiles/{id} |
— |
204; 409 on the default profile |
| POST | /api/v1/client-profiles/{id}/apply |
{"confirm": true} |
what was overwritten |
{
"id": 4, "name": "Laptops", "description": "Field machines that sleep mid-backup",
"is_default": false, "template_id": 3, "template_name": "Minimal (System Config)",
"schedule": { "frequency": "weekly", "times": "23:30",
"timezone": null, "timezone_effective": "UTC",
"day_of_week": 6, "day_of_month": null },
"retention": { "minutes": 0, "hours": 0, "days": 9, "weeks": 2, "months": 6, "years": 0 },
"failure_handling": {
"max_retry_attempts": null, "give_up_after_minutes": 45,
"retry_backoff_minutes": null, "backup_overdue_hours": 336
},
"failure_handling_effective": {
"max_retry_attempts": 3, "give_up_after_minutes": 45,
"retry_backoff_minutes": 5, "backup_overdue_hours": 336
},
"client_count": 12
}failure_handling is what the profile stores, where null means "follow the server-wide setting" in GET /api/v1/settings. backup_overdue_hours is how long a client of this kind may go without a successful backup before GET /api/v1/health reports it overdue — two weeks for a laptop, a day for a server. failure_handling_effective is what will actually be used. Retention counts are per interval; 0 keeps none, -1 keeps every one.
schedule.timezone — v2.91.1 — is the zone the run hours are stated in. null means the server's own zone, and timezone_effective is what an apply would actually write onto each schedule. It exists because a schedule stores its own timezone: without it, applying one profile wrote the same 01:00 into schedules that each read it differently, and the same profile appeared to run hours apart on different clients (#411).
PATCH updates the model only and deliberately leaves existing clients alone. The exception is failure handling, which is read live — change it and every client in the profile is treated with the new patience straight away.
Apply is destructive. It overwrites directories, excludes, options, retention and each schedule's frequency, run times and timezone on every client in the profile, leaving repository, plugins and existing archives alone. It requires {"confirm": true}; without it the call returns 422 and the impact instead:
{
"error": "confirm must be true — this overwrites settings on every client in the profile",
"apply_impact": { "clients": 12, "plans": 14, "schedules": 14 }
}GET on a single profile returns the same apply_impact, so the numbers can be shown before the user commits. Every apply is written to the server log. There is no undo.
Clients carry client_profile_id and client_profile_name in GET /api/v1/clients and GET /api/v1/clients/{id}, and PUT /api/v1/clients/{id} accepts client_profile_id to move one.
Unreleased.
Whether this install is opted in to the push notification service, which brokers delivery to mobile devices. Distinct from POST /api/v1/push/register, which registers one device; this is the install-wide opt-in that has to happen first.
GET /api/v1/settings/push
PUT /api/v1/settings/push
{
"push": {
"enabled": true,
"relay_url": "https://push.borgbackupserver.com",
"registered": true,
"server_id": "srv_dc8e8e3138af0f6a"
}
}enabled is the administrator's choice; registered is whether the install actually holds relay credentials. They can differ, and enabled-but-unregistered is the state worth showing.
PUT takes {"enabled": true|false, "relay_url": "https://…"}. enabled is required (422 otherwise); relay_url is optional and must be https (422 otherwise).
Turning it on is not simply a value being written — it registers this install with the relay, which is the first thing that ever leaves the server on its behalf. That is why it has its own endpoint rather than being a key inside a section PATCH, where a bulk save could enable it by accident.
| Outcome | Status | Body |
|---|---|---|
| Disabled | 200 |
push.enabled: false; nothing further is sent to the service |
| Enabled and registered | 200 |
registered_ok: true and a message
|
| Enabled, registration failed | 502 |
registered_ok: false and a message explaining why |
On failure the flag is deliberately left on, so a transient problem is retryable rather than silently reverting.
Unreleased.
Apprise notification targets — Discord, Slack, Telegram, ntfy and many others. Not to be confused with push notification to a mobile device, which is POST /api/v1/push/register.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/v1/notification-services |
— | services + event types |
| POST | /api/v1/notification-services |
{name, apprise_url, events, enabled} |
the created service |
| PATCH | /api/v1/notification-services/{id} |
partial | the updated service |
| DELETE | /api/v1/notification-services/{id} |
— | 204 |
| POST | /api/v1/notification-services/{id}/test |
— |
{"status":"ok"} or 502
|
{
"services": [
{ "id": 3, "name": "Ops Slack", "service_type": "slack", "enabled": true,
"url_hint": "slack://...T0A1B",
"events": { "backup_failed": true, "backup_completed": false },
"last_used_at": "...", "created_at": "..." }
],
"event_types": {
"backup_completed": "Backup Completed",
"backup_warning": "Backup Completed with Warnings",
"backup_failed": "Backup Failed",
"restore_completed": "Restore Completed",
"restore_failed": "Restore Failed",
"agent_offline": "Client Offline",
"agent_online": "Client Online",
"repo_check_failed": "Check Failed",
"repo_compact_done": "Compact Done",
"storage_low": "Storage Low",
"s3_sync_failed": "S3 Sync Failed",
"s3_sync_done": "S3 Sync Done",
"missed_schedule": "Missed Schedule"
}
}-
apprise_urlis a secret — it embeds a webhook credential and is never returned.url_hintis a redacted form for telling two targets apart. Sending an emptyapprise_urlon aPATCHkeeps the stored one; it never clears it. -
service_typeis derived server-side from the URL scheme. -
event_typesships with the response rather than being hard-coded in a client — the list has grown more than once, and a stale copy silently drops new events from an editor.
Unreleased.
Reusable plan definitions.
| Method | Path | Body |
|---|---|---|
| GET | /api/v1/backup-templates |
— |
| POST | /api/v1/backup-templates |
{name, description, directories, excludes, advanced_options} |
| PATCH | /api/v1/backup-templates/{id} |
partial |
| DELETE | /api/v1/backup-templates/{id} |
— |
{ "templates": [
{ "id": 2, "name": "Web server", "description": "docroot and vhosts",
"directories": "/var/www\n/etc/nginx", "excludes": "*.log",
"advanced_options": null, "usage_count": 3,
"created_at": "...", "updated_at": "..." }
] }directories and excludes are newline-separated, exactly as stored. usage_count reports how many plans were created from the template — worth checking before deleting one.
Unreleased.
Manage the tokens described at the top of this page.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/v1/tokens |
— | { "tokens": [...] } |
| POST | /api/v1/tokens |
{name, can_read_secrets} |
{ "id": 9, "token": "bbs_tok_..." } |
| DELETE | /api/v1/tokens/{id} |
— | 204 |
{ "tokens": [
{ "id": 9, "name": "Grafana", "kind": "user", "can_read_secrets": false,
"user_id": 1, "username": "admin",
"created_at": "...", "last_used_at": "...", "last_seen_ip": "10.0.0.4",
"expires_at": null, "device_name": null, "is_current": false }
] }- The token value is returned once, at creation, and never again.
-
is_currentmarks the token making the request. Revoking it signs the caller out, and a list ofbbs_tok_...rows is otherwise indistinguishable — check this before offering a revoke button. -
A session token cannot create a
can_read_secretstoken (403). Session tokens are barred from reading secrets, and minting one would walk around that. - The hosted platform's own token cannot be revoked here (
403). - Duplicate names return
409.
Unreleased.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/v1/updates |
— | server and agent update state |
| POST | /api/v1/updates/check |
— | forces a release check, then the same shape |
| POST | /api/v1/clients/{id}/upgrade-agent |
— | queues one agent upgrade |
| POST | /api/v1/updates/upgrade-agents |
{client_ids?} |
queues several |
{
"server": {
"current_version": "2.74.0",
"latest_version": "2.75.0",
"update_available": true,
"release_notes": "...",
"release_url": "https://github.com/marcpope/borgbackupserver/releases/tag/v2.75.0",
"include_prereleases": false,
"checked_at": "2026-08-10 03:21:36"
},
"agents": {
"bundled_version": "2.74.0",
"outdated": [ { "id": 7, "name": "web-01", "agent_version": "2.70.1" } ]
}
}Agents that already have a pending upgrade job are skipped rather than double-queued; the response reports how many were actually queued. Omit client_ids to upgrade every outdated agent.
There is deliberately no endpoint to upgrade the server itself. That restarts the application, which is not something to trigger over a connection that may drop mid-request with no console to recover from. Use the web UI or the CLI for a server upgrade.
# 1. Generate API token (run once after BBS install)
- name: Generate BBS admin API token
command: /var/www/bbs/bin/bbs-token create --name "ansible"
register: bbs_token
delegate_to: backup_server
# 2. Create client
- name: Create BBS client
uri:
url: "https://backup.example.com/api/v1/clients"
method: POST
headers:
Authorization: "Bearer {{ bbs_token.stdout }}"
body_format: json
body:
name: "{{ inventory_hostname }}"
register: bbs_client
# 3. Install agent
- name: Install BBS agent
shell: "{{ bbs_client.json.install_command }}"
args:
creates: /etc/bbs-agent/config.ini
# 4. Create repository
- name: Create repository
uri:
url: "https://backup.example.com/api/v1/clients/{{ bbs_client.json.id }}/repositories"
method: POST
headers:
Authorization: "Bearer {{ bbs_token.stdout }}"
body_format: json
body:
name: "{{ inventory_hostname }}-main"
encryption: repokey-blake2
register: bbs_repo
# 5. Create MySQL plugin config
- name: Configure MySQL backup
uri:
url: "https://backup.example.com/api/v1/clients/{{ bbs_client.json.id }}/plugin-configs"
method: POST
headers:
Authorization: "Bearer {{ bbs_token.stdout }}"
body_format: json
body:
plugin: mysql_dump
name: "Production DB"
config:
host: localhost
user: bbs_backup
password: "{{ mysql_backup_password }}"
databases: "*"
dump_dir: /home/bbs/mysql
register: mysql_config
# 6. Create backup plan with MySQL plugin
- name: Create backup plan
uri:
url: "https://backup.example.com/api/v1/clients/{{ bbs_client.json.id }}/plans"
method: POST
headers:
Authorization: "Bearer {{ bbs_token.stdout }}"
body_format: json
body:
name: daily-backup
repository_id: "{{ bbs_repo.json.id }}"
directories: "/home\n/etc\n/var/www"
excludes: "*.tmp\n*.log\n*.cache"
advanced_options: "--compression lz4 --exclude-caches --noatime"
frequency: daily
times: "02:00"
prune_days: 7
prune_weeks: 4
prune_months: 6
plugins:
- plugin_config_id: "{{ mysql_config.json.id }}"| Code | Meaning |
|---|---|
| 400 | Bad request (missing required fields) |
| 401 | Invalid or missing API token |
| 403 | Not permitted: an admin-only endpoint called by a non-admin, a client the caller can't access, or a missing per-client permission |
| 404 | Resource not found (including a client outside the caller's scope) |
| 409 | Conflict (duplicate name/path, or a precondition such as SMTP not configured) |
| 422 | Unprocessable entity (invalid field value, or rejected by deployment policy) |
| 429 | Rate limited (too many failed auth attempts) |
| 410 | Gone (a single-use challenge or setup window has expired) |
| 500 | Server error |
All errors return:
{"error": "Description of the problem"}📖 User Manual
Getting Started
Using BBS
- Dashboard
- Managing Clients
- Client Profiles
- Linux Agent Setup
- macOS Agent Setup
- Windows Agent Setup
- Docker Agent Setup
- Repositories
- Storage Setup
- Backup Plans
- Restoring Files
- Database Backups
- Plugins
- Remote Storage
- S3 Offsite Sync
Monitoring
Administration
- Settings
- User Management
- Single Sign-On
- Two-Factor Authentication
- Updating BBS
- Server Backup and Restore
Reference