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; and /orgs, the realm’s organizations
invitations/invitations
apikeys/apikeys
secrets/secrets
auditGET /audit
functionsGET /triggers/status, GET /triggers/gaps and POST /triggers/gaps/{id}/resolve; 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}/identitiesnone200 with the user’s sign-in methods, oldest first (provider, email, email_verified, created_at, last_used_at; never a subject, a hash or a token), as GET /_auth/me lists them for the user
DELETE /users/{id}/identities/{provider}none204; removes one of the user’s sign-in methods (password included). 409 last_sign_in_method for the last one, 404 for a provider the user doesn’t have; audited as identity.unlinked with by: admin. Removing Apple also revokes the user’s Apple tokens
POST /users/{id}/mfa/resetnone204; removes the user’s second factor and recovery codes and ends all of their sessions; never on yourself (403 self_reset_not_allowed), 409 mfa_not_enabled when there is none
GET /users/{id}/mfanone200 with the user’s second factor: enabled, methods, recovery_codes_left, required; never a secret or a code
GET /users/{id}/devicesnone200 with the user’s push devices, most recently seen first (id, platform, name, created_at, last_seen; never a token); 404 push_not_configured without push:
DELETE /users/{id}/devices/{device_id}none204; the device stops receiving pushes; audited as device.removed. 404 for an unknown device
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 /orgs?status=none200 with the realm’s organizations, newest first (status: active, orphaned, deleted). Needs the users area
POST /orgs{"name", "owner", "slug"?}201: creates an organization for the user owner (a user id), who holds the realm’s creator_roles; the create rule and max_created_per_user don’t apply. 404 for an unknown user, 409 slug_taken. Audited as org.create. Needs the users area with write access
GET /orgs/{org}none200 with the organization and its claims; 404 if unknown
DELETE /orgs/{org}none204: ends the organization at once and queues the cleanup of its documents. 409 org_not_active when it is deleted already. Audited as org.delete and org.deleted
GET /orgs/{org}/ownednone200 with what a deletion would do: the documents in each collection with organizations and its on_org_delete, and totals to_delete and to_keep
POST /orgs/{org}/reactivate{"slug"?}200: a deleted organization is active again with no members. 409 slug_taken, 409 org_not_deleted. Audited as org.reactivate
GET /orgs/{org}/membersnone200 with a page of members, with emails (limit, after)
POST /orgs/{org}/members{"user", "roles"?}200 with the member: adds the user without an invitation, or gives an existing member the roles. 404 for an unknown user, 409 org_limit_reached at their membership cap. Audited as org.member_add
PATCH /orgs/{org}/members/{user}{"roles": [...]}200: replaces the member’s direct roles. 409 last_org_manager when it would leave the organization without a manager. Audited as org.roles
DELETE /orgs/{org}/members/{user}none204; 409 last_org_manager for the last manager (assign another first). Audited as org.member_remove
GET /orgs/{org}/teams, POST /orgs/{org}/teams{"name", "roles"?}The organization’s teams; 201 on create, 409 team_name_taken or team_limit_reached. Audited as org.team_create
PATCH and DELETE /orgs/{org}/teams/{team}{"name"?, "roles"?}200 / 204; 409 last_org_manager when no manager would be left. Audited as org.team_update, org.team_delete
PUT and DELETE /orgs/{org}/teams/{team}/members/{user}none200 with the member; 404 when the user isn’t a member. Audited as org.team_member_add, org.team_member_remove
GET /orgs/{org}/invitationsnone200 with the organization’s invitations, never tokens
POST /orgs/{org}/invitations{"binding", "user"?, "email"?, "roles"?, "teams"?, "send"?, "expires_in"?, "max_uses"?}201 with the invitation and its token and link, which are shown only now; any role may be given, but an open link never one that manages the organization. Audited as org.invitation_create
POST /orgs/{org}/invitations/{id}/linknone200 with a new token and link; the old one stops working
DELETE /orgs/{org}/invitations/{id}none204; audited as org.invitation_revoke
PUT /orgs/{org}/claimsa JSON object, at most 1 KiB200: replaces the organization’s claims, which members and rules read. Audited as org.claims, with the keys
POST /orgs/{org}/owner{"user"}200: gives the user the realm’s creator_roles in the organization; an orphaned organization becomes active. Audited as org.owner_assign
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 /users/{id}/consents—200 with {consents, history}: the versions the user holds and every consent record, with the text hash and the evidence of administrator records. Needs the users area
POST /users/{id}/consents{consent, version?, action, evidence}201 with the record (method admin). evidence is required; version is required to accept. 400 for an unknown consent or version, 404 for an unknown or erased user. Audited as user.consent_recorded. Needs the users area with write access
POST /consents/notify{"consent": "<name>"}200 with {consent, version, matched, queued, limited}: queues the consent-updated email for the users who hold an older version of the consent. 400 for an unknown consent, 409 without email:. Needs the users area with write access. Audited as consents.notify
GET /compliance/scope—200 with what a user’s personal data export takes from each collection: whether it is included, the fields when only some are, the reason for every exclusion, and the system data an export adds and never holds. Needs the config area (read is enough)
GET /confignone200 with the realm’s configuration, read-only
GET /typesnone (?db= limits it to one database)200 with types (the contents of types.d.ts), info (its realm.json) and warnings: what backd types writes from CONFIG_DIR, for the config this instance runs. Needs the config area (read is enough)
GET /openapi.jsonnone200 with the OpenAPI document of the admin API, internal functions included; ETag and 304. Needs the config area (read is enough)
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
POST /jobs/{id}/cancelnone200 with the job, now done with the result cancelled: a queued, retrying or running function job ends at once and a worker running it stops the run. 409 when it has already finished or isn’t a function’s job. Audited. See Cancelling and re-running a job
POST /jobs/{id}/rerunnone202 with a new job (rerun_of names the original) for a finished function job: the same function, input and caller. 409 while it hasn’t finished, or when it isn’t a function’s job or its function no longer exists. Audited
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 /triggers/statusnone200 with dispatcher (started, held, epoch, watermark_at, watermark_age_seconds, lease_expires_at; null when this instance can’t read it), triggers (the realm’s, each with runs_with_admin_access), backlog and open_gaps. The lease holder’s name is not included. See the trigger dispatcher
GET /triggers/gaps, POST /triggers/gaps/{id}/resolveall=true, limit (1 to 500)200 with the dispatcher’s recorded gaps (window, preimage_missing, backlog), newest first, open ones unless all=true; resolving one is audited and answers 404 when there is no open gap by that id
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.
  • organizations lists the organizations the user can manage, and last_manager says whether an erase leaves one without anyone who can: it becomes orphaned (its members keep access) until an administrator assigns a manager. kept_in_organizations counts, per collection, the documents the user owns that belong to an organization: they stay with it, with the owner cleared, unless the collection says in_org: apply.
  • 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.

App configuration

A realm that declares app configuration has its values under /v1/{realm}/_admin/app-config (404 without), in the config area: read-only administrators read, full administrators change.

  • GET shows every key with its effective value, the keys clients can’t read included, the values that differ from the defaults, stored values that stopped validating after a deploy (they read as their defaults until fixed or reset), and the latest history. The ETag is the version, such as "c3".
  • PATCH with {"set": {"maintenance.enabled": true}, "reset": ["features.export"]} and If-Match: "c3" validates the result as a whole against the schema and applies it atomically, or applies nothing (400 with the failing paths). A stale version answers 412, a missing If-Match 428. A change that leaves every value as it was makes no version.
  • GET /app-config/history lists versions newest first: who (user:<id>, key:<name> or a function), when, and each path’s old and new value. The last 100 versions or 90 days are kept, whichever is longer.
  • POST /app-config/rollback with {"version": "c2"} restores that version’s values as a new version (c0 is the defaults), if they still fit the schema.

Every change is audited as appconfig.change with the version, the kind and the paths, never the values. The command line does the same with backd app-config get|set|reset|history|rollback --realm <r>.

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.
  • Two more routes exist only here, for organizations: POST /{id}/_transfer moves a document to another organization, and POST /_assign_org gives the documents of an adopted collection that have none one (data.transfer and data.assign_org, audited).
  • 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 19 Oct 2026