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. |