Skip to content

Admin API

Manage a realm's users and roles from server-side services with an API key.

The admin API manages the users and API keys of a realm. backd user and backd apikey are command-line clients of it (see Command-line administration), and server-side services can call it directly. It lives under /v1/{realm}/_admin in every realm with auth: enabled.

Every request needs one of the realm’s API keys with the admin role, acting as itself, or the session of a user holding one of the realm’s admin roles:

CredentialAnswer
API key with the admin roleAllowed
Session of a user holding an admin role (admin: true in realm.yaml)Allowed
Session of a user whose admin role opens only some areasAllowed for those areas; 403 forbidden naming the missing one for the rest
API key with the data role403 forbidden
None, or an invalid key401 unauthenticated
Session of a user without an admin role403 forbidden
X-Backd-On-Behalf-Of with any credential400 invalid_header: admin calls never act as another user

Requests from outside the realm’s admin.allowed_networks, or outside an admin user’s own admin_networks, answer 404 not_found (see network restrictions).

The Admin UI is a web interface to this API, for administrators who prefer a browser.

Admin rights

An admin role can open the whole admin API (admin: true), only some of its areas (admin: [users, invitations]), or all of it to read (admin: read), so that the person who invites teammates doesn’t also hold the secrets and the keys, and developers can look without being able to change anything. A user’s roles add up, and an admin API key always opens every area.

AreaEndpoints
users/users and everything under /users/{id}: reading, disabling, erasing, passwords, addresses, roles and networks
invitations/invitations
apikeys/apikeys
secrets/secrets
auditGET /audit
functionsPOST /jobs/{id}/cancel
POST /jobs/{id}/rerunnone
POST /functions/{database}/{name}/invoke, GET /invocations, GET /jobs, GET /jobs/{id}, POST /jobs/{id}/cancel and …/rerun, GET /schedules, and POST /functions/{database}/{name}/pause and …/resume; and reading any job through the data API
datathe admin data route (documents past their collections’ rules), and the schema checks (GET and POST /data-checks, read is enough; cancelling one needs only this area)
configGET /config, the read-only view of the realm’s configuration, POST /config/check, which checks a change to the configuration, and POST /storage/check, which checks the realm’s object storage

The read-only level

admin: read reads every area and changes none: every GET under /_admin answers, every other method answers 403 (“can read the area but not change it”). Two areas hold personal data, so a realm opts in: users and data are read-only-visible only with admin.read_access.users and admin.read_access.data set to true in realm.yaml. API keys are listed (never the keys) and secrets by name (never a value). A list may add read to a few writable areas: admin: [read, secrets] reads everything and changes secrets. The same rules apply to every credential: network restrictions, session limits for administrators, and the audit trail of refusals.

GET /whoami tells a client what the signed-in administrator may do (level: full, read or custom; the areas it may write and read; read_access), so an interface offers only what the server would allow.

A request for an area the roles don’t open answers 403 forbidden with the missing area in the message, and is recorded in the audit trail as admin.refused with the reason, the method and the path.

An administrator can’t use these endpoints to end up holding more than they started with:

  • A role is granted or taken away only by someone who holds everything it opens, reading included: giving admin: read (every area to read) takes a caller who can read every area. support (users, invitations) can give support or a role that is no admin role, but not staff (admin: true), and not keeper (apikeys, secrets) either.
  • A user who holds admin rights the caller doesn’t can be read, and nothing more: changing their password or address, disabling, erasing, re-roling them or setting their networks answers 403, since each would be a way to become them.
  • Admin API keys are created and revoked only by full administrators (admin: true or an admin key), because such a key opens every area. Data keys follow the apikeys area.

Keep one role with admin: true, held by the people who recover the realm: backd bootstrap needs it for the first administrator, and startup warns when a realm has none.

Endpoints

Paths are relative to /v1/{realm}/_admin. Bodies are JSON (Content-Type: application/json) and unknown fields are rejected.

Method and pathBodySuccess
GET /usersnone; query limit (1–100, default 20), skip or after, email (exact), q (search: plain text, at most 254 characters)200 with a page of users, sorted by email, read from the database a page at a time; next_cursor (the last email) while has_more: send it as after for the next page
POST /users{"email", "password"?}201 with the user
GET /users/{id}none200 with the user
PATCH /users/{id}{"email_verified"?: bool, "disabled"?: bool}200 with the user
DELETE /users/{id}none202 with the erase job: erases the user (irreversible)
GET /users/{id}/sessionsnone200 with the user’s unexpired sessions, newest first (id, created_at, last_used_at, expires_at; never a token)
DELETE /users/{id}/sessions/{session_id}none204; the session’s token stops working at once; audited as session.revoke. 404 for an unknown session
POST /users/{id}/password{"password"}204
GET /users/{id}/ownednone200 with what erasing the user would do
POST /users/{id}/email{"email"}200 with the user; changes the address
PUT /users/{id}/roles/{role}none200 with the user
DELETE /users/{id}/roles/{role}none200 with the user
PUT /users/{id}/networks{"admin_networks": [...], "login_networks": [...]}200 with the user
GET /whoaminone200 with what this credential may do: level, the areas it may write and read, and read_access (see the read-only level)
POST /storage/checknone200 with the report of a storage check (ok, one entry per step with its level and detail, what the storage does with a signed SHA-256, the bucket’s CORS and encryption where exposed): it stores a few small objects under <prefix>/<realm>/_check/ and deletes them. 404 when the realm has no storage:; 503 storage_unavailable when its keys aren’t set. Needs the config area (read is enough). Audited as storage.check
GET /storagenone200 with the realm’s storage status: its configuration (the keys by name), whether the keys are set and the bucket answers, the running totals of bytes and files (the realm’s and the 20 users holding the most), the deletion queue and the unfinished uploads. {"configured": false} without storage:. Needs the config area (read is enough)
POST /files/versions/regenerate{"database", "collection", "field", "version"?, "missing_only"?, "rate"?}202 with the job that makes the stale image versions of a file field again (copies made with other parameters, failed or missing; a function’s own copies are left alone). 409 regenerate_running while one runs for the field; 404 for a field or version that isn’t declared; 400 for a version only functions make. Follow and cancel it with the jobs routes. Needs the config area with write access. Audited as files.versions.regenerate
POST /storage/reconcile{"delete"?: bool}200 with the reconcile report: what was looked at, what was skipped and why, and the unreferenced objects; with delete: true it removes exactly those. 404 without storage:, 503 storage_unavailable when the keys can’t be read. Needs the config area with write access. Audited as storage.reconcile
GET /confignone200 with the realm’s configuration, read-only
POST /config/check{"files": {"main/notes/collection.yaml": "…", "main/old/schema.json": null}}200 with ok, checked (how many files of the realm were loaded and validated, drafts included), changed and problems (file, message). It loads the realm’s whole configuration as startup does, with the draft files (paths relative to the realm, null deletes one) in place of the ones on disk, so a change that breaks another file is found. It works on a copy and applies nothing; function bundles are not part of it. At most 50 files of 1 MiB; 400 for a path outside the realm. Needs the config area (read is enough)
POST /invitations{"email"?, "expires_in"?, "send"?, "redirect_to"?, "locale"?}201 with the invitation and its token, or sent: true and no token when it is emailed
GET /invitationsnone200 with unexpired, unused invitations
DELETE /invitations/{id}none204
GET /apikeysnone200 with the realm’s API keys (never the keys themselves)
POST /apikeys{"name", "role"?, "expires_in"?, "networks"?, "scopes"?}201 with the key, shown only here
DELETE /apikeys/{name}none204; the key stops working at once
GET /secretsnone200 with the realm’s secrets’ metadata (never their values)
PUT /secrets/{name}{"value", "database"?}204; creates or replaces the value
DELETE /secrets/{name}none; query database?204
GET /auditnone; query action, actor, target, since, until (RFC 3339), limit, skip200 with a page of audit records, newest first
GET /jobs/{id}none200 with the job as the listing shows it, plus steps (every step the running or last attempt reported) and steps_omitted; 404 for an unknown id
GET /jobsnone; query function (<database>/<name>), status (queued, running, done), origin (such as function:main/ship, the emails that function sent), scheduled (true for cron runs), since, until (RFC 3339, on the creation time), limit, skip200 with a page of jobs, newest first: id, function, status, scheduled, origin, email_kind (the kind of an email job, never its recipients), attempts, created_at, completed_at and result (status, code, duration_ms), never a job’s input or output. backd functions jobs is its CLI
POST /data-checks{"database"?, "collection"?, "limit"?} (none: every collection of the realm)202 with the job (id, scope, collections, limit, estimated_documents) of a schema check, which a worker runs in the background: it reads every document. 409 check_running (the running job’s id in details) while another is queued or running; 404 for an unknown database or collection; 400 for a limit outside 1–1000. Needs the data area (read is enough). Audited as data.check
GET /data-checksnone200 with running (the check queued or running now, with its step in progress, or null) and items: every collection of the realm with the summary of its latest report, or null
GET /data-checks/{database}/{collection}none200 with the latest report in full (each invalid document with its problems: a path and a rule, never a value); 404 when it was never checked
GET /schedulesnone200 with the realm’s scheduled functions: function, schedule, timezone, overlap, paused, and when and by whom it was last paused or resumed. See Pausing a schedule
POST /functions/{database}/{name}/pause and /resumenone200 with the schedule. A paused schedule creates no runs, and a resume doesn’t make up the missed ones; the state is stored and survives restarts. 409 for a function with no schedule. Audited
POST /functions/{database}/{name}/invoke{"input"?, "as"?}; header Idempotency-Key?Runs any function by hand, internal ones included, as the user as (an email) or with no user; answers like _func (200 with the output, or 202 with a job). Audited. See Internal functions
GET /invocationsnone; query function, request_id, since, until (RFC 3339), limit, skip200 with a page of function invocation records, newest first

A user looks like this:

{
  "id": "dars2ql90v600434mf0g",
  "email": "ada@example.com",
  "email_verified": true,
  "roles": ["editor"],
  "locale": "en",
  "disabled": false,
  "erased_at": null,
  "admin_networks": [],
  "login_networks": ["10.20.0.0/16"],
  "created_at": "2026-09-26T12:58:18.838Z",
  "updated_at": "2026-09-26T13:02:41.120Z"
}

Finding a user by email

GET /users?email=ada@example.com returns the user with exactly that email (case doesn’t matter), or an empty list. Services use it to turn an email into a user id, for example to add someone to a document’s member list. Signed-in users can’t look other users up, so browsers can’t use this to discover which emails are registered.

Behavior

  • Create: the email must be valid and not registered (409 email_taken). The user starts with a verified address: an administrator vouches for it. Without password, the user can’t sign in with a password until one is set. Roles seeded in realm.yaml for that email are applied.
  • Update: only email_verified and disabled can change. Disabling a user ends all their sessions and blocks login. Sending email answers 400: an address changes through its own call, which sends the notices a change needs.
  • Password: must follow the realm’s password policy; all of the user’s sessions end, and in a realm with email the user gets a password-changed message.
  • Roles: only roles declared in realm.yaml (400 otherwise). Like backd user add-role, assignments that realm.yaml doesn’t list live only in the database, and removing a seeded one only lasts until the next startup.
  • Delete is an erase: the user becomes a tombstone at once (the id stays, the email becomes erased-<id>@erased.invalid, and the sessions, sign-in methods, email tokens and queued emails are deleted), and a worker applies the collections’ policies. The answer is the erase job; repeating the call for a user whose job failed resumes it, and for an erased user answers 409 already_erased. To keep the data, disable the user instead. Everything else on an erased user answers 409 user_erased. See Deleting and erasing users.
  • Unknown user ids answer 404 not_found.

Network restrictions

PUT /users/{id}/networks replaces the user’s network restrictions: admin_networks (their admin API requests; within the realm’s admin.allowed_networks, or 400) and login_networks (their login and session). Both lists are required; empty lists remove the restriction. For users whose networks realm.yaml sets, realm.yaml wins again at the next startup.

API keys

The same operations as backd apikey, over HTTP:

curl -X POST https://localhost:8443/v1/blog/_admin/apikeys \
  -H "Authorization: Bearer $BACKD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "billing-2026q4", "role": "data", "expires_in": "90d", "networks": ["203.0.113.0/24"], "scopes": ["read:main/posts"]}'
{
  "name": "billing-2026q4",
  "role": "data",
  "prefix": "bdk_7VeQ1h",
  "networks": ["203.0.113.0/24"],
  "scopes": ["read:main/posts"],
  "created_at": "2026-09-27T10:00:00.000Z",
  "last_used_at": null,
  "expires_at": "2026-12-26T10:00:00.000Z",
  "key": "bdk_7VeQ1hN0m2k…"
}
  • key is shown only in this response; backd stores just its hash.
  • role is data (default) or admin; expires_in takes days or Go durations, and without it the key never expires; networks pins the key; scopes (up to 32 grants such as read:main/posts) limit what a data key reaches and must name databases, collections and functions the realm has.
  • A name that’s already used answers 409 conflict. GET /apikeys lists name, role, first characters, scopes (empty: everything), networks, creation, last use and expiry.

Secrets

Functions read secrets you set here as ctx.secrets, decrypted (see Functions → Secrets). backd secret set|list|delete are command-line clients of these endpoints:

curl -X PUT https://localhost:8443/v1/blog/_admin/secrets/STRIPE_KEY \
  -H "Authorization: Bearer $BACKD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"value": "sk_live_…", "database": "shop"}'
{"items": [
  {"database": "shop", "name": "STRIPE_KEY", "created_at": "2026-09-28T10:00:00.000Z", "updated_at": "2026-09-28T10:00:00.000Z", "updated_by": "key:publisher"}
]}
  • Without database, the secret is in the realm scope (realm.NAME in function.yaml); with it, that database’s scope (NAME, readable only by functions of that database — never another’s, even in the same realm).
  • value must not be empty; name must be upper-case letters, digits and _, starting with a letter (400 otherwise). Setting an existing scope and name replaces its value; GET /secrets never includes it, only who last set it and when.
  • A change reaches running functions within about a minute (decrypted values are cached briefly); deleting one makes a function that declares it answer 500 secret_missing again.
  • Answers 500 (internal_error) if the server has no BACKD_SECRETS_KEY configured: an operator problem, not something a request can fix.

Previewing an erase

GET /users/{id}/owned (backd user owned --email …, admin.users.owned(id) in the JavaScript client) answers what an erase would do to a user’s data, only for the collections that declare a policy in collection.yaml:

{
  "user": { "id": "dars2ql90v600434mf0g", "status": "active" },
  "collections": [
    { "database": "main", "collection": "orders", "action": "anonymize", "owned": 12,
      "remove": ["phone"], "replace": ["buyer_name"] },
    { "database": "main", "collection": "groups", "action": null, "owned": 0,
      "pull": { "members": 3 }, "unset": { "paid_by": 0 } }
  ],
  "without_policy": ["main.digests", "main.events"]
}
  • owned counts the documents the user owns (_meta.owner); pull and unset count, per field, the documents that hold the user (by email or id, as the policy says). status is active or deactivated.
  • The collections without a policy are listed by name and not counted: an erase leaves them alone.
  • It returns counts and field names, never document content. The counts use indexes that backd provision creates from the policies.

Changing a user’s email

POST /users/{id}/email with {"email": "new@example.com"} changes the address at once, in any realm with email (404 without), whatever account.allow_email_change says: the administrator vouches for the address, so it counts as verified. The user’s sessions end, the old address is sent email-changed with a link to undo the change for 7 days (the undo restores the address, ends every session, makes the current password unusable and sends a password reset link to it), and the new address is told. An address another user has answers 409 email_taken. Audited as user.email_changed with by: admin.

Configuration

GET /v1/{realm}/_admin/config shows what this instance runs for the realm, read from the files it loaded, so an operator can check what is live without logging into the host: the realm’s settings (defaults applied), every database’s collections (schema, indexes, rules, the collection.yaml policy) and functions (function.yaml: mode, limits, calls, network, secrets by name only), the email templates and hosted pages found on disk, the configuration fingerprint (the same /readyz shows) and a list of warnings (authentication disabled, no administrator role, an admin API reachable from any network, open sign-up, the development delivery function). Each item carries the file it came from, relative to the config directory. Seeded user emails show only to callers who may read users. It needs the config area, which a read-only administrator has.

Nothing here can be changed through the API: configuration is a reviewed, versioned artifact, and a change goes through your repository and a deploy.

Data

/v1/{realm}/_admin/data/{database}/{collection}[/{id}] (and /data/{database}/_batch) serve the data routes’ operations to an administrator past the collection’s rules: list, get, create, PUT, PATCH, DELETE (soft or hard as the collection decides, with ?purge=true and …/restore for a soft-deleting one), and the deleted and where parameters of the data routes. Everything else is the same: the schema validates every write, _meta.version, ETag and If-Match work, errors have the same shape. It needs the data area: admin: true and an admin key have it, an area list has it only if it names data, and a read-only administrator reads only when admin.read_access.data is true (every change answers 403).

  • A document created here has no owner (_meta.owner is null): the administrator didn’t create it for themselves. created_by and updated_by record who did, user:<id> or key:<name>.
  • Writes are audited, never their content: data.create, data.update, data.delete, data.restore and data.purge, with the actor and the target doc:<database>/<collection>/<id>. Reads are not in the audit trail; they carry the actor in the access log.
  • The normal data routes are unchanged: an administrator using an app is bound by its rules there.
  • With BACKD_ADMIN_API=false the route doesn’t exist (404).

Invitations

In a realm with signup: invite, people can only sign up with an invitation. A service creates one and delivers its token, for example by email:

curl -X POST https://localhost:8443/v1/blog/_admin/invitations \
  -H "Authorization: Bearer $BACKD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"email": "ada@example.com", "expires_in": "3d"}'
{
  "id": "dasb1l190v6000bbojk0",
  "token": "bdi_ZkN0x3Qm…",
  "email": "ada@example.com",
  "created_by": "key:publisher",
  "created_at": "2026-09-26T16:40:00.000Z",
  "expires_at": "2026-09-29T16:40:00.000Z"
}
  • token is shown only in this response; backd stores just its hash. The invitee sends it as invitation to POST /_auth/signup.
  • email is optional. With it, only that email can sign up with the invitation; without it, anyone holding the token can.
  • expires_in takes days (7d) or Go durations (12h): default 7 days, from 1 minute to 90 days.
  • Each invitation works once. GET /invitations lists those not used or expired yet (never their tokens); DELETE /invitations/{id} revokes one.
  • Expired and used invitations are deleted automatically.

Emailing an invitation

In a realm with email, "send": true (with an email) makes backd email the invitation instead of returning a token to deliver yourself:

curl -X POST https://localhost:8443/v1/blog/_admin/invitations \
  -H "Authorization: Bearer $BACKD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"email": "ada@example.com", "send": true, "redirect_to": "https://app.example.com/welcome", "locale": "es"}'

The answer has "sent": true and no token: nobody holds one. The message carries a link to a page of backd (the realm’s pages/accept-invitation, showing the invited address) where the person chooses a password, or to your own page with email.links.invitation, which posts {token, password, locale?} to POST /_auth/accept-invitation. The link works as long as the invitation does (expires_in) and once. redirect_to (within email.allowed_redirects) and locale (the email’s language) only apply with send. A revoked invitation’s link stops working. The email limits apply: past them the answer is 429 and no invitation is left behind.

The access log records the caller as key:<name>.

Edit this page · Last updated 08 Oct 2026