StabisoDocs

API reference

Users

People who sign in to the web UI, their roles, and the UI's sign-in endpoints.

Operation Summary Scope Idempotency-Key
GET /v1/auth/config Get the sign-in settings
POST /v1/auth/google Sign in with Google
POST /v1/auth/logout Sign out of the web UI
GET /v1/auth/organizations List my organizations
POST /v1/auth/passkey Sign in with a passkey
POST /v1/auth/password Sign in with a password
POST /v1/auth/step-up Touch your passkey again
POST /v1/auth/step-up/options Ask for a fresh passkey touch
POST /v1/auth/switch Switch organization
GET /v1/me/passkeys List my passkeys
POST /v1/me/passkeys Add a passkey
POST /v1/me/passkeys/options Start adding a passkey
DELETE /v1/me/passkeys/{passkey_id} Remove a passkey
GET /v1/people List the people to choose from read
GET /v1/users List users admin
POST /v1/users Invite a user admin
PATCH /v1/users/{user_id} Update a user admin
POST /v1/users/{user_id}/passkeys/reset Reset a user's passkeys admin
POST /v1/users/{user_id}/password-email E-mail a user a link to set their password admin

GET /v1/auth/config

Get the sign-in settings

What the web UI's sign-in page needs: the Google OAuth client id (google_client_id, null when Google sign-in is not configured), where people without a Google account sign in with a password (password_sign_in, null when not configured) and whether the development login is on. It needs no credential and is not for API-key clients, which sign in with their key.

Required scope none (public)
Operation id auth_config_v1_auth_config_get

Example request

curl "$ERP_URL/v1/auth/config" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK AuthConfig

Example response (shape)

{
  "google_client_id": "string",
  "password_sign_in": {
    "api_key": "string",
    "project": "string"
  },
  "dev_login": true
}

POST /v1/auth/google

Sign in with Google

Exchange a Google ID token for a web UI session cookie.

This endpoint serves the web UI session and is not for API-key clients: a script authenticates with an API key (Authorization: Bearer <key>), never with this call. The caller must have been invited (POST /v1/users). On success the answer is passkey: every sign-in also needs the person's passkey (ADR-0101). The browser touches it, or creates the first one, with the options in passkey, and sends the answer to POST /v1/auth/passkey, which sets the erp_session cookie (HttpOnly, SameSite=Strict). When the address belongs to several organizations the answer is choose_organization with the list: repeat the call with the chosen org_id. No cookie is set by this call. Refused with 403 for an invalid token, a person who is not invited, a domain that may not sign in, or an org_id that is not theirs; with 422 when Google sign-in is not configured on the server.

Required scope none (public)
Idempotency-Key not used
Operation id google_login_v1_auth_google_post

Request body

application/json (required): GoogleLogin

Field Type Required Description
credential string yes Google ID token from Google Identity Services.
org_id string (uuid) or null Chosen organization.

Example request

curl -X POST "$ERP_URL/v1/auth/google" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..."
}'

Responses

Status Meaning Body
200 OK LoginResult
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "status": "choose_organization",
  "organizations": [
    {
      "org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "org_name": "string",
      "test_organization": false,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "copy_of": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  ],
  "expires_at": "2026-09-21T09:30:00Z",
  "passkey": {
    "setup": true,
    "options": {}
  }
}

POST /v1/auth/logout

Sign out of the web UI

End the web UI session whose cookie accompanies the request and clear the cookie. It serves the web UI and is not for API-key clients: with an API key there is no session to end and nothing happens. The response has no body.

Required scope any valid credential
Idempotency-Key not used
Operation id logout_v1_auth_logout_post

Example request

curl -X POST "$ERP_URL/v1/auth/logout" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
204 No content
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope

GET /v1/auth/organizations

List my organizations

The organizations the signed-in person may switch to, their current one included, as sign-in lists them (test_organization marks a test copy or demo). Only for a web UI session: an API key answers 403, as it belongs to one organization.

Required scope any valid credential
Operation id my_organizations_v1_auth_organizations_get

Example request

curl "$ERP_URL/v1/auth/organizations" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK array of Membership
401 Missing or invalid API key (unauthorized). ErrorEnvelope

Example response (shape)

[
  {
    "org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "org_name": "string",
    "test_organization": false,
    "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "copy_of": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
]

POST /v1/auth/passkey

Sign in with a passkey

The second step of every sign-in to the web UI (ADR-0101): the browser's answer to the passkey question of POST /v1/auth/google or POST /v1/auth/password. For a person without a passkey the answer creates their first (name names it); otherwise it is a touch of one of theirs. On success the answer is signed_in and the erp_session cookie is set (HttpOnly, SameSite=Strict). The question is valid once and for five minutes. Refused with 403 for an answer that does not verify (passkey_refused), a question that is used or expired (passkey_expired, sign in again) or a person who may no longer sign in; with 409 for a passkey already registered. A passkey made at the site's former address (it moved) is answered passkey once more: create the passkey of this address with that question and send the answer here; the session starts with it. Refusals count against the client address like refused sign-ins (429 past the limit). It serves the web UI and is not for API-key clients.

Required scope any valid credential
Idempotency-Key not used
Operation id passkey_login_v1_auth_passkey_post

Request body

application/json (required): PasskeyAnswer

Field Type Required Description
credential object yes The browser's answer, as JSON (PublicKeyCredential.toJSON()).
name string or null For a new passkey: a name the person recognises it by ("MacBook", "Phone"). Ignored for a touch. (max length 100)

Example request

curl -X POST "$ERP_URL/v1/auth/passkey" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "credential": {
    "id": "q2Vx...",
    "rawId": "q2Vx...",
    "response": {
      "authenticatorData": "SZYN...",
      "clientDataJSON": "eyJ0..."
    },
    "type": "public-key"
  }
}'

Responses

Status Meaning Body
200 OK LoginResult
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "status": "choose_organization",
  "organizations": [
    {
      "org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "org_name": "string",
      "test_organization": false,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "copy_of": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  ],
  "expires_at": "2026-09-21T09:30:00Z",
  "passkey": {
    "setup": true,
    "options": {}
  }
}

POST /v1/auth/password

Sign in with a password

Exchange the ID token of an e-mail and password sign-in at Google Identity Platform for a web UI session cookie, for people without a Google account (ADR-0037).

The browser sends the address and password to Google (accounts:signInWithPassword with the password_sign_in.api_key of GET /v1/auth/config), never to this server; Google keeps the password and refuses repeated wrong ones. This call takes the idToken Google returned. It serves the web UI and is not for API-key clients. The answers are those of POST /v1/auth/google: passkey (then POST /v1/auth/passkey), or choose_organization. Refused with 403 for an invalid token, an address that is not verified, a person who is not invited, a domain that may not sign in, or an org_id that is not theirs; with 409 when password sign-in is not configured. Failed sign-ins count against the client address (429 past the limit).

Required scope any valid credential
Idempotency-Key not used
Operation id password_login_v1_auth_password_post

Request body

application/json (required): PasswordLogin

Field Type Required Description
credential string yes The ID token Google Identity Platform returned for an e-mail and password sign-in (idToken of accounts:signInWithPassword).
org_id string (uuid) or null Chosen organization.

Example request

curl -X POST "$ERP_URL/v1/auth/password" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..."
}'

Responses

Status Meaning Body
200 OK LoginResult
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "status": "choose_organization",
  "organizations": [
    {
      "org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "org_name": "string",
      "test_organization": false,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "copy_of": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  ],
  "expires_at": "2026-09-21T09:30:00Z",
  "passkey": {
    "setup": true,
    "options": {}
  }
}

POST /v1/auth/step-up

Touch your passkey again

Check the browser's answer to POST /v1/auth/step-up/options and note the touch on the session: steps that ask for a fresh touch work for five minutes from now. 403 for an answer that does not verify, a used or expired question, another session's question or an API key.

Required scope any valid credential
Idempotency-Key not used
Operation id step_up_v1_auth_step_up_post

Request body

application/json (required): PasskeyAnswer

Field Type Required Description
credential object yes The browser's answer, as JSON (PublicKeyCredential.toJSON()).
name string or null For a new passkey: a name the person recognises it by ("MacBook", "Phone"). Ignored for a touch. (max length 100)

Example request

curl -X POST "$ERP_URL/v1/auth/step-up" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "credential": {
    "id": "q2Vx...",
    "rawId": "q2Vx...",
    "response": {
      "authenticatorData": "SZYN...",
      "clientDataJSON": "eyJ0..."
    },
    "type": "public-key"
  }
}'

Responses

Status Meaning Body
200 OK StepUpDone
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "passkey_at": "2026-09-21T09:30:00Z"
}

POST /v1/auth/step-up/options

Ask for a fresh passkey touch

The question for a fresh touch of the signed-in person's passkey, which a risky step asks for (403 passkey_step_up with within_minutes): touch it in the browser with these options, send the answer to POST /v1/auth/step-up, then repeat the step. Only for a web UI session (403 for an API key).

Required scope any valid credential
Idempotency-Key not used
Operation id step_up_options_v1_auth_step_up_options_post

Example request

curl -X POST "$ERP_URL/v1/auth/step-up/options" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK PasskeyStep
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope

Example response (shape)

{
  "setup": true,
  "options": {}
}

POST /v1/auth/switch

Switch organization

Move the signed-in person to another of their organizations (GET /v1/auth/organizations) without signing in again, for example from a test copy back to the real organization. The current session ends and a new one starts in org_id, checked as a sign-in with the same account would be: the answer and the erp_session cookie are those of signed_in. The new session ends when the current one would have: a switch never extends a sign-in. Refused with 403 for an org_id that is not theirs and for an API key, and with 409 for the organization the person is already in. A suspended organization may still be left this way. Recorded as user.login with via: switch and the organization left (from_org).

Required scope any valid credential
Idempotency-Key not used
Operation id switch_organization_v1_auth_switch_post

Request body

application/json (required): OrganizationSwitch

Field Type Required Description
org_id string (uuid) yes Id (uuid) of the organization to switch to, from GET /v1/auth/organizations.

Example request

curl -X POST "$ERP_URL/v1/auth/switch" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "org_id": "3f0c2a9e-5b7d-4e1a-9c8b-2d6f4a1e7b30"
}'

Responses

Status Meaning Body
200 OK LoginResult
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "status": "choose_organization",
  "organizations": [
    {
      "org_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "org_name": "string",
      "test_organization": false,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "copy_of": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
    }
  ],
  "expires_at": "2026-09-21T09:30:00Z",
  "passkey": {
    "setup": true,
    "options": {}
  }
}

GET /v1/me/passkeys

List my passkeys

The signed-in person's passkeys, oldest first. They open every organization the person is a user of. Only for a web UI session (403 for an API key).

Required scope any valid credential
Operation id my_passkeys_v1_me_passkeys_get

Example request

curl "$ERP_URL/v1/me/passkeys" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK array of PasskeyOut
401 Missing or invalid API key (unauthorized). ErrorEnvelope

Example response (shape)

[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "string",
    "created_at": "2026-09-21T09:30:00Z",
    "last_used_at": "2026-09-21T09:30:00Z",
    "backed_up": true
  }
]

POST /v1/me/passkeys

Add a passkey

Record the passkey the browser created for POST /v1/me/passkeys/options, named name. Needs a passkey touch within five minutes (403 passkey_step_up); 403 for an answer that does not verify or a used or expired question; 409 for a passkey already registered. Recorded as a security event (passkey.add).

Required scope any valid credential
Idempotency-Key not used
Operation id add_passkey_v1_me_passkeys_post

Request body

application/json (required): PasskeyAnswer

Field Type Required Description
credential object yes The browser's answer, as JSON (PublicKeyCredential.toJSON()).
name string or null For a new passkey: a name the person recognises it by ("MacBook", "Phone"). Ignored for a touch. (max length 100)

Example request

curl -X POST "$ERP_URL/v1/me/passkeys" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "credential": {
    "id": "q2Vx...",
    "rawId": "q2Vx...",
    "response": {
      "authenticatorData": "SZYN...",
      "clientDataJSON": "eyJ0..."
    },
    "type": "public-key"
  }
}'

Responses

Status Meaning Body
201 Created PasskeyOut
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name": "string",
  "created_at": "2026-09-21T09:30:00Z",
  "last_used_at": "2026-09-21T09:30:00Z",
  "backed_up": true
}

POST /v1/me/passkeys/options

Start adding a passkey

The question to create one more passkey, for another device: create it in the browser with these options and send the answer to POST /v1/me/passkeys. Needs a passkey touch within five minutes (403 passkey_step_up); only for a web UI session.

Required scope any valid credential
Idempotency-Key not used
Operation id add_passkey_options_v1_me_passkeys_options_post

Example request

curl -X POST "$ERP_URL/v1/me/passkeys/options" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK PasskeyStep
401 Missing or invalid API key (unauthorized). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope

Example response (shape)

{
  "setup": true,
  "options": {}
}

DELETE /v1/me/passkeys/{passkey_id}

Remove a passkey

Remove one of the signed-in person's passkeys (a lost or replaced device). Needs a passkey touch within five minutes (403 passkey_step_up); 409 for the last one (passkey_last: add another first); 404 for a passkey that is not theirs. Recorded as a security event (passkey.remove). The response has no body.

Required scope any valid credential
Idempotency-Key not used
Operation id remove_passkey_v1_me_passkeys__passkey_id__delete

Parameters

In Name Type Required Description
path passkey_id string (uuid) yes Passkey id (GET /v1/me/passkeys).

Example request

curl -X DELETE "$ERP_URL/v1/me/passkeys/3fa85f64-5717-4562-b3fc-2c963f66afa6" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
204 No content
401 Missing or invalid API key (unauthorized). ErrorEnvelope
404 The resource does not exist, or belongs to another organization (not_found). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

GET /v1/people

List the people to choose from

The active people of the organization (id, email, name) ordered by name, to choose the owner of a deal or a close task from. Any credential with the read scope may list them; invitations, scopes and their management are GET /v1/users (admin scope).

Required scope read (admin implies every scope)
Operation id list_people_v1_people_get

Example request

curl "$ERP_URL/v1/people" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK array of Person
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope

Example response (shape)

[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "email": "string",
    "name": "string"
  }
]

GET /v1/users

List users

The people of the organization (invited, active or disabled) ordered by email, with their scopes. Only a human with the admin scope may list them (403 otherwise).

Required scope admin (admin implies every scope)
Operation id list_users_v1_users_get

Example request

curl "$ERP_URL/v1/users" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK array of UserOut
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope

Example response (shape)

[
  {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "email": "string",
    "name": "string",
    "scopes": [
      "string"
    ],
    "status": "string",
    "created_at": "2026-09-21T09:30:00Z",
    "last_login_at": "2026-09-21T09:30:00Z",
    "locale": "string",
    "access_until": "2026-09-21T09:30:00Z"
  }
]

POST /v1/users

Invite a user

Invite a person by email; they sign in to the web UI with the Google account of that address, or, without one, with the address and a password, which activates the user. When password sign-in is configured, Google also e-mails them the link that sets their password (as POST /v1/users/{user_id}/password-email), except to an address of the deployment's Google Workspace domains, whose people sign in with Google. The access is a role preset (viewer by default) or an explicit list of scopes, which wins when both are sent. Only a human with the admin scope may invite (403 otherwise); 409 when the address is already a user of this organization; 422 for an unknown scope; 502 when Google refuses the e-mail (nobody is invited, the request can be repeated). The new user has status invited.

An external auditor (the auditor role) is invited with access_until, the moment their access ends (422 access_until_missing without it): they read everything and change nothing, and every request they make goes to the access log (GET /v1/access-log).

Required scope admin (admin implies every scope)
Idempotency-Key not used
Operation id invite_user_v1_users_post

Request body

application/json (required): InviteInput

Field Type Required Description
email string yes The person's email address; they sign in with the Google account of this address, or, without one, with this address and a password. Stored in lower case. (pattern ^[^@\s]+@[^@\s]+\.[^@\s]+$)
name string or null The person's name, shown in the UI; filled from Google at first sign-in when omitted.
role "accountant" | "admin" | "auditor" | "controller" | "viewer" or null Preset scopes; or pass scopes.
scopes array of string or null Explicit scopes (read, draft, post, approve, admin, audit). When given and not empty it wins over role.
access_until string (date-time) or null When the person's access ends (ISO 8601; without a time zone, UTC): in the future, at most two years ahead. Required for an external auditor (the auditor role, or read and audit without a right that changes anything): every request they make is then recorded in the access log (GET /v1/access-log).
approver ApproverDeclaration or null Who the person is, declared by the administrator: required when the access lets the person approve (controller, admin, or the approve scope) (422 approver_declaration_missing); 409 approver_same_person for an address or name that is plainly the administrator's own. Recorded in the audit trail and shown in the approver register (GET /v1/approvers).

Example request

curl -X POST "$ERP_URL/v1/users" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "anna.keller@example.com",
  "name": "Anna Keller",
  "role": "accountant"
}'

Responses

Status Meaning Body
201 Created InvitedUser
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "email": "string",
  "name": "string",
  "scopes": [
    "string"
  ],
  "status": "string",
  "created_at": "2026-09-21T09:30:00Z",
  "last_login_at": "2026-09-21T09:30:00Z",
  "locale": "string",
  "access_until": "2026-09-21T09:30:00Z",
  "password_email_sent": true
}

PATCH /v1/users/{user_id}

Update a user

Change a user's name, access or status. Send a role preset or a list of scopes, not both (422); only the fields sent change. Setting the status to disabled also ends the user's sessions. access_until moves the end of the user's access (null removes it, except for an external auditor). You cannot remove your own admin scope, disable yourself or set an end to your own access (409). Only a human with the admin scope may do this (403 otherwise); an unknown user answers 404.

Required scope admin (admin implies every scope)
Idempotency-Key not used
Operation id update_user_v1_users__user_id__patch

Parameters

In Name Type Required Description
path user_id string (uuid) yes User id (GET /v1/users).

Request body

application/json (required): UserUpdate

Field Type Required Description
name string or null New display name; omit to keep it.
role "accountant" | "admin" | "auditor" | "controller" | "viewer" or null A preset replacing the user's scopes: viewer (read), auditor (read, audit), accountant (read, draft, post), controller (plus approve) or admin (all). Not together with scopes.
scopes array of string or null Explicit scopes replacing the user's: read, draft, post, approve, admin, audit. Not together with role.
access_until string (date-time) or null A new end of the person's access (in the future, at most two years ahead), or null to remove the end (not for an external auditor, whose access always ends). Omit to keep it. To end access now, disable the person. Nobody sets their own.
status "active" | "disabled" or null active or disabled; disabling ends the user's sessions, blocks sign-in and revokes the API keys they issued (and the keys those issued). Omit to keep it.
approver ApproverDeclaration or null Who the person is, declared by the administrator: required when the new access lets the person approve and the old did not (422 approver_declaration_missing; 409 approver_same_person). Recorded in the audit trail and the approver register.

Example request

curl -X PATCH "$ERP_URL/v1/users/3fa85f64-5717-4562-b3fc-2c963f66afa6" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "role": "controller"
}'

Responses

Status Meaning Body
200 OK UserOut
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope
404 The resource does not exist, or belongs to another organization (not_found). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "email": "string",
  "name": "string",
  "scopes": [
    "string"
  ],
  "status": "string",
  "created_at": "2026-09-21T09:30:00Z",
  "last_login_at": "2026-09-21T09:30:00Z",
  "locale": "string",
  "access_until": "2026-09-21T09:30:00Z"
}

POST /v1/users/{user_id}/passkeys/reset

Reset a user's passkeys

For a person who lost every device with a passkey: remove their passkeys and end their sessions; their next sign-in creates a new first passkey. Only a human administrator may do this, with a passkey touch within five minutes (403 passkey_step_up), and not for themselves (409). For a person who is also a user of another organization (other than this one's test copies) it answers 409 passkey_reset_elsewhere: their passkeys open that organization too, so the platform's operator resets them. 404 for an unknown user. Recorded in the audit trail (user.passkey_reset) and as a security event (passkey.reset).

Required scope admin (admin implies every scope)
Idempotency-Key not used
Operation id reset_passkeys_v1_users__user_id__passkeys_reset_post

Parameters

In Name Type Required Description
path user_id string (uuid) yes User id (GET /v1/users).

Example request

curl -X POST "$ERP_URL/v1/users/3fa85f64-5717-4562-b3fc-2c963f66afa6/passkeys/reset" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
200 OK PasskeyReset
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope
404 The resource does not exist, or belongs to another organization (not_found). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "removed": 1
}

POST /v1/users/{user_id}/password-email

E-mail a user a link to set their password

Have Google e-mail the user a link that sets the password of their account at Google Identity Platform, for a person without a Google account (ADR-0037). It creates the account when there is none, sets a first password and resets a forgotten one alike; an existing password keeps working until the link is used. Google sends the e-mail; the ERP never sees the password. Only a human with the admin scope may do this (403 otherwise); 404 for an unknown user; 409 for a disabled user or when password sign-in is not configured; 502 when Google refuses. Recorded in the audit trail (user.password_email).

Required scope admin (admin implies every scope)
Idempotency-Key not used
Operation id send_password_email_v1_users__user_id__password_email_post

Parameters

In Name Type Required Description
path user_id string (uuid) yes User id (GET /v1/users).

Example request

curl -X POST "$ERP_URL/v1/users/3fa85f64-5717-4562-b3fc-2c963f66afa6/password-email" \
  -H "Authorization: Bearer $ERP_KEY"

Responses

Status Meaning Body
202 Successful Response PasswordEmailSent
401 Missing or invalid API key (unauthorized). ErrorEnvelope
403 The caller lacks the required scope (missing_right, with scope), the key belongs to no person where a person is needed (forbidden), or the approval policy denies the action (policy_denied, with action and rule_id). ErrorEnvelope
404 The resource does not exist, or belongs to another organization (not_found). ErrorEnvelope
409 The request conflicts with the data: an Idempotency-Key reused with a different request, a closed period, or an integrity rule of the database (conflict, integrity_violation). ErrorEnvelope
422 Validation failed: the error envelope (validation_failed). For a malformed body or parameter its message is in sentences ("Line 2: the quantity must be above zero") and fields lists each error with pydantic's loc, type and msg beside its sentence (message) and its parts, to word it in another language: items (name and number from 1: line 2), field (its name as sent) and the rule it breaks with its limit, count and choices (none for a validator's own sentence); FastAPI's own detail list stays beside the envelope. HTTPValidationError

Example response (shape)

{
  "email": "string"
}

Schemas

AuthConfig

Field Type Required Description
google_client_id string or null yes The Google OAuth client id the web UI signs in with; null when Google sign-in is not configured.
password_sign_in PasswordSignIn or null Where people without a Google account sign in with their e-mail address and a password (Google Identity Platform); null when it is not configured.
dev_login boolean yes True when the development login is enabled (never in the cloud).

GoogleLogin

Field Type Required Description
credential string yes Google ID token from Google Identity Services.
org_id string (uuid) or null Chosen organization.

InviteInput

Field Type Required Description
email string yes The person's email address; they sign in with the Google account of this address, or, without one, with this address and a password. Stored in lower case. (pattern ^[^@\s]+@[^@\s]+\.[^@\s]+$)
name string or null The person's name, shown in the UI; filled from Google at first sign-in when omitted.
role "accountant" | "admin" | "auditor" | "controller" | "viewer" or null Preset scopes; or pass scopes.
scopes array of string or null Explicit scopes (read, draft, post, approve, admin, audit). When given and not empty it wins over role.
access_until string (date-time) or null When the person's access ends (ISO 8601; without a time zone, UTC): in the future, at most two years ahead. Required for an external auditor (the auditor role, or read and audit without a right that changes anything): every request they make is then recorded in the access log (GET /v1/access-log).
approver ApproverDeclaration or null Who the person is, declared by the administrator: required when the access lets the person approve (controller, admin, or the approve scope) (422 approver_declaration_missing); 409 approver_same_person for an address or name that is plainly the administrator's own. Recorded in the audit trail and shown in the approver register (GET /v1/approvers).

InvitedUser

Field Type Required Description
id string (uuid) yes Id (uuid) of the user.
email string yes The sign-in address, lower case.
name string or null yes Display name; null until set.
scopes array of string yes The scopes the user holds: some of read, draft, post, approve, admin, audit.
status string yes invited (never signed in), active or disabled.
created_at string (date-time) yes When the user was invited (ISO 8601, UTC).
last_login_at string (date-time) or null yes The last sign-in; null if never.
locale string or null The user's UI language (en, de-CH, fr-CH, it-CH, sv-SE); null follows the browser.
access_until string (date-time) or null When the person's access ends (ISO 8601, UTC): from then on they cannot sign in and their sessions stop. Null for no end. An external auditor always has one.
password_email_sent boolean yes True when Google e-mailed the person the link that sets their password (false when password sign-in is not configured, or for an address of a Google Workspace domain, whose people sign in with Google).

OrganizationSwitch

Field Type Required Description
org_id string (uuid) yes Id (uuid) of the organization to switch to, from GET /v1/auth/organizations.

PasskeyAnswer

Field Type Required Description
credential object yes The browser's answer, as JSON (PublicKeyCredential.toJSON()).
name string or null For a new passkey: a name the person recognises it by ("MacBook", "Phone"). Ignored for a touch. (max length 100)

PasskeyOut

Field Type Required Description
id string (uuid) yes Id (uuid) of the passkey.
name string yes The name the person gave it.
created_at string (date-time) yes When it was created (ISO 8601, UTC).
last_used_at string (date-time) or null yes When it was last touched (ISO 8601, UTC).
backed_up boolean yes true when a password manager keeps a copy (iCloud Keychain, Google Password Manager), so a new device gets it; false for a key bound to one device.

PasskeyReset

Field Type Required Description
removed integer yes How many passkeys were removed.

PasswordEmailSent

Field Type Required Description
email string yes The address Google sent the e-mail to.

PasswordLogin

Field Type Required Description
credential string yes The ID token Google Identity Platform returned for an e-mail and password sign-in (idToken of accounts:signInWithPassword).
org_id string (uuid) or null Chosen organization.

PasswordSignIn

Field Type Required Description
api_key string yes The browser API key of Google Identity Platform (public, like an OAuth client id).
project string yes The Google Cloud project the password accounts live in.

StepUpDone

Field Type Required Description
passkey_at string (date-time) yes When the passkey was touched (ISO 8601, UTC): steps that ask for a fresh touch work for five minutes from then.

UserOut

Field Type Required Description
id string (uuid) yes Id (uuid) of the user.
email string yes The sign-in address, lower case.
name string or null yes Display name; null until set.
scopes array of string yes The scopes the user holds: some of read, draft, post, approve, admin, audit.
status string yes invited (never signed in), active or disabled.
created_at string (date-time) yes When the user was invited (ISO 8601, UTC).
last_login_at string (date-time) or null yes The last sign-in; null if never.
locale string or null The user's UI language (en, de-CH, fr-CH, it-CH, sv-SE); null follows the browser.
access_until string (date-time) or null When the person's access ends (ISO 8601, UTC): from then on they cannot sign in and their sessions stop. Null for no end. An external auditor always has one.

UserUpdate

Field Type Required Description
name string or null New display name; omit to keep it.
role "accountant" | "admin" | "auditor" | "controller" | "viewer" or null A preset replacing the user's scopes: viewer (read), auditor (read, audit), accountant (read, draft, post), controller (plus approve) or admin (all). Not together with scopes.
scopes array of string or null Explicit scopes replacing the user's: read, draft, post, approve, admin, audit. Not together with role.
access_until string (date-time) or null A new end of the person's access (in the future, at most two years ahead), or null to remove the end (not for an external auditor, whose access always ends). Omit to keep it. To end access now, disable the person. Nobody sets their own.
status "active" | "disabled" or null active or disabled; disabling ends the user's sessions, blocks sign-in and revokes the API keys they issued (and the keys those issued). Omit to keep it.
approver ApproverDeclaration or null Who the person is, declared by the administrator: required when the new access lets the person approve and the old did not (422 approver_declaration_missing; 409 approver_same_person). Recorded in the audit trail and the approver register.
Stabiso, the Enterprise System of Record · generated from the code and its guides · release 6f03a5e846b3