Skip to content
Marc Pope edited this page Aug 18, 2026 · 22 revisions

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

API tokens and endpoint reference

Authentication

All API requests require a Bearer token in the Authorization header:

Authorization: Bearer bbs_tok_...

Two kinds of token

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.

Token Capabilities

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.

Creating Tokens

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.


Endpoints

All endpoints accept and return JSON. Base URL: https://your-bbs-server

Clients

List Clients

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"
    }
  ]
}

Create Client

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 Client Detail

GET /api/v1/clients/{id}

Returns client info including repositories and plans arrays.

Update Client

PUT /api/v1/clients/{id}

Request:

{
  "name": "new-client-name"
}

Delete Client

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.


Agent Install Command — v2.78.0

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.

Client Statistics — v2.78.0

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.

Repositories

List Repositories

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.

Create Repository (Local)

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"
}

Create Repository (Remote SSH)

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.

Rename Repository

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 Repository

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

List Recovery Points (Archives)

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"
    }
  ]
}

Lock / Unlock a Recovery Point

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.


List All Repositories (Across Clients)

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 decrypted passphrase field 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 to server_log with 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.


Toggle Per-Repository S3 Sync

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
}

Repository Maintenance — v2.78.0

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.

Rebuild the File Catalog — v2.78.0

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 a Recovery Point — v2.78.0

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" }

Databases in a Recovery Point — v2.78.0

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.

Backup Plans

List Plans

GET /api/v1/clients/{id}/plans

Create Plan

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}

Update Plan

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 Plan

DELETE /api/v1/clients/{id}/plans/{plan_id}

Pause Plan

POST /api/v1/clients/{id}/plans/{plan_id}/pause

Disables the schedule. The plan can still be triggered manually.

Resume Plan

POST /api/v1/clients/{id}/plans/{plan_id}/resume

Re-enables the schedule.

Trigger Backup (Run Now)

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\""
}

Jobs & Queue

List Jobs (Backup History)

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 by queued_at — a client with a backlog would otherwise return queued rows ahead of the finished ones you asked for. An unrecognised value returns 400 naming 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 Job Detail (per client)

GET /api/v1/clients/{id}/jobs/{job_id}

Returns full job record including error_log and status_message.

Get Job Detail (by job id) — v2.73.0

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.position is the job's place in line while queued, null otherwise.
  • current_file is only populated while a backup is running.
  • prune_stats is only populated for prune jobs.
  • client_status and client_last_heartbeat are what let a caller explain why a job is stuck — agent offline, agent online but waiting to poll, or queue full.

Global Queue

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

Cancel a Queued Job — v2.73.0

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.

Retry a Failed Job — v2.73.0

POST /api/v1/queue/{id}/retry

Re-queues a failed job. Requires the trigger_backup permission on the job's client.


Plugins

List Plugins

GET /api/v1/plugins

Returns all available plugins (mysql_dump, pg_dump, shell_hook, s3_sync).

Get Plugin Schemas

GET /api/v1/plugins/schema

Returns field definitions for each plugin type, useful for building config forms programmatically.

List Plugin Configs

GET /api/v1/clients/{id}/plugin-configs

Returns named plugin configurations for a client. Sensitive fields (passwords, keys) are masked.

Create Plugin Config

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.


Update, Delete and Test a Plugin Config — v2.78.0

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.

Storage

List Storage Locations

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", ...}
  ]
}

Create Local Storage Location

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 — label and path required, path must be absolute, path must exist as a directory
  • 409 — a storage location already exists at that path

Update Local Storage Location

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 — path differs from the stored one, label is empty, nothing to update, or an attempt to unset the default (make another location the default instead)

Delete Local Storage Location

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 repositories array of their names), the location is the default, or it is the only one left

Remote SSH Targets

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 repositories array of their names
  • 422 — missing name / remote_host / remote_user / ssh_private_key, an unrecognised provider, or nothing to update

Test a Remote SSH Target

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.

Storage Capacity

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

S3 Off-site Sync (Global Settings)

Set Global S3 Settings

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"] }

Clear Global S3 Settings

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 Global S3 Settings

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 returns access_key and secret_key. Requires a token created with the Display Secrets capability; other tokens get HTTP 403. Each call with this flag is logged to server_log for 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.


Maintenance Mode

Get Maintenance State

GET /api/v1/maintenance

Response:

{ "enabled": true }

Toggle Maintenance Mode

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 }

Users

List Users

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 One User

GET /api/v1/users/{id}

Returns the same shape as a single entry in the list.

Create User

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.

Update User

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 User

DELETE /api/v1/users/{id}

Refuses (409) to delete the last admin or the user the calling API token belongs to.


Server Log

List Log Entries

GET /api/v1/log

Query parameters:

  • levelinfo, warning, or error
  • 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
}

Schedules

List All Schedules

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

Day View — v2.74.0

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:

  • dateYYYY-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 in timezone. Group by minute_of_day / 60 to 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, or null when date isn'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.


Monitoring

Metrics Snapshot

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.

Health — v2.78.0

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.

The backups check — v2.91.0

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, with client_id so a row can link straight to GET /api/v1/clients/{id}, how long it has been, and what it was allowed.
  • failed_24h no 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 with status.
  • stalled still 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.

Server Stats — v2.91.1

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.
  • network values are null, not 0, 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_mount is 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 df lists but cannot be measured in bytes returns null byte figures with percent still filled, so a meter can render either way.

Fleet Summary

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.


Dashboard

Dashboard Summary — v2.73.0

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_bytes is what the repositories occupy, which is not the sum of deduplicated_bytes: compaction and pruning move one without the other, so expect them to differ by a percent or so.
  • dedup_savings_percent is 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_at is 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.
  • hour is the naive-UTC datetime the bucket starts, like every other datetime in this API.
  • A failed job is counted in failed only, 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 error rows 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.

Notifications

List Notifications — v2.73.0

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
}

Mark Read — v2.73.0

POST /api/v1/notifications/{id}/read
POST /api/v1/notifications/read-all

Catalog & Restore

Browse Archive Files — v2.73.0

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's next_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.

Restore Files — v2.73.0

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.

Search the Catalog — v2.78.0

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.

Download a Selection — v2.78.0

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 }

Database Connectors — v2.78.0

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.

Restore a Database — v2.78.0

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.


Session API

Endpoints for signing a user in, rather than using a pre-minted admin token. Anything that can hold a bearer token can use them.

Discover a Server — v2.73.0

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.

Sign In — v2.73.0

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

Complete Two-Factor — v2.73.0

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.

Single Sign-On — v2.73.0

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.

Session Management — v2.73.0

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.

Push Registration — v2.73.0

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.

Registered Devices — v2.78.0

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.


My Profile

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 Profile

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.

Update Profile

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 timezone affects display only. Schedules keep their own declared timezone and their own next run — a timezone change never moves when a backup fires.

Timezone List

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
}

Change Password

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.

Low-Storage Alerts

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.

Two-Factor Authentication

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": [...] }
  • setup returns the otpauth URI, 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. enable therefore needs only the code — it never trusts a secret sent back by the client. Enabling after that window returns 410.
  • 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.

Reports

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.


Server Settings

Unreleased. These endpoints are on main and will ship in the next release. Check GET /api/v1/auth/discover for 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:

  1. PATCH is 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.
  2. Values are typed. The settings store keeps everything as strings; the API casts on the way out, so "1" becomes true and "587" becomes 587.
  3. Secrets are write-only. Send a value to set it, omit it to keep the stored one. Secrets are never returned — they appear as *_set booleans instead.
  4. 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 All Settings

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
  }
}

Update a Section

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 } }

Send a Test Email

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.


Client Profiles

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.


Push Notification Service

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.


Notification Services

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_url is a secret — it embeds a webhook credential and is never returned. url_hint is a redacted form for telling two targets apart. Sending an empty apprise_url on a PATCH keeps the stored one; it never clears it.
  • service_type is derived server-side from the URL scheme.
  • event_types ships 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.

Backup Templates

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.


API Tokens

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_current marks the token making the request. Revoking it signs the caller out, and a list of bbs_tok_... rows is otherwise indistinguishable — check this before offering a revoke button.
  • A session token cannot create a can_read_secrets token (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.

Updates

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.


Example: Full Ansible Workflow

# 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 }}"

Error Responses

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"}

Clone this wiki locally