Skip to main content

Users, invites and lifecycle

Companion to Identity and authentication. Everything here lives under /api/v1; tenant-scoped routes need X-Tenant-ID.

Invitations​

An invitation is a single-use, expiring link: {DASHBOARD_URL}/accept-invite?token=.... Only the SHA-256 of the random token is stored (invites.token_hash); the plain token exists only in the email. Clear-text password emails no longer exist.

EndpointAuthNotes
POST /invites {email, role, account_id?, expires_in_hours?}session, invites:write201. role is the key of one of the tenant's assignable roles (GET /users/assignable-roles), and you can only invite a role whose permissions you hold in full (403 otherwise); account_id (optional) restricts a user with a client-kind role to one account and is rejected for other kinds. A platform role and the customer role cannot be invited (400). Supports Idempotency-Key.
GET /invites?status=&email=&page=&limit=session, invites:readstatus: pending, accepted, expired, revoked.
POST /invites/{id}/resendsessionNew token (old link dies), new expiry, email re-sent. 400 if accepted/revoked.
DELETE /invites/{id}sessionRevokes. 204.
GET /invites/validate?token=public{email, role, role_name, tenant_name, account_name, expires_at}; uniform 404 for unknown, expired, revoked or used tokens.
POST /invites/accept {token, first_name, last_name, password}publicCreates the user, grants tenant (and account) membership with granted_by = inviter, marks accepted, signs in with the same cookies as /auth/login.

Statuses: pending -> accepted or revoked; expired is derived (pending past expires_at, default 72 h). A new invite for the same email and scope revokes the older pending one.

An email that already has an active login only gets the grant added (roles are per tenant, so the same person can hold different roles in different tenants; an existing role is replaced by the invited one); its password and names are left alone (a login with no password yet, e.g. from the CSV import, gets the chosen one). Incompatible or suspended accounts, and every other failure, answer the same 404, so nothing reveals whether an email exists. If the account has MFA the memberships are added but signed_in is false (no session without the second factor).

The legacy send_invite flag on POST /users and POST /accounts/{id}/members still exists but now emails an invitation link instead of the password (POST /users adds Deprecation: true).

Lifecycle​

users.status: active ↔ suspended, and deleted (soft delete). users.active is generated from status/deleted_at; code never writes it.

EndpointEffect
POST /users/{id}/suspend, /unsuspendBlock/allow sign-in; suspend revokes all sessions.
POST /users/{id}/unlockClears lockout and failure counter.
POST /users/{id}/deleteSoft delete: status=deleted, deleted_at, all memberships removed, sessions revoked.
POST /users/{id}/restoreBack to active, re-granted the tenant's member role.
POST /users/{id}/revoke-sessions{revoked: n}.
POST /users/{id}/mfa/resetClears MFA secret and recovery codes, signs out.
DELETE /users/{id}Unchanged: removes the user from the current tenant only.

Safeguards (single and bulk): not yourself (400), nobody holding permissions you lack (403), never the last active superadmin (409), deleted users must be restored first (409). The email of a deleted user stays reserved; only an admin restore brings the account back. All actions are audited with domain='user'.

Directory​

GET /users filters: role, kind, status, mfa_enabled, locked, last_login_before/after, escalation_level, search, sort (created_at|last_login_at|email), order, plus include_deleted (admin). UserRead adds status, last_login_at, mfa_enabled, locked_until, locked, escalation_level, created_at. PATCH /users/{id} accepts escalation_level (L1..L5 or null, staff roles only: superadmin, tenant admin, operator).

Bulk: POST /users/bulk {action, user_ids[<=500], value?}, actions suspend, unsuspend, delete, set_role, revoke_sessions, set_escalation_level. Always 200 with per-user results[{user_id, ok, error, status_code}]; one failure does not stop the rest.

CSV: GET /users/export streams text/csv with the list filters (no secrets; formula-like cells are apostrophe-prefixed). POST /users/import (multipart file, dry_run=true default) reads email, role[, first_name, last_name, account_id, escalation_level], max 1000 rows / 1 MiB; real runs create users with no password, grant memberships and email invitation links; errors are reported per row.

Self profile and preferences​

GET/PATCH /users/me (names, optionally embedded preferences), GET/PATCH /users/me/preferences (timezone validated by zoneinfo, locale, density compact|comfortable, notification_channels subset of in_app|email|sse; null resets). Email change: POST /users/me/email {new_email, password} mails a 1 h single-use link to the new address (always 202, even if the address is taken); POST /users/me/email/confirm {token} applies it and signs out the other sessions. Password change is POST /me/password (see identity).

Idempotency​

Idempotency-Key is honoured on POST /users, POST /invites and POST /users/bulk (app/core/idempotency.py, table idempotency_keys, scope tenant + caller + key). Same key and payload replays the stored response (Idempotent-Replayed: true); same key with a different payload is 422; while the first call runs it is 409; failures release the key. Entries expire after 24 h. Use run_idempotent(...) to add it to another endpoint.