Skip to content

Users and admin API

Except for first-boot setup, these endpoints require a user with is_admin=true; an admin:* scope alone does not confer that role. Only GET /users and GET /users/{id} are usable with an API key (with a suitable users/admin scope and a globally-admin owner). User mutations require a fresh MFA browser session, and /admin/settings plus /admin/system/* are not available to API keys. First-boot setup is instead guarded by the one-shot token and an empty users table.

User scopes inside a project are still managed through memberships; this page is for the parallel surface of global user state that lives outside of any single project.

Base path: /api/v1/users. All routes require global admin.

GET /api/v1/users

Query: ?limit= (default 100, max 500; zero, negative or out-of-range values fall back to 100) and ?offset= (default 0; negative values clamp to 0). Newest first. Returns a list of UserAdminPublic:

[
{
"id": "...",
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Doe",
"display_name": "Alice",
"is_admin": true,
"is_active": true,
"timezone": "UTC",
"created_at": "...",
"updated_at": "...",
"last_login_at": "..."
}
]
GET /api/v1/users/{user_id}
POST /api/v1/users

CSRF-protected.

{
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Doe",
"display_name": "Alice",
"password": "Str0ng-example-pass!",
"is_admin": false,
"timezone": "UTC"
}

Password is at most 256 characters and must satisfy the configured password policy: Z4J_PASSWORD_MIN_LENGTH defaults to 12, at least three of lowercase / uppercase / digit / symbol are required unless the password is 16+ characters, and common passwords are denied. It is hashed with argon2id. A policy failure on this route escapes as HTTP 500 rather than a validation envelope. The created user has no project memberships; add them through memberships or an invitation. timezone (default UTC) must be a valid IANA zone name such as America/New_York, on create and on update alike; any other value is 422.

PATCH /api/v1/users/{user_id}

CSRF-protected. Any subset of:

{
"first_name": "...",
"last_name": "...",
"display_name": "...",
"is_admin": true,
"is_active": false,
"timezone": "America/New_York"
}

email is not an update field, but unknown fields are ignored: sending an email key returns 200 with the old address rather than rejecting the request. Renaming therefore requires creating a new user and migrating memberships. An admin may not demote or deactivate their own account, nor demote or deactivate the final active is_admin=true user. Setting is_active to false revokes every session of that user immediately.

POST /api/v1/users/{user_id}/password

CSRF-protected.

{"new_password": "new-password-that-meets-policy"}

Resets the user's password without requiring the old one. The new value must satisfy the same password policy. Every existing session and remembered MFA device is removed immediately. The action writes a user.password.reset audit row attributed to the caller.

DELETE /api/v1/users/{user_id}

CSRF-protected and fresh-MFA/session-only. This is a permanent delete: memberships, sessions, API keys, preferences, and subscriptions cascade away; automation rules created by the user are disabled; and surviving audit rows and commands anonymize their actor reference. It refuses self-deletion and deletion of the final active admin. For a reversible operation that preserves the row and attribution, PATCH {"is_active": false} instead.

These routes back the dashboard's Settings, Admin pages. Caller must be is_admin=true.

GET /api/v1/admin/settings

Returns {"z4j_home": ..., "settings": [...]}: one row per Settings field (name, string-rendered value, source, is_secret, description), sorted by name. SecretStr values and recognized secret-shaped field names are masked as ***; database_url is masked by name. Other plain URL fields are not guaranteed to be masked: scheduler_trigger_url, for example, is returned as configured and can contain credentials. Treat the response as sensitive admin-only data, not as a shareable configuration export. The source labels mirror z4j config show on the CLI.

GET /api/v1/admin/system/versions

Returns the brain's cached latest-known-versions snapshot (bundled with the wheel, or the last one fetched via Check for updates), not the installed versions: packages maps package name to latest known version, plus schema_version, generated_at, generated_by, canonical_url, source (bundled or remote), fetched_at, fetched_from and check_for_updates_url. No network call. Used by the dashboard's Settings, System page.

POST /api/v1/admin/system/versions/check

CSRF-protected. Fetches the upstream version manifest (Z4J_VERSION_CHECK_URL, default points at the public repo) once per call, with no server-side cache, swaps it into the in-memory snapshot and returns the refreshed snapshot in the same shape as the GET; the dashboard computes what is newer. The check is read-only; nothing is installed. Returns 404 not_found (reason check_disabled) when Z4J_VERSION_CHECK_URL is empty and 409 conflict when the URL is not https:// or the fetch or validation fails; on failure the previous snapshot is unchanged.

GET /api/v1/admin/audit-forwarder

Instance admin, session cookie only: the route's tag has no API-key scope mapping, so every API key, admin:* included, gets 403 here.

Returns the durable cursor of the audit webhook forwarder: enabled (whether Z4J_AUDIT_WEBHOOK_URL is set), sink_id, worker (the leader-lock name), cursor_initialised, cursor_occurred_at and cursor_id (the last row the receiver acknowledged), lag_rows (audit rows past the cursor), the last attempt and success timestamps, consecutive_failures and backoff_seconds_remaining, the configured batch size, poll interval and maximum backoff, and the sent and failed counts of the replica answering. The cursor and lag are read from the database, so they are the same on every replica; the process counts are not. See the audit webhook page for the forwarding contract.

The setup window is active only while the entire users table is empty -- a non-admin row closes it too. Afterwards, POST /api/v1/setup/complete returns 409 conflict with reason="already_initialised"; GET /setup returns 404; and anonymous GET /api/v1/setup/status returns 401 (an authenticated caller gets {"first_boot": false}).

GET /api/v1/setup/status # is setup needed?
POST /api/v1/setup/complete # mint the first admin using a one-shot token

The setup form is also served at GET /setup as HTML for evaluators booting a brain locally.

complete takes {token, email, display_name, password}: token is 10 to 200 chars, email 3 to 320, display_name optional and at most 200, password 8 to 256. It returns {user, project_id} and sets the session cookies, so the new admin is signed in.

Token TTL is Z4J_FIRST_BOOT_TOKEN_TTL_SECONDS (default 15 minutes). A hard-coded process-local setup bucket permits only five completion requests per IP per 15 minutes. Z4J_FIRST_BOOT_ATTEMPTS_PER_IP (default 30) is a second, audit-log-backed budget; with defaults the five-request gate wins, so raising 30 does not permit more attempts.

For deployments that prefer to skip the form, set Z4J_BOOTSTRAP_ADMIN_EMAIL and Z4J_BOOTSTRAP_ADMIN_PASSWORD. Accepted values create the admin at boot and keep setup closed. Invalid email or password policy values do not fail startup closed: the brain logs the failure, falls back to minting a setup token, and prints a /setup?token=... URL. Confirm the z4j auto-bootstrap complete startup log message before assuming the form was never exposed. See first-admin.