Skip to content

REST API

Every response uses the envelope {"success": bool, "data": ..., "error": "..."}. Some refusals also carry a stable, machine-readable code next to error (api_key_expired, rate_limited); match it rather than the English text.

Authentication

There is no unauthenticated mode. Apart from the public routes below, every request needs either

  • a session: the mr_session cookie set by POST /api/v1/setup or POST /api/v1/auth/login (HttpOnly, SameSite=Strict, Secure over HTTPS). Unsafe methods (POST, PUT, PATCH, DELETE) must also send the session's CSRF token as X-CSRF-Token, or they are rejected with 403; or
  • an API key: Authorization: Bearer <key> or X-API-Key: <key>. Keys are created under Settings → API keys (mr_<prefix>_<secret>, shown once); a MONGORESCUE_API_KEY of an earlier build is imported once as a key. API-key requests need no CSRF token. The command line (mongorescue backup, restore, list, verify, status) is a client of these routes with an API key.

Public routes: / and the dashboard assets, GET /api/v1/health, GET /api/v1/setup/status, POST /api/v1/setup, POST /api/v1/auth/login, GET /api/v1/auth/methods and the single sign-on routes GET /auth/oidc/start and GET /auth/oidc/callback. GET /api/v1/auth/me answers signed-out visitors with 200 and an empty session ({"user": null, "csrf_token": "", "auth": "", "role": "", "scope": ""}); every other protected route answers 401. /metrics takes an API key (not a session) unless the security.metrics_public setting is on. /mcp, the MCP endpoint, takes an API key only, never a session.

Dashboard roles

Every user has a dashboard role: viewer, operator or admin (design). A session gets the scope of its user's role: viewers read, operators operator, administrators admin. The first user (setup) is always an admin, users that existed before v0.17.0 are admins, and POST /api/v1/users creates a viewer unless the request names another role. An API key created by a user can never do more than that user's current role: its effective scope is the lower of the key's scope and the creator's role, recomputed on every request, so demoting a user caps their keys at once (GET /api/v1/api-keys shows effective_scope; GET /api/v1/auth/me shows role, the effective scope and, for keys, key_scope). Keys without a creator (imported from MONGORESCUE_API_KEY) keep their scope. A key cannot be created with a scope above the creator's (403). The last admin can be neither demoted nor deleted (409), and nobody can change their own role (400).

Connection access

A user or an API key reaches every connection or exactly a list of them (design). The access is two fields, all_connections and connection_ids: {"all_connections": true} is every connection, {"all_connections": false, "connection_ids": [...]} exactly those, and an empty connection_ids is no connection, never every connection. Users and keys stored before connection access existed have all_connections: true. Administrators set a user's access with PUT /api/v1/users/{id}/connections (or in POST /api/v1/users) and a key's in POST /api/v1/api-keys; a request with connection_ids but without all_connections means exactly that list, one with neither field every connection, and all_connections: true with a non-empty list is refused (400). Saving a user's list drops, silently, the IDs of deleted connections the user still had, so removing a deleted connection never forces anyone to widen access; other unknown IDs are refused (400). A key is always capped by its creator's current connections, like its scope by the creator's role: a key with all_connections created by a limited user reaches the user's connections, one under a creator with no connection reaches none, and a list naming a connection outside the creator's is refused (404 for a limited caller, like any connection it cannot see). Administrators, and keys with the admin scope, always reach every connection: any limit on them is refused (400), and promoting a user to admin gives them every connection. Group mappings carry connections too.

For a caller limited to some connections, every route behaves as if the other connections, their jobs, backups, restores and run logs did not exist:

  • lists (GET /api/v1/connections, /jobs, /backups, /restores, their databases lists, /runs/active, /readiness with its PITR streams, /pitr/streams, /stats, /stats/history, /notifications/rules, /pending-changes) contain only the caller's records; filters naming another connection find nothing;
  • a PITR stream belongs to its connection: another connection's stream, its chunks, POST /api/v1/pitr/streams/{id}/base and .../chain-test, and a point-in-time restore or preflight naming it (by stream or connection ID, in POST /api/v1/restore and /restores/preflight, MCP pitr_restore, CLI restore --pitr) answer 404 (PITR stream not found), before the admin check those restores need; pitr_status lists the caller's streams only, and the targets of the caller's streams count as its targets;
  • a route whose path, query or body names another connection or one of its records answers 404 (connection not found, job not found, backup not found, ...), exactly like an ID that does not exist; restoring into another connection is 404 too;
  • bulk actions skip such items as not_found;
  • storage targets: only the targets the caller's jobs and backups use and the default target are listed and readable (404 otherwise), and a backup may only name those; the storage scans (GET /api/v1/storage-targets/{id}/scan, and the scans and sweep of GET /api/v1/integrity) cover every connection's archives and are not shown;
  • GET /metrics answers 403: the metrics label every connection, so a limited key cannot scrape them (an unlimited read key can);
  • the audit logs, users, settings changes, connections and storage targets themselves stay admin, which a limited caller never is.

A backup's own connection decides, not its job's: a job that moved from another connection shows a limited caller only the backups taken from its connections (lists, statistics, history, readiness evidence, restore tests), and the RTO of the readiness report counts only restores whose source and target the caller may touch. GET /api/v1/auth/me returns all_connections and, for a limited caller, connection_ids; GET /api/v1/users returns each user's all_connections and connection_ids; GET /api/v1/api-keys returns a key's own access and its effective one, effective_all_connections and effective_connection_ids ([] without effective_all_connections: none, for example because its list and its creator's do not overlap). GET /api/v1/settings shows the connections of the group mappings to administrators only. MCP tools and the command line use these routes, so they follow the same rules: a refused CLI command exits 4 (not found).

API key expiry

A key may expire: POST /api/v1/api-keys takes expires_at (RFC 3339, in the future) or expires_in_days (1 to 3650), not both (400); without either the key never expires. While security.api_key_max_lifetime_days is set, a key without an expiry or with a later one than that many days from now is refused (400), and existing keys get a deadline (security.md). A request with an expired key answers 401 with {"success": false, "error": "unauthorized: the API key expired at …", "code": "api_key_expired"} (on /mcp also WWW-Authenticate: Bearer … error="invalid_token"), also for GET /api/v1/auth/me. GET /api/v1/api-keys and GET /api/v1/auth/me (key_expires_at) show the expiry; keys without one have no expires_at. The list also marks the key imported from MONGORESCUE_API_KEY (imported: true, the only key a maximum lifetime leaves without an expiry) and names the API key that created a key in created_by_key.

Rate limits

Each API key and each session has a request budget (security.api_rate_limit_per_minute, default 1200, with bursts of security.api_rate_limit_burst, default 300), and routes that start work (backups, restores, job runs, verifications, scans, bulk actions, tests) spend a tighter one too (security.api_work_rate_limit_per_minute, default 60, bursts of 20). Unauthenticated requests get 300 a minute (bursts of 100) per client address, with the sign-in routes (setup, login, GET /api/v1/auth/methods, GET /api/v1/auth/me, single sign-on) in a bucket of their own, so failed credentials elsewhere cannot lock out signing in (behind a reverse proxy, turn on security.trust_proxy_headers). A request over a budget is refused before it runs: 429 with Retry-After (seconds) and {"success": false, "error": "too many requests: over the general rate limit of 1200 per minute; retry in 3s", "code": "rate_limited"}; send it again after that. GET /api/v1/health and /metrics with a valid key are never limited; /mcp has its own limit. See security.md.

API key scopes

Every API key has a scope, chosen when it is created (read when omitted); sessions have the scope of their user's dashboard role. The auth middleware checks the effective scope of every request against one route table (internal/server/scopes.go) and answers 403 saying what the request needs and what limits the caller (the role, the key's scope, or the creator's role capping the key) when it is too small:

Scope Allowed
read Every GET route except the audit logs (GET /api/v1/audit, /api/v1/audit/events, /api/v1/audit/events/export, /api/v1/audit/events/verify) and GET /api/v1/users (GET /api/v1/users/names is readable), plus /metrics and the MCP endpoint (read tools only), and the self-service routes: signing out, your own password, sessions and API keys (see below)
operator read plus POST /api/v1/backups, POST /api/v1/backups/{id}/retry, POST /api/v1/jobs/{id}/run, POST /api/v1/jobs/{id}/cancel, POST /api/v1/restore into a safe clone on the backup's own connection, and cancelling backups and restores that are not in place (POST /api/v1/backups/{id}/cancel, POST /api/v1/restores/{id}/cancel), POST /api/v1/backups/{id}/verify, .../pin and POST /api/v1/jobs/{id}/restore-test, plus the MCP action tools
admin Everything: deletions, in-place and cross-connection restores, jobs, connections, storage targets, notifications, settings, users (including the user list and roles), other users' passwords, sessions and API keys, and the audit logs

Six self-service routes need only read in the table, and the auth service applies the finer rule: POST /api/v1/auth/logout ends the request's own session; PUT /api/v1/users/{id}/password changes your own password from a session with the current password, another user's with admin; DELETE /api/v1/auth/sessions/{id} ends your own user's sessions from a session, any session with admin, and no session with an API key below admin; GET /api/v1/api-keys lists only the keys you created unless you are admin; POST /api/v1/api-keys needs a session or an admin key and a scope no higher than yours; DELETE /api/v1/api-keys/{id} revokes the keys your user created from a session, only itself with an API key below admin, and any key with admin (an unknown ID is 404 for everyone).

The recovery kit (POST /api/v1/recovery-kit) is refused to every API key, admin included: it needs a signed-in administrator who confirms their password. An in-place restore ("safe_clone": false or a target_database) and a restore into another connection than the backup's (target_connection_id) need admin even though the route itself needs operator, and so does cancelling a running in-place restore (it may leave the target partially restored). Keys created before scopes existed (and a key imported from MONGORESCUE_API_KEY) are admin keys.

Failed logins return a generic 401. After 5 failures for the same (client IP, username), further attempts for that pair get 429 Too Many Requests with Retry-After; the lockout starts at 30 seconds and doubles up to 15 minutes. Once an IP has 20 recent failures, each further username from it is locked after a single failure, but the correct password of a user that is not locked always works, so clients sharing one address (NAT, a proxy) cannot lock each other out. Only one attempt per (IP, username) is checked at a time, and at most four per IP for usernames that already failed; concurrent extras get 429 with Retry-After: 2. Password comparisons share a global pool (twice the number of CPUs): a login waits up to 5 seconds for a free slot rather than being refused. Wrong setup codes are throttled only after 100 per IP within 15 minutes, and the correct code is always accepted. Passwords longer than 72 bytes are rejected without a password check.

POST /api/v1/setup and POST /api/v1/auth/login require Content-Type: application/json (415 otherwise) and, when the request has an Origin header, it must be this server's host or a configured CORS origin (403 otherwise). This blocks cross-site form posts (login CSRF).

Sessions end after the security.session_idle_timeout without requests (default 12 hours) or the security.session_absolute_timeout after login (default 7 days), on logout, when they are revoked from the session list (DELETE /api/v1/auth/sessions/{id}, the dashboard's user menu → Sessions), and when the user's password changes (other sessions) or the user is deleted. Deleting a user also revokes the API keys they created.

Single sign-on

With the oidc settings on, people sign in through an OpenID Connect provider (recipes, design); API keys, the CLI and MCP are unchanged. The desktop app has no single sign-on. The browser routes live outside /api/ and need no credentials:

  • GET /auth/oidc/start?return_to=/path answers 404 while single sign-on is off. Otherwise it sets the mr_oidc flow cookie (state, nonce and PKCE verifier, sealed with a subkey of secret.key; HttpOnly, Path=/auth/oidc/, Max-Age=600, SameSite=Lax, Secure like the session cookie) and redirects to the provider (authorization code flow, S256 PKCE, query response mode). return_to must be a path on this server; anything else becomes /. Starts are throttled per client address (30 a minute) and not audited.
  • GET /auth/oidc/callback?code=…&state=… clears the flow cookie, checks the state (single use, at most 10 minutes old), exchanges the code with the verifier, verifies the ID token (signature RS256 or ES256, iss, aud, azp, nonce, exp, nbf, iat with 60 seconds of leeway), applies the domain filter and the group mappings, finds the user by issuer and subject or creates one, and redirects to return_to with a session cookie. Failures redirect to /?oidc_error=<code>: state_mismatch, idp_error, token_invalid, domain_not_allowed, no_role (also an unknown identity while auto_create_users is off), account_conflict (a user with that name exists: users are never linked by name or email), disabled or throttled. Tokens, the code and claims never appear in responses, logs or the audit log; access and refresh tokens are discarded.

A group mapping carries connections like a user: all_connections, or exactly connection_ids (an empty list is none; a mapping saved before connection access, with neither field, has every connection). The connections come with the role: only the mappings that grant the user's role, the highest of their matching mappings, count. A user's connections are the union of those mappings' lists, or every connection when one of them has all_connections; a mapping of a lower role never widens them. For example, with G1 → operator on connection A and G2 → viewer on every connection, a member of G1 and G2 is an operator on A only (not a viewer, nor anything, on the others); a member of G2 alone is a viewer on every connection. When no mapping matches and oidc.default_role applies, and for administrators (admin mappings cannot be limited), it is every connection. The connections are recomputed with the role at every sign-in, take effect at once (sessions do not end), and cannot be changed by hand while mappings exist (409). Widening someone's connections is never an admin grant, so the two-person rule does not hold it.

While group mappings exist, the role of a single sign-on user is recomputed at every sign-in (a change ends their sessions, like PUT /api/v1/users/{id}/role) and changing it by hand is refused with 409; without mappings, oidc.default_role is only the role of new users and a role set by hand stays. Single sign-on users have no password: the password form never signs them in (it takes as long as for an unknown name) and changing or confirming their password answers 400, so the recovery kit needs a local administrator. While single sign-on is on, the last local administrator can be neither demoted nor deleted (409); with oidc.local_login admins_only, a local user without the admin role gets 403 from POST /api/v1/auth/login, only after the password matched.

Endpoints

Method Endpoint Description Success Errors
GET /api/v1/health Health check (status, version, time, scheduler, scheduler_last_tick, draining, and in HA mode role and ha); see health 200 503 stale scheduler
GET /api/v1/setup/status {"setup_required": bool} 200
POST /api/v1/setup {setup_code, username, password} → first user and session: {user, csrf_token} 201 400, 403 wrong code, 409 already set up, 429
POST /api/v1/auth/login {username, password} → {user, csrf_token} 200 401, 403 foreign origin or password sign-in limited to local admins, 415, 429
GET /api/v1/auth/methods Sign-in methods: {local: "all"\|"admins_only", oidc: {enabled, display_name, available}} (public) 200
POST /api/v1/auth/logout Revoke the current session → {logged_out, end_session_url} (end_session_url only for single sign-on users with oidc.rp_logout) 200
GET /api/v1/auth/me {user, csrf_token, auth: "session"\|"api_key", role, scope, key_scope, key_expires_at, connection_ids} (scope is the effective scope; key_scope only for API keys, key_expires_at only for keys with an expiry; connection_ids only for a caller limited to some connections); signed out: {user: null, csrf_token: "", auth: "", role: "", scope: ""} 200
GET /api/v1/auth/sessions Your own live sessions, most recently active first: [{id, user_id, username, created_at, last_seen_at, expires_at, current}]; ?all=true lists every user's (admin only). Never carries tokens or their hashes; an API key sees the sessions of the user who created it 200 400, 403
DELETE /api/v1/auth/sessions/{id} Revoke a session → {revoked_id, current}: your own user's from a session of any role, any user's with admin; API keys below admin may revoke none. Revoking your own current session also clears the cookie 200 403, 404
GET / POST /api/v1/users List users [{id, username, role, auth_provider, connection_ids, created_at, last_login_at}] (auth_provider: local or oidc; connection_ids absent for every connection) / create a local user {username, password, role, connection_ids} (role: viewer (default), operator or admin; connection_ids optional, not for admins); admin only 200 / 201 400 (also an unknown connection or a limited admin), 403, 409 username taken
GET /api/v1/users/names [{id, username}] of every user, to show who created or pinned something; read 200
PUT /api/v1/users/{id}/connections {all_connections, connection_ids} → the user: every connection, or exactly the list ([]: none); IDs of deleted connections the user had are dropped; applies to their sessions and API keys from the next request; admin only, no approval under the two-person rule 200 400 neither field, unknown connection or a limit on an administrator, 403, 404, 409 a single sign-on user whose group mappings decide
PUT /api/v1/users/{id}/role {role} → the user; ends their sessions (their API keys are capped by the new role); admin only 200 400 unknown role or yourself, 403, 404, 409 last admin, last local admin while single sign-on is on, or a single sign-on user whose role the group mappings decide
DELETE /api/v1/users/{id} Delete a user and revoke their sessions and API keys; admin only 200 400 yourself, 403, 404, 409 last user, last admin, or last local admin while single sign-on is on
PUT /api/v1/users/{id}/password {current_password, new_password}: your own from a session with the current password, another user's (no current password) with admin 200 400 (also for single sign-on users, who have no password), 403 wrong current password, an API key for your own, or not admin, 404
GET / POST /api/v1/api-keys List keys (admins see all, others their own) with effective_scope, effective_connection_ids and expires_at / create {name, scope, all_connections, connection_ids, expires_at \| expires_in_days} (scope: read (default), operator or admin, at most your own; the access as for users, within your own, every connection with admin; the expiry optional unless a maximum lifetime is set) → {api_key, key} (plaintext only here) 200 / 201 400 (also a limited admin key, an expiry in the past, missing or beyond the maximum lifetime), 403 scope above yours or a non-admin key, 404 a connection you cannot see
DELETE /api/v1/api-keys/{id} Revoke a key: the keys your user created from a session of any role, only itself with an API key below admin, any key with admin 200 403, 404
GET /api/v1/audit Recent API key activity (MCP calls and REST requests), newest first (?limit= 1-1000, default 200); admin only (details) 200 400, 403
GET /api/v1/audit/events The audit log of every action, newest first, with filters and pages (details); admin only 200 400, 403
GET /api/v1/audit/events/export The audit log as JSON Lines, oldest first, same filters; admin only 200 400, 403
GET /api/v1/audit/events/verify Verify the audit log's hash chain and report the first broken entry; admin only 200 403
GET / POST /api/v1/connections List / create {name, uri, description} 200 / 201 400
GET / PUT /api/v1/connections/{id} Get / update a connection 200 400, 404
DELETE /api/v1/connections/{id} Delete a connection 200 404, 409 used by jobs
POST /api/v1/connections/{id}/test Test a saved connection → {ok, server_version, latency_ms, error} 200 404
POST /api/v1/connections/test Test an unsaved {uri} (with optional TLS fields and connection_id) 200 400
GET /api/v1/connections/{id}/databases [{name, size_bytes, empty}]; admin, config, local only with ?system=true 200 404, 502 unreachable
GET /api/v1/connections/{id}/databases/{db}/collections [{name, type}] 200 404, 502
GET /api/v1/stats Dashboard KPIs over every record: counts, failed_backups_24h, total_restores, last_backup, job_last_backups (details) 200
GET /api/v1/stats/history Outcomes and stored size per day, each job's recent runs, the next 24 hours' scheduled runs and failed verifications (?days= 1-366, default 30; ?tz_offset= minutes east of UTC) (details) 200 400
GET /api/v1/schedule/preview Whether ?cron= is a valid schedule and its next ?n= (1-10, default 3) activations, as the scheduler computes them (details) 200 400
GET /api/v1/readiness One row per database a job backs up: last good, verified and restore-tested backups, RPO, estimated RTO, escrowed keys and an ok / warn / fail status (details) 200
GET /api/v1/pitr/streams Every PITR stream with its status (details) 200 503
POST /api/v1/pitr/streams Admin. Create the stream of a replica set connection 201 400, 409
GET /api/v1/pitr/streams/{id} State, windows, lag, headroom, chains and base backups of a stream 200 404
PATCH /api/v1/pitr/streams/{id} Admin. Change a stream: enable, disable, schedule, retention 200 400, 404
DELETE /api/v1/pitr/streams/{id} Admin. Delete a disabled stream (see below) 200 404, 409
GET /api/v1/pitr/streams/{id}/chunks Oplog chunk metadata, newest first (?limit= 1-500, default 100; ?offset=) 200 400, 404
POST /api/v1/pitr/streams/{id}/base Operator. Take a base backup now 202 404, 409 busy
GET /api/v1/settings All settings, secrets masked: {general, security, encryption, integrity, metadata_backup, restart_required, warnings, pending_changes} 200
PUT /api/v1/settings Partial update, e.g. {"general": {...}}; returns the full settings, the pending_changes and, when a change waits for a second administrator, approvals_requested (delete protection) 200 400, 409 two-person rule with fewer than two admins
POST /api/v1/settings/encryption/generate-key New X25519 key pair {identity, recipient} (not stored) 200
POST /api/v1/settings/warnings/{id}/dismiss Dismiss a persistent warning for good; returns the remaining {warnings} 200, 404
POST /api/v1/settings/monitoring/test Send one test ping: {heartbeat_url} (optional; empty, "******" or the masked stored URL ping the stored monitoring.heartbeat_url) → {ok, host}; admin only, at most 5 per minute per user or API key 200 400 invalid or masked URL, nothing configured; 429 too many test pings (Retry-After); 502 the monitor was unreachable or did not answer 2xx (a fixed reason such as connection refused, timeout, TLS error or DNS error, with the host only, never an address); 503
POST /api/v1/settings/oidc/test Fetch the provider's discovery document and keys: {issuer} (optional; the stored issuer when omitted) → {issuer, authorization_endpoint, token_endpoint, jwks_uri, end_session_endpoint, signing_algorithms, usable_algorithms, pkce_methods, keys, key_types}, never secrets; admin only 200 400 invalid issuer, 404 desktop app, 502 provider unreachable or unusable
GET /api/v1/metadata-backup Status of the metadata backups: last snapshot {target_id, target_name, key, created_at, size_bytes, encrypted}, last error, next run 200 503
POST /api/v1/metadata-backup/run Take a metadata snapshot now, in the background (admin) 202 409 already running, 503
GET /api/v1/recovery-kit When the last recovery kit was downloaded and whether it is still current 200 503
POST /api/v1/recovery-kit Download the recovery kit {passphrase, current_password} as an age-encrypted tar (admin, signed-in users only) (details) 200 400, 403, 429
GET / POST /api/v1/storage-targets List / create {name, type, local \| s3} (the first target becomes the default) 200 / 201 400
GET / PUT /api/v1/storage-targets/{id} Get / update a target 200 400, 404, 409 changed meanwhile, location locked, or new credentials of a target in use failed the connection test
DELETE /api/v1/storage-targets/{id} Delete a target nothing references; with the two-person rule 202 with an approval request 200, 202 404, 409 default or in use (by a job or any backup record but purged ones)
POST /api/v1/storage-targets/{id}/test Test a saved target → {ok, latency_ms, error} 200 404
POST /api/v1/storage-targets/test Test an unsaved target (plus id when editing, for its masked secret) 200 400
POST /api/v1/storage-targets/{id}/default Make the target the default 200 404
POST /api/v1/storage-targets/{id}/rotate-credentials Probe new S3 credentials {access_key_id, secret_access_key} (write, read, list, delete) and swap them in (key rotation) 200 400, 404, 409, 422
GET /api/v1/security/key-rotation secret_key: {from_env, fingerprint, previous_key_kept} (key rotation) 200
POST /api/v1/security/rotate-secret-key Rotate secret.key {current_password} (admin, signed-in users only; everyone signs in again) 200, 202 403, 409, 429
POST /api/v1/encryption/rotate Rotate the backup encryption key {passphrase?, reencrypt?} 200 400, 409
GET /api/v1/encryption/reencryption The latest re-encryption job (null when none ran) 200
GET /api/v1/jobs One page of the scheduled jobs (sort name, next_run, last_status or created_at, order asc or desc; limit 1-500, default 100; after the previous page's meta.next); optional filters q, enabled, connection_id, database, schedule, last_status (details) 200 400
POST /api/v1/jobs Create or update a job (connection_id and database or database_selection required, several databases; storage_target_id and parallelism optional; the cron expression is validated). A shorter retention of an existing job is answered as pending_retention (or approval) and applies later (delete protection); warnings names a retention shorter than the object lock of the job's storage target 201 400
GET /api/v1/jobs/{id} Get a job with its next three activations (next_runs, UTC), its effective RPO (effective_rpo_minutes, rpo_default; see RPO) and a pending retention shortening (pending_retention) 200 404
PUT /api/v1/jobs/{id} Update a job and reschedule it at once (details); a shorter retention is answered as pending_retention (or approval) and applies after the grace period 200 400, 404, 409 changed meanwhile
DELETE /api/v1/jobs/{id} Delete a job 200 404
POST /api/v1/jobs/{id}/run Run a job now: the body is the backup record, or for a job with several databases the run (status: "running"), whose databases are resolved and backed up in the background 202 400, 404, 409
POST /api/v1/jobs/{id}/cancel Stop the job's current run: the running database and those still waiting (details) 200, 202 still stopping 404, 409 not running
GET /api/v1/jobs/{id}/runs The job's runs, newest first, each with the outcome of every database (?limit= ≤ 200) 200 404
GET /api/v1/jobs/{id}/databases/preview The databases the job's selection backs up now, the excluded ones with reasons and the new ones ({included, excluded, missing, new_since_last_run, warnings}; query parameters preview another selection) (details) 200 400, 404, 502
GET /api/v1/jobs/databases/preview The same for an unsaved job (connection_id, mode, databases, include, exclude, auto_include_new) 200 400, 404, 502
GET /api/v1/backups List backups, newest first; filters, sorting and pagination (details) 200 400
GET /api/v1/backups/databases Distinct database names of all backups, sorted (for filters) 200
POST /api/v1/backups Start a backup {connection_id, database, collections \| exclude_collections, storage_target_id, gzip, include_users_and_roles} (users and roles), or one run of several with databases and parallelism instead of database (details) 202 400, 409
DELETE /api/v1/backups/{id} Delete a backup softly, with an optional ?reason= → {deleted_id, status: "deleted", deleted_at, purge_after, archive_deleted: false}. The archive stays until purge_after (the delete grace period) and the deletion can be undone until then; with the two-person rule 202 with an approval request (delete protection) 200, 202 404, 409 pinned, running or already deleted
POST /api/v1/backups/{id}/undelete Undo the deletion during the grace period: the backup gets back its earlier status (admin) 200 403, 404, 409 not deleted or already purged
POST /api/v1/backups/bulk Run one action on many backups (delete, verify, pin, unpin, cancel), with a dry run (details) 200 400, 403, 409 confirm_count, 422 too many
POST /api/v1/restores/bulk Delete restore history records or cancel restores in bulk (details) 200 400, 403, 409, 422
POST /api/v1/jobs/bulk Enable, disable, run or delete jobs in bulk (details) 200 400, 403, 409, 422
GET /api/v1/bulk/actions The available bulk actions and whether the caller may run each 200
POST /api/v1/backups/{id}/verify Re-read the archive and compare it with its checksum, in the background; poll the backup for verified_at (verification) 202 404, 409 not completed or already running
POST /api/v1/backups/{id}/pin Pin (legal hold) with an optional {note}: retention and deletion skip it 200 400, 404
POST /api/v1/backups/{id}/unpin Lift the pin (admin: it makes the backup deletable again); with the two-person rule 202 with an approval request 200, 202 403, 404
GET /api/v1/approvals Requests of the two-person rule, newest first (?status= pending, approved, failed, rejected or expired); admin 200 400, 403
GET /api/v1/approvals/{id} One request; admin 200 403, 404
POST /api/v1/approvals/{id}/approve Approve and run the request: an administrator signed in to the dashboard, not the requester; API keys never 200 403 API key, requester or not admin, 404, 409 decided or expired
POST /api/v1/approvals/{id}/reject Reject the request, with an optional {reason} (admin, API keys and the requester included) 200 403, 404, 409 decided or expired
GET /api/v1/pending-changes Lowered protections that take effect later: [{id, kind: "retention"\|"delete_grace_days", job_id, retention_days, retention_count, delete_grace_days, requested_by, approved_by, created_at, effective_at}] 200
DELETE /api/v1/pending-changes/{id} Cancel a pending change; the current protection stays (admin) 200 403, 404
GET /api/v1/jobs/{id}/retention/preview Backups the retention policy would delete now and why (?retention_days=, ?retention_count= preview other values) 200 400, 404
GET /api/v1/jobs/{id}/retention/log Backups the job's retention deleted, newest first (?limit= ≤ 200) 200 404
POST /api/v1/jobs/{id}/restore-test Run a restore test of the job's latest backup now, in the background → {job_id, backup_id} 202 404, 409 no backup or already running, 503
GET /api/v1/jobs/{id}/restore-tests Restore test results, newest first (?limit= ≤ 100) 200 404
GET /api/v1/integrity Integrity sweep status, the latest scan of every storage target, the next scheduled scan and the integrity work running 200
POST /api/v1/integrity/sweep Start an integrity sweep now (admin) 202 409 already running
GET / POST /api/v1/storage-targets/{id}/scan Latest / a new storage scan: orphan and missing archives (POST admin) 200 404
POST /api/v1/storage-targets/{id}/import Create (or revive the failed, pruned or purged record of) the orphan archive {key}, hashed in the background (admin); the archive of a deleted backup waiting for its purge is not an orphan 202 400 no valid database in the key, 404, 409 not an orphan or already importing
POST /api/v1/backups/{id}/retry Retry a failed backup with its parameters; the new record's retry_of is {id} (details) 202 404, 409 not failed or already running, 422 connection or target gone
GET /api/v1/backups/{id}/collections Collections stored in the backup, read from its archive header (details) 200 404, 422 key missing
POST /api/v1/backups/{id}/cancel Cancel a running backup (details) 200, 202 still stopping 404, 409 not running or finishing
GET /api/v1/backups/{id}/log The backup's run log as text/plain: the last N lines with ?tail=N (1-10000), otherwise the whole file as a download (details) 200 400, 404
POST /api/v1/restore Restore (safe clone by default; optional selected_collections, target_connection_id (admin), verify, restore_users_and_roles (in place only, users and roles)) 202 400, 403, 404, 409, 422
GET /api/v1/restores Restore audit history, newest first; filters, sorting and pagination (details) 200 400
GET /api/v1/restores/databases Distinct target databases of all restores, sorted 200
POST /api/v1/restores/{id}/cancel Cancel a running restore; an in-place one needs admin (details) 200, 202 still stopping 403, 404, 409 not running or finishing
POST /api/v1/restores/{id}/drop-clones Drop the clones a restore kept after a failed post-restore command; admin (details) 200 400, 403, 404
GET /api/v1/restores/{id}/log The restore's run log (like the backup log) 200 400, 404
GET /api/v1/runs/active Live progress of every running backup and restore (details) 200
GET / POST /api/v1/notifications/channels List / create notification channels 200 / 201 400
PUT / DELETE /api/v1/notifications/channels/{id} Update / delete a channel 200 400, 404
POST /api/v1/notifications/channels/{id}/test Send a test notification 200 404
GET / POST /api/v1/notifications/rules List / create notification rules 200 / 201 400
PUT / DELETE /api/v1/notifications/rules/{id} Update / delete a rule 200 400, 404
GET /metrics Prometheus metrics 200 401
POST /mcp MCP Streamable HTTP endpoint (JSON-RPC; stateless, so GET and DELETE answer 405); API keys only 200 401, 403 disabled or foreign origin

Every protected endpoint also answers 401 without valid credentials, 403 for a cookie request with a missing or wrong X-CSRF-Token and 403 for an API key whose scope is too small.

Database and collection names sent to create or update a job, start a backup, or name an in-place restore target and selected collections must follow MongoDB's cross-platform rules: a database name has 1 to 63 bytes, none of / \ . " $, a space or a control character, and does not start with -; a collection name is not empty and has no $ or control character. Other names are refused with 400 (invalid namespace). Scheduled runs of existing jobs, retries and restores of existing backups are never refused for their names. Backups are always stored under a key the server derives from the database name; clients cannot choose it.

Connections

Connection strings are validated, encrypted at rest and only ever returned redacted (mongodb://user:******@host/...). To keep the stored password when editing, send the URI back exactly as it was returned; any other value containing ****** is rejected. A connection test succeeds or fails with HTTP 200 (ok: false plus a redacted error) and times out after 10 seconds.

A connection may also set read_preference (primary, primaryPreferred, secondary, secondaryPreferred, nearest; empty keeps the URI's own) with read_preference_tags (a list of tag sets such as [{"dc": "east", "use": "backup"}, {}]), and max_concurrent_backups (0 = unlimited, at most 64); an update without them keeps them. A test (POST /api/v1/connections/{id}/test, or POST /api/v1/connections/test with read_preference and read_preference_tags next to uri) also reports read_member (host, state: primary, secondary, standalone, mongos or other, set_name) with the read_preference it used, or read_member_error when no member matches. See Read preferences, throttling and backup windows.

A connection may also carry TLS material: tls_ca_pem, tls_client_cert_pem, tls_client_key_pem, tls_client_key_password, tls_allow_invalid_hostnames and tls_insecure (which needs "tls_insecure_confirm": true when it is turned on). The key and its password are sealed and returned as ******; send ****** back to keep them, omit a field to keep it and send "" to remove it. POST /api/v1/connections/test takes the same fields, and with connection_id it applies them to the stored connection's. The material is checked on save (400 with the field at fault), and a URI that sets its own TLS file or verification options is refused alongside it. See connections.md.

Administrators may also set post_restore_commands, the MongoDB commands every safe-clone restore into the connection runs against its clones before it completes (post-restore commands); an update without the field keeps them and [] removes them. Only administrators see them: GET answers other callers without the field. They are stored sealed with secret.key, like the URI. Every PUT is audited with post_restore_commands_changed and, when they changed, post_restore_commands_from and post_restore_commands_to (the counts, never the contents). While the two-person rule is on, a PUT that removes or changes any existing command (which could bring erased data back after a restore) saves the other fields, keeps the commands and answers 202 with an approval request (reduce_post_restore_commands, the new list held sealed); adding commands needs no approval.

Jobs and manual backups name a connection_id. Backup records keep connection_id and a connection_name snapshot. A restore goes to the backup's connection unless target_connection_id names another one, which restores across servers and needs admin; restore records always carry source_connection_id, source_connection_name, target_connection_id and target_connection_name, the names as they were when the restore started.

Settings

GET /api/v1/settings returns every setting grouped as general, security and encryption (descriptions), plus restart_required (always empty: every change applies to the next operation or request), pending_changes (lowered protections that take effect later, see delete protection) and warnings, the persistent notices the dashboard shows as a banner: [{id, message, setting}]. The warnings are encryption_off_after_upgrade (see encryption.md), metadata_backup_unencrypted (metadata backups are on but encryption is off; it cannot be dismissed) and recovery_kit_missing (no recovery kit was downloaded since secret.key, the encryption keys or the storage targets last changed; dismissing it hides it until they change again). Durations are Go duration strings ("6h0m0s"), secrets (encryption.identity, encryption.passphrase) are "******" when set and "" when not, and encryption.retired_keys lists {kind, recipient, retired_at} without key material.

PUT /api/v1/settings takes one or more groups with only the fields to change and answers with the full settings. Unknown fields, invalid values and malformed durations are rejected with 400 and nothing is changed. A lower security.delete_grace_days, turning security.require_locked_copies off and turning security.require_second_approver off are not applied by the request: the first two become pending changes (or, with the two-person rule, approval requests), the last always an approval request; see delete protection. Sending "******" for a secret keeps the stored value; "" removes it. A replaced or removed identity or passphrase is moved to retired_keys, so backups encrypted with it stay restorable.

curl -X PUT http://localhost:8080/api/v1/settings -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' -d '{"general": {"backup_timeout": "3h", "default_retention_days": 14}}'

The oidc group configures single sign-on (fields); client_secret is stored encrypted and shown as "******". Turning enabled on, or local_login to admins_only, needs a local administrator, and turning single sign-on on (or changing the issuer while it is on) fetches the provider's discovery document and keys first; a failure answers 400 and changes nothing. API key expiry adds api_keys_expiring (keys that expire within security.api_key_expiry_warning_days or have expired, each in api_keys: [{id, name, prefix, expires_at}]; a non-admin sees only their own keys) and api_key_never_expires (a key imported from MONGORESCUE_API_KEY while a maximum lifetime is set); neither can be dismissed. Single sign-on adds the warning oidc_role_kept (a sign-in would have demoted the last administrator, or an administrator while the two-person rule is on, so the stored role was kept; under the rule the demotion waits as an approval request).

The audit group holds retention_days (default 365, 30 to 36500), webhook_url and webhook_secret (audit.md). Both are stored encrypted; webhook_url is shown only up to its host ("https://siem.example.com/******", a bare origin unchanged) and webhook_secret as "******". Sending either back as shown keeps the stored value, "" removes it. A webhook_url with another host needs webhook_secret again (400 when it is masked or omitted), like oidc.client_secret when oidc.issuer changes.

The monitoring group holds heartbeat_url and heartbeat_interval (default 5m, 1m to 1h; see monitoring.md). heartbeat_url is stored encrypted and shown only up to its host ("https://hc-ping.com/******"); sending it back as shown, or "******", keeps it, "" turns the heartbeat off. A masked value with another host is refused with 400: the full URL must be entered again. Test the URL with POST /api/v1/settings/monitoring/test.

To encrypt new backups with a fresh key pair, call POST /api/v1/settings/encryption/generate-key, store the returned identity safely, and send {"encryption": {"enabled": true, "mode": "x25519", "recipients": ["<recipient>"], "identity": "<identity>"}}. The passphrase of passphrase mode needs at least 16 characters.

Storage targets

A target is {id, name, type: "local"|"s3", is_default, local: {path}, s3: {endpoint, region, bucket, prefix, access_key_id, secret_access_key, use_path_style, part_size_mb, object_lock, retention_days, legal_hold_on_pin}, created_at, updated_at, last_test_at, last_test_ok, last_test_error, hints}. object_lock is none, governance or compliance with retention_days 1 to 3650 and optional legal_hold_on_pin (immutable backups); saving or testing a locked target is refused (400, or a test with ok: false) unless the bucket has Object Lock and versioning enabled. An update that lowers the lock (a weaker mode, none, a shorter retention or the legal hold turned off) keeps the current lock and answers pending_object_lock (applied after the delete grace period) or, with the two-person rule, approval; raising it applies at once. hints (responses only) lists {level: "info"|"warn", code, message}: no_object_lock, governance_bypass or local_not_immutable. Create and update bodies contain only the sub-object of their type; the default is changed only with POST /api/v1/storage-targets/{id}/default. The secret access key is returned as "******"; sending it back keeps the stored key only while endpoint, bucket and access_key_id are unchanged. part_size_mb is the multipart part size, 5 to 512 MiB (default 16 when omitted); it caps one archive at part size × 10,000 (see S3 multipart limits). Tests answer 200 with ok: false and an error when the probe fails. local.path must be absolute and must not be /, the data directory or inside it. Updates are refused with 409 when the target changed meanwhile, when they would move a target that backup records reference (type, path, endpoint, bucket or prefix; every record but purged ones counts, deleted backups waiting for their purge included), and when new credentials, region or path style of such a target fail a connection test; create a new target instead of moving one.

Every signed-in user, and every API key with the admin scope, has full rights, including settings, storage targets and the connection and storage test endpoints, which connect to hosts named in the request. Roles for users are on the roadmap.

Jobs and manual backups take an optional storage_target_id (the default target when omitted). Backup records carry storage_target_id and a storage_target_name snapshot; restores, deletions and retention use the record's target. Deleting the default target, or a target still used by a job or referenced by any backup record but purged ones, answers 409 with the reason.

Metadata backups and the recovery kit

The metadata_backup settings group (enabled, interval, target_id, retention_count; see configuration.md) schedules snapshots of mongorescue.db. POST /api/v1/metadata-backup/run takes one now (202, 409 while one runs); GET /api/v1/metadata-backup reports {enabled, prefix, running, last_run_at, last_trigger, last_error, retention_error, last, next_run_at}; prefix is _mongorescue/metadata/<install_id>/, this installation's own prefix (derived from secret.key), and last carries install_id. A failed snapshot publishes metadata_backup.failed. The runbook is in production.md.

POST /api/v1/recovery-kit returns the recovery kit as application/octet-stream (Content-Disposition: attachment; filename=mongorescue-recovery-kit-<date>.tar.age, Cache-Control: no-store). The body is {"passphrase": "...", "current_password": "..."}:

  • only a signed-in user may call it, with the session cookie and X-CSRF-Token; API keys get 403 whatever their scope;
  • current_password must be the user's password (403 otherwise; wrong passwords count against the same throttle as password changes, then 429 with Retry-After);
  • passphrase seals the kit with age scrypt and needs at least 12 characters (400). It is not stored.
  • include_previous_key (optional) adds secret.key.previous, the key the last secret.key rotation replaced, which opens metadata snapshots taken before it (409 when there is none).
  • plaintext_secret_key (optional) only matters with key wrapping on: the kit then carries secret.key.wrapped (and secret.key.previous.wrapped) with the KMS key URI in recovery.json (secret_key.wrapped, secret_key.key_wrap), and recovering needs access to the KMS key; true puts the plaintext keys into the kit instead. The audit entry of such a download records plaintext: true. Under the two-person rule the first such request answers 202 with an approval request (plaintext_recovery_kit); once another administrator approved it, the requester repeats the request with "approval_id": "<id>" within an hour, once (403 for a request that is not approved, not theirs, too old or already used). The response always carries Cache-Control: no-store. 409 while secret.key.wrapped does not hold the running key (a rotation being installed).

The kit is a tar archive with README.txt (the recovery steps), secret.key, recovery.json (encryption settings, every storage target with its credentials, the snapshot prefix metadata_prefix and the latest metadata snapshot) and identities.txt (the age private keys, only when the server holds one; in recipient-only mode the README points to your own key file instead). Passphrases are never included. Open it with age -d -o kit.tar mongorescue-recovery-kit-<date>.tar.age && tar -xf kit.tar. Every download and refused attempt is written to the audit log (POST /api/v1/recovery-kit, the user, the result), never the passphrase, the password or the content. GET /api/v1/recovery-kit returns {downloaded_at, up_to_date, min_passphrase_length, key_wrapped}.

Key rotation

See the runbook in encryption.md.

  • POST /api/v1/security/rotate-secret-key needs a signed-in administrator, the session cookie, X-CSRF-Token and {"current_password": "..."} (API keys 403, a wrong password 403, then 429). With the two-person rule it answers 202 with the approval request; the rotation runs when a second administrator approves it. A key from MONGORESCUE_SECRET_KEY answers 409, as does a rotation already running or an earlier one a restart must settle. On success it answers {old_fingerprint, new_fingerprint, resealed, sessions_revoked, skipped, rotated_at, sign_in_again: true}; every session, the caller's included, has ended. skipped names (table|id|field) stored secrets that could not be opened (damaged or planted, see unreadable records): they are left as they were, do not block the rotation, are listed in the event and the audit entry (skipped), and must be entered again.
  • POST /api/v1/encryption/rotate (admin) answers {mode, old_fingerprint, new_fingerprint, recipients, reencryption}. X25519 keys are named by their public keys; passphrases by "passphrase". With "reencrypt": true the answer carries the job {id, status, total, done, skipped, failed, errors, ...}, also returned by GET /api/v1/encryption/reencryption.
  • POST /api/v1/storage-targets/{id}/rotate-credentials (admin) answers {target, steps: [{name, ok, error}], old_access_key_id, new_access_key_id}. A failed probe answers 422 with the same document in data and changes nothing; a local target, missing fields or unchanged credentials answer 400.

When the database was re-sealed but the new key file could not be installed, the endpoint answers 500 with that explanation: the audit entry already carries the fingerprints, and the next start installs secret.key.next and publishes the event then (also for a rotation a crash interrupted after its commit; the actor and approval come from the rotation marker, the audit log gets a keyrotation.completed system entry). Each successful rotation publishes security.key_rotated (action: secret_key, encryption or storage_credentials; detail: the old and new fingerprints; approval_id) and annotates its audit entry with key_rotated, old_fingerprint and new_fingerprint.

Restores

Restore into a safe clone (the default, so safe_clone may be omitted):

curl -s -X POST http://localhost:8080/api/v1/restore \
  -H "Authorization: Bearer $MONGORESCUE_KEY" -H 'Content-Type: application/json' \
  -d '{"backup_id": "bkp_shop_20260924_030000_3f9a1c2e"}'

A safe clone is a new database named <db>_rescue_<YYYYMMDD_HHMMSS>_<id>: the source database, the start of the restore in UTC and a random 4-character lowercase hex ID (from a cryptographic random source), for example shop_rescue_20261002_120000_3f9a. The ID keeps two restores of one database started in the same second apart. A name may not exceed MongoDB's 63 bytes, so the source part of a database longer than 35 bytes is shortened (at a character boundary). Anything matching _rescue_ still matches every clone. The restore record's target_database names the clone; the preflight of POST /api/v1/restore checks that very name.

Restoring in place (into the source database, or into target_database) must be confirmed explicitly with {"safe_clone": false, "confirm_in_place": true}; any other in-place request is rejected with 400 Bad Request before mongorestore starts. In-place restores are always verified first (verify and the policy apply to safe clones only). A missing decryption key is rejected up front with 422 Unprocessable Entity; a checksum mismatch or failed decryption found during verification marks the restore record as failed, and mongorestore is never started. Two restores into the same target database cannot run at once (409); a dry run ("dry_run": true) writes nothing, so it takes no lock on the target and runs alongside them.

A backup with copies can be restored from any target that holds it: source_target_id names the primary target or the target of a done copy (anything else is 400). Without it the restore reads the primary, and falls back to the first healthy copy when the primary archive is recorded missing, failed its last verification, or is missing or has another size on its target. The restore record then has source_target_id, source_target_name and source_fallback (why it read a copy), and the preflight adds a source check: pass when the chosen archive is there, warn with the reason after a fallback, fail when the archive is missing and no healthy copy exists.

curl -s -X POST http://localhost:8080/api/v1/restore \
  -H "Authorization: Bearer $MONGORESCUE_KEY" -H 'Content-Type: application/json' \
  -d '{"backup_id": "bkp_shop_20260924_030000_3f9a1c2e", "source_target_id": "stg_offsite"}'

Restore preflight

POST /api/v1/restores/preflight (operator scope) takes the body of a restore request and answers the go/no-go summary without starting anything:

{
  "ok": false,
  "checks": [
    {"id": "connection", "status": "pass", "message": "connected to prod (MongoDB 7.0.14)"},
    {"id": "encryption", "status": "pass", "message": "the backup is not encrypted"},
    {"id": "server_version", "status": "warn", "message": "the backup's server version is unknown (backups of earlier releases do not record it); the target runs MongoDB 7.0.14"},
    {"id": "target_database", "status": "pass", "message": "restores into the new database shop_rescue_<YYYYMMDD_HHMMSS>_<id>; existing data is untouched"},
    {"id": "privileges", "status": "pass", "message": "the user of connection prod may restore into shop_rescue_<YYYYMMDD_HHMMSS>_<id>"},
    {"id": "disk_space", "status": "fail", "message": "the target has 120.0 MiB free, less than the 2.1 GiB archive"},
    {"id": "collections", "status": "pass", "message": "a safe clone restores into a new database; no existing collection is replaced"},
    {"id": "users_and_roles", "status": "pass", "message": "users and roles are not restored"}
  ]
}

status is pass, warn or fail; ok is false when a check failed. A safe clone is named when its restore starts, so this endpoint names it by its pattern (shop_rescue_<YYYYMMDD_HHMMSS>_<id>, also in post_restore); the checks stored on a started restore name its clone. The checks, in this order:

ID Checks
connection The target server answers (fail otherwise; the server checks below are then warn "not checked").
encryption An encrypted backup has a decryption key configured.
server_version The target's MongoDB version against the version recorded on the backup (server_version, read with buildInfo while the manifest is captured; backups of earlier releases have none and warn "unknown"). Any difference only warns (mongorestore may still work across versions); this check never fails.
target_database Whether the target database exists; for a safe clone the clone name must be free.
privileges The connection's user holds createCollection, createIndex and insert on the target database (connectionStatus), or on each restored collection through collection-level grants, plus dropCollection for an in-place restore with drop_target. It fails only when the actions are certainly missing (built-in roles only, every privilege understood); with custom roles, partial grants or an unreadable connectionStatus it warns. Missing user administration actions for restore_users_and_roles only warn.
disk_space The archive size against the free space of the target server's data filesystem, from dbStats (fsTotalSize - fsUsedSize) or, for a server on the same host, from the file system of its dbPath. Less free space than the archive fails only when dbStats reported it and the restore is not in place with drop_target (which frees the space of what it replaces); the local value (a loopback address may be an SSH tunnel or a Docker host network) only warns. Less than twice (uncompressed) or four times (compressed or unknown) the archive warns; unknown free space warns.
collections For an in-place restore, the existing collections the restore writes into (from the selection, the backup's manifest or its collection filter, against listCollections): dropped and replaced with drop_target, otherwise documents are added. Listed as a warning.
users_and_roles restore_users_and_roles against the backup (fails where POST /api/v1/restore answers 400); a valid request warns that the database's users and roles are replaced.
post_restore Only when the target connection has post-restore commands: the commands the restore runs, per clone (delete on users in shop_rescue_20261002_120000_3f9a), also listed in post_restore of the result with status: "planned". Passes for a safe clone and a dry run (which runs none), warns for an in-place restore (which runs none), fails when a stored command is no longer valid. Point-in-time preflights list them too.

Only these can fail, and so refuse a restore: connection (the target is unreachable), encryption (no decryption key, which POST /api/v1/restore already answered with 422), target_database (the safe clone name exists), privileges (write actions certainly missing), disk_space (free space reported by dbStats smaller than the archive), users_and_roles (a request POST /api/v1/restore already answered with 400) and post_restore (a stored command that is no longer valid). Everything else warns. The server checks share one connection to the target and are bounded by 15 seconds.

A preflight applies the scope rules of the restore it checks: an in-place or cross-connection preflight needs admin. confirm_in_place is not needed to ask (it is still needed to start the restore). Dry runs need neither privileges nor space.

POST /api/v1/restore runs the same checks before it starts and stores them on the restore record (preflight). A failed check refuses the restore with 409 Conflict, the checks in data and the failed ones in error; send "force": true to restore anyway (the record then has "forced": true). Warnings never block, and dry runs are never refused. The dashboard runs the preflight while the restore dialog's options change and shows Restore anyway when a check failed.

Restore verification

"verify_restore": true compares the restored database with the manifest the backup captured (per collection: the document count range and the indexes) once the restore completed, like an automated restore test does with a temporary database. It is off when omitted, so API clients of earlier releases are unchanged; the dashboard turns it on by default. Only the restored collections are compared: the selection of a selective restore, and for an in-place restore only the collections in the backup (others in the target are ignored). An in-place restore without drop_target adds documents to the ones already there, so a larger count is a note, not a mismatch. The result is stored on the record:

"verification": {
  "status": "failed",
  "mismatches": ["collection orders: 9 documents restored, 10 expected"],
  "notes": [],
  "collections": 2,
  "checked_at": "2026-10-02T12:00:41Z"
}

status is passed, failed or skipped (a dry run, a backup without a manifest, a target that could not be inspected; notes says why). A PITR chain test compares exact counts and adds unverified, the number of collections whose count could not be checked (with a note each), unverified_reason (the first reason) and unverified_old_base (the newer base predates exact counts); such a pass is not clean: readiness warns with pitr_chain_test_unverified. A failed verification keeps the restore completed, adds a warning to it and publishes restore.verification_failed (selectable in notification rules): the data is applied, but it does not match what the backup recorded. The dashboard shows the result in the restore details.

Post-restore commands

A connection's post_restore_commands (admin, POST/PUT /api/v1/connections, or Post-restore commands in the dashboard's connection form) are MongoDB commands that every safe-clone restore into the connection (a restore, a point-in-time restore or a restore test) runs against the databases it created, before it is reported complete. The use case is re-applying erasures to restored data (privacy.md):

"post_restore_commands": [
  {"database": "*", "command": {"delete": "users", "deletes": [{"q": {"_id": {"$in": [1042, 1077]}}, "limit": 0}]}},
  {"database": "shop", "command": {"update": "orders", "updates": [{"q": {"customer_id": 1042}, "u": {"$unset": {"address": ""}}, "multi": true}]}}
]
  • database is a source database: the command runs in that database's clone, and not at all when the restore did not restore it. "*" runs it in every clone the restore created. A command never runs in the database it names, in a source database, or in admin, config or local: every target must be a clone the restore created, or nothing runs and the restore fails.
  • command is a command document in (extended) JSON, sent with runCommand; its first key is the command. Allowed: delete, update, findAndModify, dropIndexes, collMod and drop, on one collection (not a system.* one). Refused anywhere in the document: $where, $function, $accumulator (server-side JavaScript), $out, $merge, $lookup, $graphLookup, $unionWith (other collections or databases) and $db; no top-level key may start with $ and no key may repeat in one object. Commands such as eval, applyOps, aggregate, renameCollection and dropDatabase are refused with the reason. Upserts are refused (upsert other than false in an update entry or a findAndModify): the commands only remove or change restored data, never insert. Integers beyond ±2^53 must be written as {"$numberLong": "…"}, so no client rounds an _id. At most 100 commands of 512 KiB together.
  • The commands run in order, per command in every clone it applies to. Dropping a collection or an index that does not exist succeeds, so a list can be re-applied any number of times.
  • A restore runs its commands over one connection to the target, each bounded by general.post_restore_command_timeout (default 60s, 1s to 24h); a deferred run (after verify_restore) is bounded by general.restore_timeout as well.
  • A command that fails, times out or cannot run fails the restore (error_message names the command, its collection and clone, and the server's error code and code name only: server messages can quote document values, such as the key of a duplicate key error) and stops the rest. The clones are kept for inspection, not dropped: they hold the restored data without every command applied (erasures included), so do not use them. Until they are dropped, every settings response carries a post_restore_clones_kept warning per restore (restore_id, databases) and the dashboard shows them with a Drop clones button; POST /api/v1/restores/{id}/drop-clones (admin) drops exactly the recorded clones of that restore (never its source or another database), records clones_dropped_at and clones_dropped_by, and is audited.
  • In-place restores and dry runs run none: the record's post_restore says so, and an in-place restore carries a warning to re-apply them by hand. With verify_restore, the clone is compared with the manifest first, then the commands run. Restore tests compare first too, then run the commands as a rehearsal (a failure fails the test; the temporary database is dropped as always). PITR chain tests run none.

The restore record shows what ran, with names and counts only, never the command documents or the documents they touched:

"post_restore": {
  "status": "completed",
  "commands": [
    {"index": 0, "database": "shop_rescue_20261002_120000_3f9a", "source_database": "shop", "command": "delete", "collection": "users",
     "status": "ok", "n": 2, "duration_ms": 14},
    {"index": 1, "database": "shop_rescue_20261002_120000_3f9a", "source_database": "shop", "command": "update", "collection": "orders",
     "status": "ok", "n": 5, "modified": 5, "duration_ms": 9}
  ]
}

status is completed, failed (with note and clones_kept) or skipped (dry runs and in-place restores, with note); each command is ok, failed (with a redacted error; write errors give their codes only, since their messages can quote document values), not_run (after a failure) or planned (dry runs). n counts the documents a delete removed or an update or findAndModify matched, modified and upserted what an update changed. Every command that ran is also written to the audit log as SYSTEM restore.post_restore_command, and its counts to the run log.

Selective restores

selected_collections restores only the named collections of the backup (one --nsInclude each, with * and \ escaped, so a name always means exactly that collection); omitted or empty, the whole database is restored. The restore record repeats the selection in selected_collections. With drop_target, mongorestore drops each collection right before restoring it, so only the selected collections are dropped in the target; its other collections are kept. A view is restored from its definition and reads from its source collection (view_on), which is not restored with it unless it is selected too.

curl -s -X POST http://localhost:8080/api/v1/restore \
  -H "Authorization: Bearer $MONGORESCUE_KEY" -H 'Content-Type: application/json' \
  -d '{"backup_id": "bkp_shop_20260924_030000_3f9a1c2e", "selected_collections": ["orders", "customers"]}'

GET /api/v1/backups/{id}/collections (read scope) lists what the backup holds, to choose from:

{
  "backup_id": "bkp_shop_20260924_030000_3f9a1c2e",
  "database": "shop",
  "source": "archive",
  "collections": [
    {"name": "big_orders", "type": "view", "view_on": "orders"},
    {"name": "metrics", "type": "timeseries"},
    {"name": "orders", "type": "collection"}
  ]
}

The list is read from the prelude of the mongodump archive only: the artifact is streamed from its storage target, decrypted and decompressed on the fly, and the download stops where the prelude ends, so the data of the backup is never transferred. At most 64 MiB of decompressed prelude are read, within 30 seconds, and at most four archives at a time; lists of completed backups are cached in memory per backup. type is collection, view or timeseries (system collections are listed too; the dashboard hides them). size_bytes appears when the archive records a size (current mongodump versions do not); document counts are not part of the archive header.

When the archive cannot be read (an artifact that is missing, damaged or not a mongodump archive, a timeout) or the backup failed or was pruned (its archive is not read then), source is record, warning gives the reason and collections is the backup's own collection filter (empty for a whole-database backup). An encrypted backup without its key is answered the same way when the record has a filter, with the key hint in warning; without one, the response is 422 with the same hint as a restore.

Users and roles

Backups do not include the users and roles defined on a database unless asked to. A job (POST /api/v1/jobs, PUT /api/v1/jobs/{id}) or an on-demand backup (POST /api/v1/backups) with "include_users_and_roles": true (default false, returned by GET /api/v1/jobs/{id}) runs mongodump --db=<db> --dumpDbUsersAndRoles, once per database for a job with several databases. The backup record then says "users_and_roles": true; a retry of a failed backup keeps the option. mongodump refuses --collection with this flag, so a collection filter of such a backup is passed as exclusions of the other collections. Without a database (database empty, which mongodump would dump whole), the option is ignored, since mongodump only dumps users and roles together with --db. The admin database is the exception: it stores every user and role of the server as its own collections (admin.system.users, admin.system.roles), so its backups contain them anyway, and a single-database job or backup of admin with include_users_and_roles is refused with 400. List, all and pattern selections never include admin.

A restore with "restore_users_and_roles": true runs mongorestore --db=<db> --restoreDbUsersAndRoles and records "users_and_roles": true on the restore. mongorestore restores the users and roles under the name of the database they were dumped from, so the option is allowed only for an in-place restore into the backup's own database ("safe_clone": false, "confirm_in_place": true, no target_database or the source database's name). Combined with a safe clone or a target_database that renames the restore, it is rejected with 400; it is rejected with 400 as well when the backup was taken without users and roles, or is a backup of admin. The users and roles of the target database are replaced by the ones in the backup: users and roles created since the backup are removed, in full even when selected_collections restores only some collections. With target_connection_id, an in-place restore on another server restores them there, into the database of the same name. Restoring them needs a connection whose user may manage users and roles (for example userAdminAnyDatabase or root).

curl -s -X POST http://localhost:8080/api/v1/restore \
  -H "Authorization: Bearer $MONGORESCUE_KEY" -H 'Content-Type: application/json' \
  -d '{"backup_id": "bkp_shop_20260924_030000_3f9a1c2e", "safe_clone": false, "confirm_in_place": true, "restore_users_and_roles": true}'

The MCP tools do not take these options: restore_to_safe_clone only restores into a safe clone, and jobs are created through the REST API or the dashboard (get_job and the job resources show include_users_and_roles).

Updating a job

PUT /api/v1/jobs/{id} (admin scope, like creating a job) replaces a job's name, cron_expression, database, collections, exclude_collections, connection_id and storage_target_id. retention_days, retention_count, gzip, include_users_and_roles (users and roles), rpo_minutes (RPO), enabled, verify_after_backup ("" follows the integrity.verify_after_backup setting, "on", "off") and restore_test ({enabled, frequency, every_n, connection_id}, see verification.md) are optional: an omitted field keeps the job's current value, so {"enabled": false, ...} pauses a job without touching its retention, and last_restore_test is managed by the server and never taken from requests. Pausing with "paused_until": "<RFC 3339 time>" resumes the job on its own at that time (the scheduler checks every minute; the time must be in the future, else 400); pausing without it pauses until the job is resumed, an edit that keeps the job paused keeps its paused_until, and resuming ("enabled": true) clears it. A paused job's run in progress continues; stop it with cancel. The job is validated exactly like a new one: the cron expression must parse (five fields or a descriptor such as @daily or @every 6h; an empty one means @daily), database and a known connection_id are required, retention must not be negative, and storage_target_id must name a target (empty means the default target).

The new schedule takes effect immediately, without a restart: the job's cron entry is replaced, or removed for a disabled job, and next_run is recomputed. The id, created_at, last_run and the job's backups (GET /api/v1/backups?job_id={id}) are kept. The response is the updated job. Concurrent updates are stored and scheduled in the same order, and a backup that finishes while the job is being edited only records its run times, so it never reverts the edit.

To avoid overwriting someone else's change, send the job's current updated_at (from GET /api/v1/jobs/{id}) in the body: the update is refused with 409 Conflict if the job was changed since. Without updated_at the last write wins.

Status When
400 Bad Request Invalid JSON, cron expression, database, retention, rpo_minutes, connection or storage target
404 Not Found No job {id}
409 Conflict updated_at was sent and the job was changed since
curl -X PUT http://localhost:8080/api/v1/jobs/job_shop_1727146800_3f9a1c2e \
  -H "X-API-Key: $MONGORESCUE_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"name":"shop hourly","cron_expression":"@hourly","database":"shop","connection_id":"conn_prod","retention_count":24}'

In the dashboard, clicking a job row (or Details in its ⋯ menu) opens the job's details: its schedule in words with the next three runs, retention, compression, encryption, state, and the last 20 runs with their success rate. Edit opens the job form prefilled, and Pause / Resume (also in the row's ⋯ menu) pauses the schedule, until resumed or until a date and time, or resumes it; a paused job shows a Paused badge. Stop current run stops the job's current run: its running backup and, for a job with several databases, every database still waiting. Edits and pauses send updated_at, so a job changed elsewhere in the meantime is reported instead of overwritten.

read_preference (with read_preference_tags), max_upload_mbps, num_parallel_collections and backup_window are optional too: an omitted field keeps the job's value, "read_preference": "" uses the connection's again and "backup_window": {} removes the window (see Read preferences, throttling and backup windows).

database_selection and parallelism are optional in an update: when database_selection is omitted, a database makes the job a single-database job of it, and an update with neither keeps the job's selection (see Jobs with several databases). restore_test.databases (rotate or all) chooses which databases of a multi-database run the restore test covers.

Jobs with several databases

A job backs up one database or several. Its database_selection says which:

{
  "mode": "pattern",
  "databases": ["billing"],
  "include": ["prod_*"],
  "exclude": ["prod_tmp?"],
  "auto_include_new": false
}
mode Backs up Fields
single One database, as jobs always did (database holds it too) databases: exactly one name
list The named databases databases: at least one name
all Every database of the connection except those exclude matches exclude, databases (always added), auto_include_new
pattern The databases an include pattern matches and no exclude pattern matches include (at least one), exclude, databases (always added), auto_include_new
  • admin, config and local are never backed up by list, all or pattern jobs, and naming one in a list is refused with 400.
  • Patterns use * (any run of characters, also none) and ? (exactly one character); every other character matches itself and matching is case-sensitive, like MongoDB database names. They follow the rules for database names (1 to 63 bytes, none of / \ . " $, a space or a control character, not starting with -), with * and ? as the only wildcards. A pattern that matches no database is allowed; the preview and the run report it as a warning.
  • The job's own collection filters (collections, exclude_collections) only apply to single jobs; a multi-database job that sends them is refused with 400. list and all jobs filter collections per database instead, see Collection filters per database.
  • parallelism (1 to 4, default 1) is how many databases a run backs up at the same time. Each database keeps its own run lock: a database another backup is running for (a manual backup, another job) waits until it finished, up to 30 minutes, and is recorded as failed in this run after that (the others still run).
  • Clients that predate selections keep working: a job created or updated with database and no database_selection is a single job of that database, an update that sends neither keeps the job's selection, and responses still carry database for single-database jobs ("" for the others). Every job stored by an earlier release was migrated to a single selection (migration 0013).

New databases. For all and pattern jobs the server records the databases the selection matched when the job was saved (and when its selection or connection changes) as known_databases (managed by the server, never taken from requests). With auto_include_new off (the default), a run backs up only the known databases plus the named ones; databases created later that match are not backed up but listed as new_databases in the run, in the run's notification ("3 new databases not included") and in the dashboard, which offers Add to job (it adds the name to databases). With auto_include_new on, a run backs them up too, adds them to known_databases and publishes a job.databases_added event the first time each one appears.

Runs. Every database of a run gets its own backup record and archive, exactly as a single-database backup: the same storage key layout (<db>/<yyyy>/<mm>/<id>.archive.gz), verification, manifest, log and cancellation, so restores, retention, pins and verification work unchanged. The records of one run share a run_id (GET /api/v1/backups?run_id=…), and GET /api/v1/jobs/{id}/runs lists the runs: {id, job_id, trigger, status, started_at, completed_at, duration_seconds, databases: [{database, backup_id, status, error}], new_databases, added_databases, warnings, error}. A run is ok when every database succeeded, partial when some failed, failed when none succeeded and cancelled when it was stopped without failures. A database named in a list (or in databases) that no longer exists is recorded as failed with "error": "database not found"; the others still run. A run whose connection cannot list its databases fails as a whole (error), and so does a run that matches no existing database. POST /api/v1/jobs/{id}/run of a multi-database job answers 202 at once with the run ({id, job_id, trigger, status: "running", started_at, databases: []}); its databases are resolved in the background and planning failures are recorded in the run (poll GET /api/v1/jobs/{id}/runs), while a run of the job still going answers 409. The run is stored after every database, so a crash keeps the outcome of the databases that finished: the next start rebuilds each database's status from its backup record (completed ones stay completed, the others fail as interrupted). Single-database jobs record a run too.

Notifications send one message per run, not one per database: the run's backup.succeeded, backup.failed (also for a partial run, whose message names the failed databases) or backup.cancelled event carries run_id and a run summary (status, databases, succeeded, failed, cancelled, failed_databases, new_databases); webhooks receive both fields. The per-database events still feed the metrics (per job and per database), and every run is counted in mongorescue_job_runs_total (metrics).

Stopping a run. POST /api/v1/jobs/{id}/cancel (operator) stops the job's current run: the running database and every database still waiting are cancelled ({job_id, run_ids, cancelled, backups}; 200 once they stopped, 202 while some still stop, 404 unknown job, 409 no run of it is active). Cancelling any one backup of a run (POST /api/v1/backups/{id}/cancel, MCP cancel_run, the desktop app's quit) stops the whole run as well. Databases already backed up keep their backups. A scheduled run that is still going when the next one is due is skipped; starting one on demand meanwhile answers 409. Retention and the restore tests of a scheduled run happen after its run slot is released, so they never delay the next run. The known databases a run records are merged into the job as it is stored when the run ends: when the job's selection or connection was changed meanwhile, only the databases it backed up that the new selection still matches are added, and a newer value is never overwritten.

Retention stays per job and database: the job's retention_days and retention_count apply to each of its databases separately, and so do the protections. The newest completed backup of every database is kept (so a database whose backup failed today keeps its last good one), and so is the newest verified one of every database. A scheduled run prunes only the databases it backed up successfully. GET /api/v1/jobs/{id}/retention/preview adds databases, the per-database breakdown (database, considered, delete, protected, kept, last_good).

Restore tests of a multi-database job test one database per run, taking turns in name order (restore_test.databases: "rotate", the default), or every database of the run ("all").

Collection filters per database

A list or all job can back up only some collections of some of its databases. An entry of databases may be an object with a filter instead of a name, and the filters are stored and returned in collection_filters, one per database, sorted by name:

{
  "mode": "list",
  "databases": ["a", {"name": "b", "exclude_collections": ["logs", "tmp"]}, {"name": "c", "collections": ["orders"]}]
}

is stored, and returned, as

{
  "mode": "list",
  "databases": ["a", "b", "c"],
  "collection_filters": [{"name": "b", "exclude_collections": ["logs", "tmp"]}, {"name": "c", "collections": ["orders"]}]
}
  • collection_filters may also be sent directly; a database must not have a filter both there and in its databases entry.
  • A database's filter has either collections or exclude_collections, never both (400).
  • Each database's filter works exactly like the collection filter of a single-database backup: collections backs up only those collections (several of them become exclusions of every other collection, and a collection that does not exist fails that database's backup), exclude_collections backs up everything else. Names follow the collection name rules (no $, no control characters), at most 1000 per list. With include_users_and_roles a collections filter becomes exclusions, as for one database.
  • * (any run of characters) and ? (one character) are wildcards, in every collection filter (one database or several, ad hoc or in a job): tmp_* excludes every collection starting with tmp_. A pattern is never taken for a collection name. The backup expands it against the database's collections when it runs: an exclude pattern that matches nothing excludes nothing, an include pattern that matches nothing fails that database's backup, and patterns never match system collections (system.*). Expanding needs the server's collection lister; a server without one refuses filters with patterns with 400 (collection filter cannot be applied), when the backup is requested or the job saved.
  • The backup record names the filter that was applied, patterns expanded: collections (the included collections), exclude_collections (the excluded ones) and filtered: true. Retrying a failed backup (POST /api/v1/backups/{id}/retry) reuses that record's own filter, so a database of a multi-database run keeps its filter.
  • A list job's filters must name databases of its databases, and an object entry of databases adds the database to the list. In an all job an object entry only attaches its filter: it does not add the database to databases (the databases always backed up) and does not change which databases the job matches or the known databases. Its filters must name databases the job backs up: not admin, config or local, and none its exclude patterns skip (400, "filter for an excluded database") unless they are also named in databases. A filter on a database that does not exist yet applies once it does.
  • pattern jobs have no per-database filters (400), and databases an all job discovers later are backed up whole until a filter is added for them.
  • The manifest, verification and restore tests of a filtered backup cover only the collections it backed up (the expanded filter on the record), as for a filtered single-database backup. A filtered backup counts towards the database's RPO; see production.md for what it does not cover.
  • Jobs stored before filters existed have no collection_filters and are unchanged; a databases array of names stays valid everywhere. The filters live in the job's JSON data, so no migration is needed.

Previewing a selection

GET /api/v1/jobs/{id}/databases/preview (read scope) resolves the job's selection against the live server exactly as its next run would, without changing anything:

{
  "job_id": "job_pattern_1727146800_3f9a1c2e",
  "selection": {"mode": "pattern", "include": ["prod_*"], "auto_include_new": false},
  "included": ["prod_eu", "prod_us"],
  "excluded": [{"name": "admin", "reason": "system"}, {"name": "prod_new", "reason": "new"}, {"name": "staging", "reason": "not_matched"}],
  "missing": [],
  "new_since_last_run": ["prod_new"],
  "warnings": []
}

Reasons are system, excluded (with the matching pattern), not_matched, not_selected, new and not_found. Query parameters preview another selection while the job is edited: mode, repeated databases, include and exclude, auto_include_new, connection_id and storage_target_id (the target whose largest archive the warnings compare each database's expected size with, as backups do); a changed selection is previewed as saving it would record it (everything it matches counts as known). GET /api/v1/jobs/databases/preview previews a selection for a job that does not exist yet (connection_id and mode required). Answers: 400 invalid selection, 404 unknown job or connection, 502 the databases cannot be listed (a list selection is then previewed as named, with a warning).

curl -X POST http://localhost:8080/api/v1/jobs \
  -H "X-API-Key: $MONGORESCUE_ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"name":"all prod","cron_expression":"0 2 * * *","connection_id":"conn_prod",
       "database_selection":{"mode":"pattern","include":["prod_*"],"exclude":["prod_tmp*"]},"parallelism":2}'

Backing up several databases now

POST /api/v1/backups with databases instead of database backs up several databases of one connection in one run, as the dashboard's Back up now does with Selected or All:

curl -s -X POST http://localhost:8080/api/v1/backups \
  -H "X-API-Key: $MONGORESCUE_KEY" -H "Content-Type: application/json" \
  -d '{"connection_id":"conn_prod","databases":["shop","crm","billing"],"parallelism":2}'
  • databases names 1 to 200 databases, each a valid database name, none twice. It cannot be combined with database (400).
  • parallelism is how many of them are backed up at once: 0 to 4, where 0 or omitted means 1, as for jobs. Every database keeps its own run lock.
  • An entry of databases is a name or an object {"name", "collections" | "exclude_collections"} with a collection filter of its own for that database, such as ["shop", {"name": "crm", "exclude_collections": ["logs", "tmp"]}, {"name": "billing", "collections": ["invoices"]}]. Entries without a filter behave exactly as before.
  • The top-level collections and exclude_collections apply only to a run of one database (and not together with a filter in its entry); with several databases they are refused with 400; give each database its own filter instead.
  • include_users_and_roles applies to each database, as for a job with several databases; admin is backed up without it (its dumps already contain every user and role). A run of admin alone with the option is refused with 400.
  • storage_target_id and gzip apply to every database.

Each database gets its own backup record and archive, with "trigger": "manual" (mcp from MCP) and the same run_id. The databases are backed up exactly like those of a job with several databases: they show up as queued at once, cancelling any one of them (POST /api/v1/backups/{id}/cancel) stops the whole run, and notifications get one summary for the run instead of one per database. The run belongs to no job, so it is not listed under GET /api/v1/jobs/{id}/runs; list its backups with GET /api/v1/backups?run_id=....

The response is 202 with the run:

{
  "run_id": "run_20261005_101500_3f9a1c2e",
  "backups": [
    {"id": "bkp_shop_20261005_101500_a1b2c3d4", "database": "shop", "status": "in_progress", "run_id": "run_20261005_101500_3f9a1c2e", "...": "..."},
    {"id": "bkp_billing_20261005_101500_e5f6a7b8", "database": "billing", "status": "in_progress", "run_id": "run_20261005_101500_3f9a1c2e", "...": "..."}
  ],
  "busy": [
    {"database": "crm", "busy": true, "error": "another backup of database crm is already running"}
  ]
}

backups are the in-progress records, in the order they run. A database another backup is running when the request arrives is not backed up: it comes back in busy (an empty array when none is) and the others start. A skipped database is neither a success nor a failure: it does not change the run's outcome (a run whose other databases all succeed is ok), its failure counts or metrics, and the summary notification lists it as "Skipped (already running)" (run.skipped_databases in webhooks). When every database is busy the request is refused with 409. A request with database is answered exactly as before: the one backup record, without run_id; it ignores parallelism, as it always did.

Retrying a failed backup

POST /api/v1/backups/{id}/retry (operator scope, like starting a backup) starts a new manual backup with the parameters of the failed backup {id}: the same connection, database, collections and storage target (the default target for records written without one). When the backup belongs to a job that still exists, the job's exclude_collections and gzip are used; otherwise compression follows the failed backup's storage key. Encryption follows the current settings. The retry is started exactly like POST /api/v1/backups (same concurrency limit per database, events and notifications) and the response is the new in-progress record with "trigger": "manual" and "retry_of": "{id}".

The failed record is never changed or deleted by a retry: its status, error_message, started_at and completed_at stay in the list, and every attempt links to the one it retries through retry_of (omitted for backups that are not retries). A failed retry can be retried again, forming a chain.

Status When
404 Not Found No backup {id}
409 Conflict The backup has not failed (only failed backups can be retried), or a backup of the same database is already running
422 Unprocessable Entity The backup's connection or storage target no longer exists, or the backup has no connection recorded
curl -X POST http://localhost:8080/api/v1/backups/bkp_shop_20260924_030000_3f9a1c2e/retry \
  -H "X-API-Key: $MONGORESCUE_OPERATOR_KEY"

In the dashboard, a failed backup that has not been retried yet shows a Retry button, and Details opens the full error message (including mongodump's stderr, which the server stores in the redacted error message), the absolute start and end times, the duration, the trigger, connection, database, storage target and the retry chain.

Listing backups and restores

GET /api/v1/backups and GET /api/v1/restores take optional query parameters. All given filters must match; empty values are ignored.

Parameter Backups Restores Meaning
id yes yes Records with exactly these IDs, comma-separated (at most 200)
status pending, in_progress, completed, failed, cancelled, pruned, missing, deleted, purged pending, in_progress, completed, failed, cancelled Records in this state. Without status (and without id), deleted and purged backups are hidden
deleted true Only deleted backups waiting for their purge (like status=deleted; the dashboard's Deleted view)
database Backed-up database Target database Exact, case-sensitive match
connection_id yes Backups taken from this connection
job_id yes Backups of this scheduled job
trigger scheduled, on_demand, manual, mcp How the backup was started (records older than triggers count as scheduled when they belong to a job, else manual)
retry_of yes Retries of this backup
run_id yes Backups of this job run (one per database)
backup_id yes Restores of this backup
from, to yes yes started_at range in RFC 3339 (2026-10-01T00:00:00Z); from is inclusive, to exclusive
q ID or database ID, source or target database Substring, ASCII case-insensitive; % and _ are literal (at most 256 characters)
sort started_at, duration, size, database, status started_at, duration, database (target), status A column for ascending order, - and a column for descending (sort=-size); ties keep the newest first. desc (newest first, the default) and asc still sort by start time
limit yes yes Page size, 1 to 200
offset yes yes Matches to skip (default 0)

Without limit the response is unchanged from earlier releases: data is the array of every matching record. With limit, meta describes the page:

{
  "success": true,
  "data": [{"id": "bkp_shop_20260924_030000_3f9a1c2e", "status": "failed", "...": "...",
            "retried_by": {"id": "bkp_shop_20260924_031000_9b1d2c3e", "started_at": "2026-09-24T03:10:00Z"}}],
  "meta": {"total": 312, "limit": 25, "offset": 0}
}

Breaking (API) in 0.9.0: these parameters are now validated. Earlier releases ignored every query parameter of GET /api/v1/restores and every one of GET /api/v1/backups except database and job_id; a malformed status, trigger, from, to, sort, limit, offset, id (more than 200 IDs) or an over-long q, database, connection_id, job_id, retry_of, backup_id or ID now answers 400 instead of being ignored. Unknown parameter names are still ignored.

total counts every match, so a client can show "1–25 of 312"; an offset past the end returns an empty data with the same total. A malformed or out-of-range value (limit=0, limit=500, sort=random, sort=id, sort=size on restores, from=yesterday, to before from, an unknown status or trigger) answers 400 with a message naming the parameter. Backup items carry retried_by, the newest backup whose retry_of is this one, when there is one; it is computed for each listed row, so retry links work on any page.

curl "http://localhost:8080/api/v1/backups?status=failed&database=shop&from=2026-09-01T00:00:00Z&limit=25&offset=25" \
  -H "X-API-Key: $MONGORESCUE_READ_KEY"

The sort columns are a fixed list mapped to constant SQL; anything else, a different case or a column the resource has not answers 400 listing the accepted columns.

curl "http://localhost:8080/api/v1/backups?status=completed&sort=-size&limit=25" -H "X-API-Key: $MONGORESCUE_READ_KEY"

GET /api/v1/stats covers every record regardless of any list filter and is computed with SQL aggregates (it does not read every record): total_backups, completed_backups, failed_backups, failed_backups_24h, total_bytes, active_backups and active_restores (pending or in progress), total_restores, active_jobs, last_backup ({id, database, job_id, status, started_at, error_message} of the newest backup) and job_last_backups (each job ID mapped to its newest backup), plus the default storage target. The figures are computed once for every caller asking with the same connection access, and again as soon as a job, backup, run or restore is written (at most 10 seconds later otherwise). When some figures cannot be read (they then count as zero), the response adds degraded: true and a degraded_reason. For callers with the effective admin scope (administrators and their admin keys) it also lists corrupt_records: stored rows that every list skips because they cannot be read, as {table, id, error} (the error never quotes stored data). See troubleshooting.md.

Listing jobs

GET /api/v1/jobs returns one page of the jobs in the order of sort and order (by name, ascending, by default), ties broken by ID, so the order is stable. Optional filters keep only the matching jobs, with the same matcher as the jobs bulk filter, so selecting "all matching" there selects exactly the listed jobs. All given filters must match.

Changed (API) after 0.26.0: the list is paged by cursor. Without limit the answer is the first 100 jobs (earlier releases returned every job), with meta.next when more follow. Clients that read every job follow meta.next until it is absent, as the CLI, the MCP server and the dashboard do.

Parameter Meaning
limit Page size, 1 to 500 (default 100)
sort name (default), next_run (paused jobs and jobs without a next run last, in both directions), last_status (the outcome of the last run: failed, partial, cancelled, in_progress, completed, within each the newest first by when that backup or run started; jobs that never ran last) or created_at
order asc (default) or desc
after The meta.next of the previous page in the same sort and order (opaque; anything else, also a cursor of another order: 400). Without it the list starts at the first job
{
  "success": true,
  "data": [{"id": "job_a1b2c3", "name": "nightly shop", "...": "..."}],
  "meta": {"total": 10000, "limit": 100, "next": "ajE6eyJzIjoibmFtZSIsImsiOnsiayI6Im5pZ2h0bHkgc2hvcCIsImlkIjoiam9iX2ExYjJjMyJ9fQ"}
}

total counts every matching job; next is present while jobs follow the page and is the after of the next one. Paging is by key (the order, its direction and the sort value and ID of the page's last job), not by position: a job created or deleted between two requests never makes the next page repeat or skip another job, and a cursor stays valid when its job is deleted. A cursor works with any filter, but pages of one filter are only consistent with that filter's cursors. total leaves out stored jobs that cannot be read, as the pages do. Every filter and order is answered in SQL from the jobs table: the pages from the indexes of the order (with and without the connection and state filters), q from a lower-cased search text of each job and database, schedule and last_status from columns kept on each job, which every job write updates (last_status follows every backup and run; an all or pattern job is matched against its patterns). A caller limited to some connections, one of whose jobs has its newest backup on another connection (a job that moved), gets last_status filters and the last_status order computed from the backups it may see instead.

curl "http://localhost:8080/api/v1/jobs?enabled=true&limit=500" -H "X-API-Key: $MONGORESCUE_READ_KEY"
curl "http://localhost:8080/api/v1/jobs?enabled=true&limit=500&after=$NEXT" -H "X-API-Key: $MONGORESCUE_READ_KEY"

Filters:

Parameter Meaning
q ID, name, database or a multi-database job's selection contains this text (case-insensitive, at most 256 characters)
enabled true scheduled jobs, false paused ones (anything else: 400)
connection_id Jobs of this connection
database Jobs covering this database: a single-database job's database, or one a multi-database job lists, knows or matches with a pattern
schedule Frequency of the cron expression: hourly (more than once a day, also @every under 24 h), daily, weekly (some weekdays), monthly (some days of the month) or other
last_status Outcome of the job's last run: completed, failed, partial, in_progress, cancelled or never. A single-database job's is its newest backup's (a pruned or missing archive still counts as completed); a multi-database job's is its newest run's (ok, partial, failed, cancelled, running), never one database's backup

An unknown schedule or last_status value, an over-long text, a limit outside 1-500, an unknown sort or order, or an after that is not a meta.next of the same order answers 400.

Overview history and schedule preview

GET /api/v1/stats/history (read scope) feeds the dashboard's Overview tab. Like /api/v1/stats it is computed with SQL aggregates and never reads every record:

  • daily: one entry per day of the window, oldest first, empty days included: {date, completed, failed, cancelled, bytes, stored_bytes}. days (1-366, default 30) counts back from today. tz (an IANA zone such as Europe/Istanbul) makes the days start at your midnights, also across daylight saving changes. When tz is missing or unknown to the server, tz_offset (minutes east of UTC, -840 to 840, e.g. 180 for UTC+3) is used as a fixed offset instead. time_zone in the answer names the zone used. bytes sums the day's completed backups that are still on record, and stored_bytes is the running total since stored_bytes_before (completed backups on record that started earlier). Both describe the archives kept today, so backups that retention has deleted no longer count.
  • jobs: each existing job with backups or an enabled schedule mapped to {runs, last_success_at, interval_seconds}: its 12 newest backups, oldest first ({id, status, started_at, duration_seconds}), when its newest completed backup started, and the gap between its next two scheduled runs.
  • For a job with several databases, runs lists its 12 newest runs, each once however many databases it covered ({id, status, run_status, databases, succeeded, started_at, duration_seconds}; status is completed for ok, failed for partial and failed runs), and last_success_at is its stalest database's newest success (empty while one never succeeded), with stalest_database and the per-database databases. The Overview's attention list then reads "job X: db Y has no successful backup since …" and flags a partial last run.
  • upcoming: the enabled jobs' scheduled runs in the next 24 hours, in order ({job_id, at}), at most 96 per job and 500 in total (upcoming_truncated says the caps cut the list).
  • verification_issues: the 20 newest completed backups of the window whose verification failed ({id, job_id, database, started_at, verification}, mismatch or error), and verification_issues_total for the window.

Every query is answered from an index on backups, never by scanning the table, and record JSON is read only for the rows the index selected (a job's newest runs, the window's completed backups). The per-day outcomes and the stored size read covering indexes only; the runs and last successes of jobs with several databases are read in one bounded query per 500 jobs. The answer is computed once for every caller asking with the same days, zone and connection access within 10 seconds (also when they ask at the same moment), and computed again earlier after a backup, job run, verification, retention, bulk action or job change, so generated_at may be a few seconds old. - server_time_zone: {name, offset_minutes}, the time zone cron expressions are evaluated in.

GET /api/v1/schedule/preview?cron=0%202%20*%20*%20*&n=3 (read scope) parses a cron expression with the scheduler's own parser and returns {valid, error, next_runs, server_time_zone, default_rpo_minutes}: the expression's hours are read in the server's time zone, and next_runs are given in UTC. default_rpo_minutes is the recovery point objective a job with this schedule has when it sets none. An expression the scheduler rejects is answered with 200 and "valid": false plus the reason; a missing or longer than 256 characters cron, or n outside 1-10, is a 400. The dashboard's cron builder uses it for its next runs line and the job form's RPO hint.

Health

GET /api/v1/health (public) answers 200 with {status: "healthy", version, time, scheduler, scheduler_last_tick}. scheduler is ok while the scheduler records its liveness tick (every 30 seconds) and not_started before it starts; scheduler_last_tick is the time of the last tick. When the last tick is older than three intervals (90 seconds: the scheduler is deadlocked or stopped; the tick runs on a monotonic timer, so wall clock steps do not count, see monitoring.md), the answer is 503 with success: false and scheduler: "stale" (status keeps its value "healthy"; read the HTTP status or scheduler), so the Docker HEALTHCHECK, a load balancer or an uptime monitor see the failure. While new runs are refused (a graceful shutdown waiting for running backups, or the desktop app waiting before it quits) the answer adds draining: true and stays 200. The fields of earlier releases are unchanged. The tick time is also exported as mongorescue_scheduler_last_tick_timestamp_seconds (metrics).

In active-passive HA mode the answer adds role (active or standby) and ha: {role, cluster, epoch, holder, lease_healthy}, where holder names the instance holding the lease as last seen. A standby answers 200 with scheduler: "not_started". It serves no data and accepts no sign-in: every other API request, signing in and out, single sign-on and /mcp answer 503 with a Retry-After header and the error this instance is the standby of HA cluster …; it serves no data and accepts no sign-ins: use the active instance (data: {role, cluster}). GET /metrics on a standby needs no credentials and exposes only the process, Go runtime, build and HA series. While an instance changes role (a takeover or a step-down) or starts, the health check answers 200 with role: "switching" (ha.role too), /metrics serves the standby series, and every other request answers 503 with Retry-After: 1 and the error this instance of HA cluster … is changing role; retry in a second (data: {role, cluster}); the port stays open (details).

Job heartbeats

A job's optional heartbeat_url (POST /api/v1/jobs, PUT /api/v1/jobs/{id}) is pinged at <url>/start when a run starts, <url> when it succeeds and <url>/fail when it fails or is partial; a run cancelled by a user or interrupted by a shutdown sends nothing after its start (monitoring.md). It must be an http or https URL without user info (else 400), is stored encrypted and is returned only up to its host ("https://hc-ping.com/******") by every endpoint and MCP tool that returns jobs. Sending the masked value (or "******") back keeps the stored URL, "" removes it, and a masked value with another host is a 400. PUT /api/v1/jobs/{id} keeps omitted fields, heartbeat_url included. POST /api/v1/jobs with an existing id is an upsert that replaces every field of the job: a body without heartbeat_url clears it (send the masked value back to keep it). A stored URL that can no longer be decrypted (a damaged or swapped ciphertext) does not stop the job: it runs without its heartbeat, and the URL is listed among the unreadable records (jobs.heartbeat_url) until it is entered again.

Read preferences, throttling and backup windows

These fields keep large backups off a busy primary and within the hours you choose; production.md explains when to use them.

Field On Values
read_preference connection, job primary, primaryPreferred, secondary, secondaryPreferred, nearest, or "": a job's empty value uses its connection's, a connection's the URI's own
read_preference_tags connection, job Tag sets tried in order, [{"dc": "east"}, {}] ({} matches any member); at most 10 sets of 10 tags, no , or : in names and values, not with primary
max_concurrent_backups connection Backups that may read from the connection at once (0 = unlimited, at most 64); further ones wait with phase: "waiting"
max_upload_mbps job Upload cap in megabits per second (0 uses the general.max_upload_mbps setting, at most 100000)
num_parallel_collections job mongodump --numParallelCollections, 1 to 16 (0 = mongodump's default of 4)
backup_window job {"timezone": "Europe/Istanbul", "days": ["sat", "sun"], "start": "22:00", "end": "02:00", "cancel_at_window_end": false}: timezone an IANA name ("" = UTC), days the days the window opens (mon to sun, none = every day), start HH:MM, end HH:MM or 24:00 and different from start (not after it: the next day)

Invalid values answer 400 naming the field. The read preference is added to the connection string passed to mongodump through its private config file (never --uri) and to the manifest capture. Backup records gain read_preference (as used, with its tags) and source_member (host, state, set_name: the member hello reported under that preference before the dump started). With secondary, a backup fails before mongodump starts when the selected member is not a secondary (read preference secondary needs a replica set secondary, but the connection reaches only ...); the other modes go on and record what they found.

A scheduled run outside its job's window does not start, and it is not a failure. Consecutive skips with no run in between share one job run with "status": "skipped", "skip_reason": "outside window" and skipped_runs (how many activations it stands for, from started_at to completed_at), so an hourly job with a four-hour window records one skip a day; it is listed by GET /api/v1/jobs/{id}/runs, passed over as the job's last status and left out of the overview history before its limit. Only the first skip of each such gap publishes backup.skipped (status: "skipped", detail: "skipped (outside window)"), and only to notification rules that name it: it is opt-in. With cancel_at_window_end, a scheduled run still going when the window closes is cancelled by system with the reason "the backup window ended". POST /api/v1/jobs/{id}/run and the MCP run_job tool ignore the window. GET /api/v1/jobs/{id} adds window_open for a job with a window and lists in next_runs only the runs the window allows; its effective_rpo_minutes uses the largest gap between them.

Copies on further storage targets

Jobs (POST /api/v1/jobs, PUT /api/v1/jobs/{id}) and manual backups (POST /api/v1/backups) take copy_targets (up to 3 storage target IDs other than the primary) and copy_mode (async, the default, or sync). An update without copy_targets keeps them, and [] removes them. An unknown or invisible target is 404/400, the primary or more than three targets are 400. Every backup record lists its copies in copies (target_id, target_name, storage_key, status, sha256_ok, version_id, retain_until, error, copied_at, attempts, next_attempt_at, verification, verified_at), and copy_mode. A retried backup keeps the copy targets of the failed one. POST /api/v1/backups/{id}/copies/retry (admin) queues every failed copy of a completed backup again with its attempts reset, including copies the queue gave up on (backup.copy_exhausted). It answers the updated backup, or 400 when the backup is not completed or has no failed copy. MCP's start_backup takes the same fields, and restore_backup takes source_target_id. See configuration.md for the copy queue, retries and the purge.

curl -s -X PUT http://localhost:8080/api/v1/jobs/job_shop \
  -H "Authorization: Bearer $MONGORESCUE_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "shop", "database": "shop", "connection_id": "conn_prod", "copy_targets": ["stg_offsite"], "copy_mode": "async"}'

Cross-region disaster recovery

  • Locked copies. Jobs take require_locked_copies (boolean). With it, a job whose copy target has no S3 Object Lock is refused with 400 ("the job requires locked copies"). POST /api/v1/jobs without the field takes security.require_locked_copies; PUT without it keeps the job's value. While security.require_locked_copies is on, no job can opt out: a new job with false, or a job turning it off, is 400. With the setting off, a job turning it off keeps it on and answers pending_locked_copies (or, with the two-person rule, locked_copies_approval first): it is turned off after the grace period (pending change kind job_locked_copies). Turning security.require_locked_copies off through PUT /api/v1/settings becomes a pending change of kind disable_locked_copies (after the grace period), or, with the two-person rule, an approval request (disable_locked_copies) first.
  • Regions. Storage targets have region (at most 64 printable characters) and region_detected. POST/PUT /api/v1/storage-targets take region (an operator's label); a PUT without it keeps the stored one, "" clears it. An AWS S3 target (no endpoint) without a region (here or in s3.region, or s3.region is auto) takes its bucket's GetBucketLocation when the key may read it (region_detected: true), detected again when the endpoint, bucket or s3.region changes. On other endpoints only a label counts as a region.
  • DR drills. A job's restore_test takes source_target_id: one of the job's copy_targets (else 400). Its restore tests then read the copy there; a test result has source_target_id and source_target_name. A scheduled drill is skipped while no backup has a completed copy on that target; a manual one (POST /api/v1/jobs/{id}/restore-test) is refused like a job without a backup.
  • PITR. Streams take copy_targets (see below), and a point-in-time restore takes source_target_id.

Readiness rows of jobs with copy targets carry dr (see recovery readiness).

Recovery point objectives

A job's rpo_minutes (POST /api/v1/jobs, PUT /api/v1/jobs/{id}, returned by GET /api/v1/jobs/{id} and the MCP get_job tool) is its recovery point objective: how old, in minutes, the newest successful backup of each of its databases may be. 0 or absent means the default from the schedule: twice its interval plus an hour, at least six hours. The interval is the largest gap between consecutive runs over 8 days (a year for monthly and rarer schedules), read in the server's time zone like the scheduler does, so it does not change with the day of the week: @hourly gets 6 hours, @every 6h 13 hours, @daily 49 hours, 0 9,17 * * * 33 hours (the 16-hour night), 0 9 * * 1-5 145 hours (Friday to Monday) and @monthly about 63 days; a schedule that cannot be read counts as daily. A job with a backup window counts only the runs the window allows. When set it must be between 15 (15 minutes) and 129600 (90 days), else 400. An update without rpo_minutes keeps it; 0 restores the default. GET /api/v1/jobs/{id} adds effective_rpo_minutes (the objective that applies) and rpo_default.

Every 5 minutes, right after a job's backup finished and right after a job is created, edited, paused, resumed or deleted, the server checks every database of every enabled job: the age of its newest successful backup against the job's objective. A database without one counts from when it joined the job: for a database a run included automatically later, the time it was first counted among the job's known databases (job_database_joins, migration 0018), else the job's last update or creation, so a database added to an old job is not reported at once. When the objective is missed, job.rpo_missed is published once (job_id, database, the age and the objective in detail); when it heals, job.rpo_recovered (its detail says whether a recent enough backup came in or the objective was changed). Breaches are kept in the metadata database (rpo_breaches, migration 0018), so a restart does not report a breach again; an event the event bus drops (a full queue) is not recorded, so the next check publishes it again. Pausing or deleting the job, or removing the database from its selection, drops the breach without an event; resuming a job that is still past its objective reports it again right away. Both events are selectable in notification rules, and the age and objective are exported as metrics.

Recovery readiness

GET /api/v1/readiness (read scope) answers "could we recover each database, and how fast?". The report is computed once for every caller asking with the same connection access and shared until a job, backup, run, restore, restore test or RPO breach is written, an event is sent or 5 seconds pass, so generated_at may be a few seconds old. It returns {generated_at, keys_escrowed, key_wrapping, summary: {ok, warn, fail}, rows, streams, storage_hints} (key_wrapping: {active, provider}, whether secret.key is wrapped by a KMS; storage_hints: the hints of every storage target with target_id and target_name; they never change a row's status) with one row per connection and database a job backs up (paused jobs included), failing rows first:

Field Meaning
connection_id, connection_name, database The database
jobs [{id, name, enabled, target_seconds, default, age_seconds, no_backup, met, breached_since}]: each job backing it up, with its objective, the age of its newest successful backup of the database and whether the objective is met
last_good_backup {id, at, job_id}: the newest completed backup by any of them; a filtered one adds filtered: true and its collections or exclude_collections (it still counts towards the RPO)
last_verified_backup The newest completed backup whose archive passed verification
last_restore_test {id, job_id, at, status, duration_seconds}: the newest restore test of the database (ok, mismatch or error)
rpo {target_seconds, age_seconds, met}: the strictest objective of the enabled jobs, the age of last_good_backup, and whether every enabled job meets its objective (met is absent when every job is paused)
rto {seconds, source, id, measured_at}: the estimated recovery time, the duration of the newest successful restore test (source: "restore_test"), else of the newest completed real restore of the whole database ("restore"; dry runs and selective restores do not count); absent when neither exists
keys_escrowed, encrypted Whether a recovery kit was downloaded for the current secret.key, encryption keys and storage targets, and whether last_good_backup is encrypted
copies, copy_targets How many copies of last_good_backup are done, of how many planned (copy_targets is absent without copy targets)
dr Only when a job of the row has copy targets: {level, cross_region, separate_credentials, locked_copy, primary_regions, copy_regions, last_drill, last_good_drill, drill_stale, drill_cross_region}. level is cross_region when a copy target in another region has Object Lock and a DR drill from such a target passed in the last 30 days, cross_region_unproven when a copy target is in another region but that is not proven, same_region otherwise. last_drill and last_good_drill are restore test references with source_target_id
status, reasons fail for rpo_missed, restore_test_failed (the newest test failed) or verification_failed (the newest good backup failed verification); warn for no_backup, paused, not_verified, no_restore_test, keys_not_escrowed (an encrypted backup without an escrowed key), copy_missing (copies of the newest good backup still not done six hours after it finished), dr_same_region (no copy target in a known region other than the primary's), dr_same_credentials (every copy target reachable with the primary's access key, default credentials or Cloudflare account) dr_drill_stale (no DR drill passed in the last 30 days) or dr_unlocked_copy (a job requiring locked copies, itself or through security.require_locked_copies, has a copy target without Object Lock; dr.unlocked_copies lists them); ok otherwise

streams lists the PITR streams with their state, status and reasons; rto estimates a whole-instance point-in-time restore to the newest point of the stream's open window: {seconds, base_bytes, oplog_bytes, oplog_entries, source, samples, base_bytes_per_second, replay_bytes_per_second, replay_entries_per_second, replay_model}, where source is measured (the stream's five newest completed restores and chain tests: the base at their rate, the replay from a fit of their replay times to entries and bytes, replay_model entries_and_bytes, entries or bytes), chain_test or default (the replay takes the longer of its bytes at 4 MiB/s and its entries at 2,000 per second) (details); it is absent without an open window.

The report reads each kind of record (jobs, newest and verified backups, restore tests, restores, breaches, join times) with one query, whatever the number of jobs. The dashboard's Overview shows it as the Recovery readiness table, and its Attention needed list reports the jobs whose objective is missed from the same data.

Point-in-time recovery streams

A PITR stream collects the oplog of one replica set connection into encrypted chunks and takes base backups of the whole instance. Reading streams needs the read scope; creating, changing and deleting them needs admin; POST /api/v1/pitr/streams/{id}/base needs operator. The answers carry chunk metadata only, never oplog contents.

POST /api/v1/pitr/streams and PATCH /api/v1/pitr/streams/{id} take:

Field Default Meaning
connection_id (required) The replica set connection; one stream per connection, and it cannot change
target_id the default target Storage target of chunks and base backups
enabled true Run the collector
base_cron 0 2 * * * Schedule of base backups (five fields or a descriptor such as @daily)
base_keep_count, base_keep_days 7, 14 Keep this many bases, or bases this many days old; pinned bases and the newest eligible one are always kept
oplog_max_days 0 (unset) Delete chunks older than this many days even when that shortens the window (privacy)
chunk_seconds 60 Chunk interval, 15-900 seconds
base_on_gap true Take a base backup as soon as a gap breaks the chain
read_preference secondaryPreferred Read preference of oplog reads
chain_test_cron empty (off) Schedule of chain tests; "" turns them off
chain_test_connection_id empty (the stream's connection) Connection chain tests restore into; another server spares production the load of a full restore
copy_targets none Up to 3 further storage targets the stream's oplog chunks and base backups are copied to (not target_id); [] removes them. While security.require_locked_copies is on, each must have S3 Object Lock (else 400), and a base backup whose copy target has none is refused

With copy_targets, base backups get copies like a job's backups, and the copy queue copies every live oplog chunk to each copy target, checks it against the chunk's SHA-256, and purges the copy once the chunk is purged (and the copy's Object Lock ended). A point-in-time restore (POST /api/v1/restores with pitr) takes source_target_id: a copy target where the base and every chunk of the range have a completed copy (a copy chain), else 400 naming the first gap. Without it the restore reads the primary, and reads the first complete copy chain instead when the primary base or a chunk is missing or cannot be read; the restore record then has source_target_id and source_fallback.

Creating or enabling a stream is refused with 400 unless the connection is a replica set, its user may read local.oplog.rs (backup, read on local or a custom role) and backup encryption is on with age keys: the oplog keeps deleted data, so PITR data is always encrypted.

A database row's rpo.source is pitr when the stream's durable lag is the better recovery point: the oplog up to it is captured and can be restored to a point in time.

GET /api/v1/pitr/streams/{id} returns {stream, state, running, live, lag_seconds, headroom_seconds, durable_rpo_seconds, windows, chains, bases, chain_breaks}. windows lists [{chain_id, open, start, end, start_time, end_time, bases}], one per chain with an eligible base: a gap or a divergence splits them. state.status is running, failed (with last_error) or stopped.

DELETE refuses an enabled stream (409). For a disabled stream with chunks it ends the open chain, deletes every chunk with the delete grace period and answers 409 until the purge removed them; delete it again then. Base backups stay as backups.

POST /api/v1/pitr/streams/{id}/chain-test (admin) starts a chain test now and answers 202 with its restore (pitr.chain_test: true); 409 when the stream has no two eligible bases in one window, the newer with a manifest, or when its preflight fails (with the checks, like a restore).

Point-in-time restores

POST /api/v1/restore and POST /api/v1/restores/preflight restore to a point in time when the body has pitr instead of backup_id:

{"pitr": {"stream_id": "str_…", "at": "2026-10-05T14:30:00Z"}, "databases": ["shop"]}
{"pitr": {"stream_id": "conn_…", "ts": {"t": 1791210600, "i": 3}}}
Field Meaning
pitr.stream_id The PITR stream, or the ID of its connection
pitr.at RFC 3339 time: every write up to and including that second (the primary's clock) is restored; the replay stops at (at+1):0
pitr.ts Exact oplog position instead of at: every write before it is restored
databases Restore only these databases (default: every database but admin, config and local, which are never restored)
target_connection_id Another connection to restore into (default: the stream's)
force Start although a preflight check failed
pitr.in_place Replace the original databases with the restored state (in place); admin only
pitr.confirm_in_place With in_place: the databases it replaces, comma-separated, exactly those of databases
pitr.keep_clone With in_place: keep the clones after a successful swap

Every restored database goes into a new database <db>_rescue_<YYYYMMDD_HHMMSS>_<id>. Point-in-time restores and their preflight need the operator scope (403 for read keys); a restore into another connection than the stream's (target_connection_id) needs admin, like for backups. The in-place options of a backup restore (safe_clone: false, confirm_in_place, target_database) are refused with 400 (use pitr.in_place), as are backup_id, dry_run, drop_target, selected_collections and restore_users_and_roles, and databases without pitr. A target outside every window, behind a chain break or a chunk that failed verification is refused with 422; a missing key for the encryption of the base or a chunk with 422 too (ErrKeyRequired); a server without point-in-time support answers 503.

The restore record has pitr: {stream_id, chain_id, base_id, target_time, limit, chunks, oplog_bytes, base_bytes, databases, clone_suffix, clones, ops_replayed, ops_applied, ops_unverified, chain_test, base_seconds, replay_seconds}: the plan chosen, the clone databases (recorded before they are written; clean-up drops exactly these), the operations the oplog filter wrote and mongorestore applied, and how long the base and the replay took (set on a completed restore; they give the measured rates). A mismatch of the two fails the restore and keeps the clones; ops_unverified: true says mongorestore printed no count. The clone suffix is _rescue_<YYYYMMDD_HHMMSS>_<id> with a random id. A database in databases the base does not hold, or a MongoRescue clone (_rescue_ in its name), is refused with 400. source_database is * (or the selected databases) and target_database is *<clone_suffix> (or the clones).

The preflight checks pitr_chain, connection, server_version, target_database, privileges (the replay roles), disk_space (base plus oplog) and tools_version (mongorestore 100.12 or newer); a plan that does not reach the target fails pitr_chain instead of the call. Its result adds pitr: {base_id, base_started_at, base_consistent_at, base_bytes, oplog_bytes, chunks, unverified_chunks, target_time, limit, clone_suffix, estimated_seconds, estimate_from}; estimate_from is measured when the stream's recent restores and chain tests gave the rates, chain_test when only a chain test of an earlier release did, default otherwise.

In-place point-in-time restores

{"pitr": {"stream_id": "str_…", "at": "2026-10-05T14:30:00Z", "in_place": true, "confirm_in_place": "shop,crm"}, "databases": ["shop", "crm"]}

"in_place": true makes the restore end with the original databases holding their state at the target (procedure, downtime, space and recovery): it restores into clones and verifies them as above, takes a safety backup of each original (a backup with "label": "pre-pitr-in-place" and restore_id), pauses the stream (its chain ends), copies each clone over its original (mongodump | mongorestore --drop, then drops the collections the clone does not have), drops the clones unless keep_clone, and enables the stream again with a new chain and a new base backup.

  • It needs the admin scope (403 otherwise, also for the preflight), a non-empty databases (400) and confirm_in_place naming exactly those databases, in any order (400, in-place point-in-time restore not confirmed). It is refused with 400 with target_connection_id naming another connection than the stream's, with 409 while a backup or restore of one of the databases, a base backup of the connection, or a whole-instance restore into it, runs, and with 409 (in-place PITR is not available in HA mode yet, also for the preflight) while active-passive high availability is on.
  • With the two-person rule on it answers 202 with approval_required and an approval request of action pitr_in_place; the approval starts the same request.
  • MCP never sends it: pitr_restore always restores into safe clones.
  • The record has in_place: true and pitr.in_place: {databases, stage, safety_backup_ids, stream_was_enabled, stream_paused, stream_resumed, swap_started, swapped, keep_clone, new_base_id}; stage is restoring, safety_backup, pausing, swapping, resuming or done, and the run's phase is safety_backup and swapping during those steps. Once the clones are complete the run can no longer be cancelled.
  • A failure before the swap leaves the originals untouched and drops the clones. A failure during the swap keeps the clones and the safety backups and names them, and which databases were swapped, in error_message.
  • The preflight adds the check in_place (fails while one of those runs is going) and runs the connection's post-restore commands against the clones before the swap.
  • Events: security.destructive_action (action: "pitr_in_place") when it starts, the usual restore.* and restore.in_place_pitr (status: "succeeded" or "failed", databases, safety_backup_ids) when it ends.

Bulk actions

POST /api/v1/backups/bulk, POST /api/v1/restores/bulk and POST /api/v1/jobs/bulk run one action on many records. They share one body:

{"action": "delete", "filter": {"status": "failed", "database": "shop", "from": "2026-09-01T00:00:00Z"}, "dry_run": true}
{"action": "delete", "ids": ["bkp_a", "bkp_b", "…the dry run's actionable_ids"], "confirm_count": 312}
{"action": "pin", "filter": {"job_id": "job_shop"}, "note": "audit 2026", "confirm_count": 40}
  • Exactly one of ids (duplicates count once; each at most 256 bytes) and filter selects the items. Destructive actions (delete) take a filter only with dry_run: true: their real run must name the exact IDs, the dry run's actionable_ids, and a filter is refused with 400, because a filter evaluated later could match other records than the ones that were shown. filter takes the names and formats of the list endpoints' query parameters (backups: status, database, connection_id, job_id, trigger, retry_of, from, to, q; restores: status, database, backup_id, from, to, q; jobs: database, connection_id, q, enabled, schedule, last_status, as in listing jobs) and selects every match; paging does not apply. A selection may hold at most 10,000 items (422 otherwise); the body may be about 5 MiB, enough for 10,000 IDs of the maximum length. Unknown fields, also inside filter, and filter fields that do not apply to the resource are refused with 400, so a misspelt filter can never widen the selection.
  • dry_run: true changes nothing and answers {matched, actionable, skipped: [{id, reason, params, detail}], total_size_bytes, actionable_ids}. reason is a stable code (see the table below) that clients translate; params fill it in (job for last_good_backup and last_verified, status for in_progress and not_running) and detail says the same in English.
  • A real run plans the selection again. When more than 10 items are affected it needs confirm_count equal to the dry run's actionable; a set confirm_count must always match. Otherwise it is refused with 409 and nothing changes, so a stale client can never act on more than it showed. Items are then processed one at a time through the same use cases as the single-item routes (logs, events and the shared-archive rule included). Every deletion of a backup (single, bulk, and retention) holds a lock per job (per connection and database for backups without a job), and bulk deletes decide the protections again inside it on the stored record, right before deleting it; an item that became protected meanwhile (pinned, newly the last good or last verified backup) is added to skipped instead. The record and, when no other record names it any more, its archive are deleted under a lock per archive, so records sharing an archive never strand it. The answer adds succeeded, failed and results: [{id, ok, error, warning, detail}] in order. Items are not started once the request ends; an item that started is finished.
  • One bulk.completed event (not selectable in notification rules) summarises every real run, and mongorescue_bulk_operations_total{resource,action} and mongorescue_bulk_items_total{resource,action,outcome} count them. Every real run also writes an audit entry (tool bulk backups delete and so on, with the actor, the counts and a SHA-256 of the processed IDs, sorted and one per line), whether it was started from a session or with an API key.

GET /api/v1/bulk/actions (read) lists the available actions as {resource, name, scope, destructive, allowed}; allowed says whether the caller has the scope. The route needs operator; each action needs the scope of its single-item route:

Resource Action Scope Skipped (reason)
backups delete (soft, optional reason) admin in_progress, pinned, already_deleted, last_good_backup (the newest completed backup of a job), last_verified (the job's newest verified scheduled backup, which retention keeps), not_found
backups verify operator not_verifiable (not completed or no checksum)
backups pin (optional note) operator already_pinned
backups unpin admin not_pinned
backups cancel operator not_running
restores delete (history records only; restored databases are not touched) admin in_progress
restores cancel (in-place restores need admin per item) operator not_running
jobs enable, disable admin already_enabled, already_disabled
jobs run_now operator — (a database that is already being backed up fails the item)
jobs delete (their backups are kept) admin —

Every action also skips IDs that name no record (not_found). A bulk delete never removes a job's last good backup, a pinned backup or the backup retention keeps as the last verified one; delete those one at a time (after unpinning) if you really mean to. Deleting backups in bulk is soft like a single delete: every result's detail says until when the backup is recoverable (recoverable until …), the archives stay for the grace period, and each deletion can be undone. With the two-person rule a real run of delete or unpin answers 202 with one approval request for all its actionable IDs; the approved run re-checks every item (delete protection).

The dashboard selects rows with checkboxes (shift-click for a range, the header box for the page, then Select all N matching the filters, which sends the filter), shows the dry run with the skipped items grouped by reason, asks to type the count for destructive actions on more than 10 items, and runs the confirmed IDs in batches of 25 with a progress bar.

Delete protection

Deletes are soft, lowering a protection waits for the delete grace period, and with the two-person rule destructive actions wait for a second administrator. The threat model is in security.md.

  • DELETE /api/v1/backups/{id} marks the backup deleted and answers {deleted_id, status, deleted_at, purge_after, archive_deleted: false}; the archive stays until purge_after. Backup records gain deleted_at, deleted_by, delete_approved_by, delete_reason, purge_after, status_before_delete and, once purged, purged_at. A deleted backup cannot be restored (409, also for the preflight), verified, pinned or deleted again (409); POST /api/v1/backups/{id}/undelete (admin) gives it back its earlier status.
  • Every ten minutes the scheduler purges the deleted backups whose grace period ended: the archive is removed (unless another live record names it) and the record becomes purged. The grace period is max(purge_after, deleted_at + security.delete_grace_days), so raising it protects earlier deletions. Retention deletes softly too (deleted_by: retention). On a target with S3 Object Lock a backup whose archive is still locked stays deleted until its retain_until too, and the purge then deletes every version of the archive. A failed backup whose uploaded artifact is locked carries archive_cleanup_pending until the purge has deleted it. Backup records carry storage_version_id, retain_until, object_lock_mode and legal_hold; pinning on a target with legal_hold_on_pin sets an S3 legal hold and unpinning lifts it (502 when the hold cannot be changed; nothing changes then).
  • A shorter job retention (PUT /api/v1/jobs/{id}, POST /api/v1/jobs) and a lower security.delete_grace_days are stored as pending changes and applied at their effective_at, the end of the current grace period. The answers carry pending_retention and pending_changes; GET /api/v1/pending-changes lists them and DELETE /api/v1/pending-changes/{id} (admin) cancels one, keeping the current protection.
  • With security.require_second_approver, deleting backups (single or bulk), unpinning (single or bulk), deleting a storage target, shortening retention, lowering the grace period and turning the rule off answer 202 Accepted:
{"success": true, "message": "a second administrator must approve this action: delete backup bkp_shop_20260924_030000_3f9a1c2e (db shop)",
 "data": {"approval_required": true, "approval": {"id": "apr_5c2e9b1f0a7d3e46", "action": "delete_backup", "status": "pending",
  "summary": "delete backup bkp_shop_20260924_030000_3f9a1c2e (db shop)", "subject": "bkp_shop_20260924_030000_3f9a1c2e",
  "requested_by": "API key ci", "requested_by_user_id": "usr_1a2b3c4d", "requested_via": "api_key",
  "created_at": "2026-10-05T09:00:00Z", "expires_at": "2026-10-08T09:00:00Z"}}}

Another administrator approves it with POST /api/v1/approvals/{id}/approve from a dashboard session (API keys, the requester, non-admins and administrators who got their role after the request get 403), which runs the action and answers the request with status: "approved" and a result, or status: "failed" and an error; POST /api/v1/approvals/{id}/reject rejects it. Requests expire after 72 hours (409 afterwards). Actions: delete_backup, bulk_delete_backups, unpin_backup, bulk_unpin_backups, delete_storage_target, shorten_retention (bound to the job's subject_created_at), lower_delete_grace, shorten_metadata_retention, restore_drop_target (POST /api/v1/restore in place with drop_target), pitr_in_place (an in-place point-in-time restore; subject is the stream), disable_second_approver, grant_admin_role, grant_admin_api_key, demote_admin, delete_admin, reset_password and oidc_admin_mapping. Turning the rule on with fewer than two administrators answers 409; turning it off while fewer than two remain is a pending change (disable_second_approver) instead. An API key without a creator gets 403 for every request while the rule is on. - With the rule on, POST /api/v1/users with role: "admin" answers 202 with the user (created as a viewer) and the request; PUT /api/v1/users/{id}/role to or from admin and DELETE /api/v1/users/{id} of an administrator answer 202; POST /api/v1/api-keys with scope: "admin" answers 202 with the key (scope operator until approved) and its plaintext key; PUT /api/v1/users/{id}/password for another user needs a session (403 for API keys) and answers 202. A PUT /api/v1/settings whose oidc change can grant admin returns the request in approvals_requested and leaves the oidc group unchanged (400 when it carries a new client_secret). - Creating or moving a storage target onto the location of another (the same or a nested directory, or the same endpoint and bucket with an equal or nested prefix) answers 409. Failed and cancelled backups block a target's deletion only while their object may exist. - The audit log entries of these requests carry the targets protection (soft_deleted, undeleted, approval_requested, approved, rejected, pending_change_cancelled), approval_id, purge_after and effective_at; purges and applied pending changes are recorded as SYSTEM backup.purge and SYSTEM protection.apply_pending. The events security.destructive_action and security.approval_requested can be selected by notification rules.

Cancelling a run

POST /api/v1/backups/{id}/cancel and POST /api/v1/restores/{id}/cancel (operator scope) stop a running backup or restore, whether it was started through the API, the dashboard, MCP or the scheduler. They wait up to 5 seconds for the run to stop and answer 200 OK with the final record ("status": "cancelled"); a run that takes longer to stop is answered with 202 Accepted and its in-progress record (progress.cancelling is true): poll it until the status is cancelled. A run cancelled while it is still queued never starts its tool. mongodump or mongorestore is terminated with its process group (SIGTERM, then SIGKILL), so no tool process outlives the run.

  • A cancelled backup deletes its partial artifact (an S3 multipart upload is aborted, a local temporary file removed) and records neither size nor checksum.
  • A cancelled safe-clone restore drops the partially restored <db>_rescue_<timestamp>_<id> database; the message says so (or asks to drop it by hand if that failed).
  • A cancelled in-place restore cannot be undone: the target may be left partially restored. The record's warning and error_message say so loudly. Cancelling one needs admin, like starting it.
  • A run cancelled before its tool started leaves the target untouched.

The record gets "status": "cancelled", cancelled_by (the username, API key <name>, ... via MCP, or system for the desktop app's force quit) and cancelled_at. Cancelled runs are not failures: they are not counted in failed_backups or the last 24 hours' failures, emit backup.cancelled / restore.cancelled instead of the failure events (notification rules may subscribe to them) and are counted with status="cancelled" in the metrics.

Status When
200 OK The run stopped; the body is its final record
202 Accepted The cancellation was requested and the run is still stopping
403 Forbidden The key may not cancel this run (an in-place restore needs admin)
404 Not Found No backup or restore {id}
409 Conflict The run is not running (it finished, or it is not active in this process), or it is already finishing: its tool completed and only the outcome is being recorded

Once mongodump or mongorestore has completed successfully, the run is past the point of no return: a cancellation that arrives then is refused with 409, and one that raced with the tool's exit does not undo the finished backup or restore. If a check after the tool fails (checksum mismatch, documents that failed to insert) while a cancellation was requested, the run is recorded as cancelled, and a safe clone is dropped.

Run logs

Every backup and restore writes a log file, <datadir>/logs/<id>.log: the complete output of mongodump / mongorestore and MongoRescue's own timestamped [mongorescue] phase lines (start, tool arguments without the connection, phases, outcome, cancellation). Each line is redacted before it is written: connection strings and secrets are masked, document values in duplicate-key errors are replaced, and tool lines are cut to 300 bytes. A log never exceeds 5 MiB: the first 1 MiB and the newest output are kept around a … N bytes truncated … marker, and the run streams its log to disk without holding it in memory.

GET /api/v1/backups/{id}/log and GET /api/v1/restores/{id}/log (read scope) return the log as text/plain. With ?tail=N they return the last N lines (1-10000), also while the run is still writing them; without it, the whole file with Content-Disposition: attachment. Runs from older releases have no log (404). Logs are deleted with their backup record, when retention prunes the backup, and after general.log_retention_days (default 30; 0 keeps them).

Live progress

GET /api/v1/runs/active (read scope) returns the progress of every running backup and restore, oldest first; running records in GET /api/v1/backups and GET /api/v1/restores carry the same object as progress:

{"id": "bkp_shop_20261001_100000_3f9a1c2e", "kind": "backup", "job_id": "job_shop", "database": "shop",
 "phase": "dumping", "percent": 41.2, "bytes": 52428800, "documents": 41200,
 "current_collection": "shop.orders", "collections_done": 3, "collections_total": 5,
 "bytes_per_second": 10485760, "started_at": "...", "updated_at": "...",
 "phases": {"queued": "...", "started": "..."}}

Bytes count what was streamed to storage (backups) or read from it (restores, with total_bytes the archive size). Documents, collections and the backup percentage come from the tools' progress lines (the percentage covers the collections the tool has started so far); a restore's percentage is the share of the archive read. phase is queued, waiting (a backup waiting for a slot of its connection's max_concurrent_backups), dumping, verifying, restoring, safety_backup and swapping (an in-place point-in-time restore), finishing or cancelling. Every record also stores the timestamps of its phases (queued, started, dump_done, upload_done, verify_done, restore_done, finished); records from older releases have queued and finished only. The dashboard polls every 2 seconds while something runs, shows a progress bar on running rows and, in the details dialog, the progress, a phase timeline and a Log tab that follows the log of a running run.

Asynchronous operations

Every backup record has a trigger: scheduled (a cron run of job_id), on_demand (POST /api/v1/jobs/{id}/run), manual (POST /api/v1/backups) or mcp (an assistant's start_backup or run_job). It is set by the server, never taken from the request, and retention only prunes a job's scheduled backups (configuration.md).

POST /api/v1/backups, POST /api/v1/backups/{id}/retry, POST /api/v1/jobs/{id}/run and POST /api/v1/restore return 202 Accepted as soon as the operation has started. The response body is the new backup or restore record with "status": "in_progress"; poll GET /api/v1/backups or GET /api/v1/restores until it becomes completed, failed or cancelled. Operations keep running if the client disconnects; cancel them to stop them. Verifications (POST /api/v1/backups/{id}/verify), restore tests, sweeps and orphan imports run in the background the same way; their results land on the backup record (verified_at, verification, last_restore_test) or in GET /api/v1/jobs/{id}/restore-tests and GET /api/v1/integrity.

Backup records carry the trust fields verified_at, verification (ok, mismatch, error) and verification_error; pinned, pin_note, pinned_at, pinned_by; has_manifest; imported and imported_at; missing_since for status: "missing"; and last_restore_test. See verification.md.

Status Meaning
202 Accepted The operation started
400 Bad Request Invalid request, for example a missing connection_id, an unknown storage_target_id or an unconfirmed in-place restore
401 Unauthorized Not signed in and no valid API key
403 Forbidden The API key's scope does not allow the operation (for example a read key starting a backup, or an operator key restoring in place)
409 Conflict A backup of the same database on the same connection, or a restore into the same target, is already running
422 Unprocessable Entity The backup is encrypted and no decryption key is configured
503 Service Unavailable The server is shutting down, or the desktop app is quitting once its running backups and restores finish (MongoRescue is shutting down)

Audit log

GET /api/v1/audit/events (admin) returns the hash-chained audit log of every action: each mutating REST request from a session or an API key, sign-ins and failed sign-ins, sign-out, setup, the recovery kit and audit log downloads, MCP tool calls and system actions. Entries name the actor, the route pattern, the path parameters, the HTTP status and outcome, the client address and the user agent, never request bodies. The format, the hash chain, retention and forwarding are described in audit.md.

Parameter Meaning
actor User ID, user name, API key ID or API key name (exact)
actor_kind user, api_key, system or anonymous
action Part of the action, case-insensitive (jobs, DELETE, MCP start_backup)
result ok, error, denied or rate_limited
since, until RFC 3339 times; since inclusive, until exclusive
limit Page size, 1-1000 (default 100)
before_id Return entries older than this ID: the next_before_id of the previous page
{"events": [{"id": 1842, "time": "2026-10-02T12:30:45.123456789Z", "actor_kind": "api_key", "actor_user_id": "",
  "actor_name": "", "actor_key_id": "key_9f8e7d6c5b4a3928", "actor_key_name": "ci", "action": "DELETE /api/v1/backups/{id}",
  "targets": {"id": "bkp_shop_20260901_030000_1a2b3c4d"}, "status": 403, "outcome": "denied", "client_ip": "192.0.2.10",
  "user_agent": "curl/8.7.1", "count": 1, "hash": "…"}],
 "next_before_id": 1842,
 "forwarding": {"enabled": true, "queued": 0, "sent": 1840, "failed": 2, "dropped": 0}}

GET /api/v1/audit/events/export takes the same filters (no paging) and streams every matching entry as one JSON object per line, oldest first (application/x-ndjson, as a mongorescue-audit-<time>.jsonl attachment). GET /api/v1/audit/events/verify walks the chain from its anchor and answers {ok, checked, anchor: {last_id, last_hash, last_time, pruned_at}, head_id, head_hash, broken_id, reason, warning, retention_days, verified_at}; ok is false and broken_id names the first entry that was changed, removed or reordered when the chain is broken, and warning is set when the anchor moved further than the retention explains (audit.md).

curl -s "http://localhost:8080/api/v1/audit/events/export?since=2026-10-01T00:00:00Z" -H "Authorization: Bearer $KEY" > audit.jsonl
curl -s http://localhost:8080/api/v1/audit/events/verify -H "Authorization: Bearer $KEY"

API key activity

GET /api/v1/audit (admin) returns the most recent activity of API keys, newest first: MCP tool calls, resource reads (tool is resources/read, the URI in arguments.resource) and prompt requests (prompts/get), and REST requests to /api/... authenticated by an API key. Browser sessions and /metrics scrapes are not audited.

{"id": 42, "time": "2026-09-25T10:15:03Z", "api_key_id": "key_1a2b3c4d5e6f7a8b", "api_key_name": "claude-desktop",
 "transport": "stdio", "tool": "restore_to_safe_clone", "arguments": {"backup_id": "bkp_shop_20260924_030000_3f9a1c2e", "verify": true},
 "result": "ok", "duration_ms": 18, "count": 1}

A REST entry has transport rest, the route pattern as tool (never the raw path, and (no route) when none matched), the path parameters as arguments (request bodies and queries are not stored) and the response status in http_status:

{"id": 43, "time": "2026-09-25T10:16:10Z", "api_key_id": "key_9f8e7d6c5b4a3928", "api_key_name": "ci",
 "transport": "rest", "tool": "DELETE /api/v1/backups/{id}", "arguments": {"id": "bkp_shop_20260901_030000_1a2b3c4d"},
 "result": "denied", "error": "Forbidden", "duration_ms": 0, "http_status": 403, "count": 1}

result is ok, error (the call ran and failed; error holds the message the assistant saw, or the status text for REST), denied (scope too small; 403 for REST) or rate_limited (429). Argument values of secret-looking keys and credentials inside strings are masked before they are stored. The newest 10,000 entries are kept. Refused and polling clients cannot flush the log: repeated denied calls of one key and tool, repeated rate_limited calls of one key, and repeated REST reads or failed REST requests of one key, route and status are merged into one entry per 10 seconds whose count is the number of calls it stands for (1 for every other entry). A merged entry keeps the distinct values of each argument, up to 20 (for example "arguments": {"id": ["bkp_a", "bkp_b"]}), and sets "_more_values": true when it dropped some.