API reference
Auth
Sign up, log in, manage sessions, passwords, two-factor login and API keys.
These endpoints create a workspace, log a person in, and manage that login's own security. A login returns a session token (vs- + 32 hex characters) that works as a Bearer token on every endpoint, exactly like an API key. Session tokens expire after 7 days.
Several operations here only make sense for a person, so they refuse an API key with 403 and the message This needs a dashboard login, not an API key. They are marked "session only" below. See Authentication for how keys and sessions differ, and Team for roles.
| Operation | Method and path | Minimum role |
|---|---|---|
| Sign up | POST /auth/signup | none |
| Log in | POST /auth/login | none |
| Complete a two-factor login | POST /auth/login/2fa | none |
| Log out | POST /auth/logout | viewer |
| Get the current login | GET /auth/me | viewer |
| Confirm an email address | POST /auth/verify-email | none |
| Resend the confirmation email | POST /auth/resend-verification | viewer, session only |
| Request a password reset | POST /auth/forgot-password | none |
| Reset a password | POST /auth/reset-password | none |
| Change a password | POST /auth/change-password | viewer, session only |
| Start two-factor setup | POST /auth/2fa/setup | viewer, session only |
| Turn two-factor login on | POST /auth/2fa/enable | viewer, session only |
| Turn two-factor login off | POST /auth/2fa/disable | viewer, session only |
| List sessions | GET /auth/sessions | viewer, session only |
| Revoke a session | DELETE /auth/sessions/{session_id} | viewer, session only |
| Log out everywhere | POST /auth/sessions/revoke-all | viewer, session only |
| Read an invitation | POST /auth/invite-info | none |
| Accept an invitation | POST /auth/accept-invite | none |
| List API keys | GET /auth/api-keys | admin |
| Create an API key | POST /auth/api-keys | admin |
| Revoke an API key | DELETE /auth/api-keys/{key_id} | admin |
Note: Emails (confirmation, reset, invitation, "password changed") go through the operator's email backend. The default backend stores each email in an outbox table and sends nothing; real delivery needs the operator to configure SMTP.
Password rules
Every endpoint that sets a password applies the same policy.
| Status | Message |
|---|---|
| 400 | Password must be at least 8 characters |
| 400 | Password must be at most 200 characters |
| 400 | Password must not be your email address |
| 400 | This password is too common. Choose one that is harder to guess. |
Accounts and login
Sign up
POST /auth/signup
Creates a workspace, its first login (the owner) and a session. The wallet starts with the operator-configured sign-up credit and the workspace is put on the operator's sign-up plan. A confirmation email is issued; its link is valid for 24 hours.
Auth: none.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
name | body | string | yes | The workspace and owner name. |
email | body | string | yes | Stored lower-cased. Must not already have a login. |
password | body | string | yes | See Password rules. |
Request
curl -X POST https://api.sevennadi.si/auth/signup \
-H "Content-Type: application/json" \
-d '{"name": "Example Bank", "email": "asha@example.test", "password": "a-long-passphrase-42"}'Response 201 Created
{
"token": "vs-0123456789abcdef0123456789abcdef",
"account": {"id": "03d064aa-fceb-4c80-b915-2b6090c7021f", "name": "Example Bank", "email": "asha@example.test", "wallet": 5.0},
"member": {"id": "0144c9aa-d4fe-4341-99fd-aa27689ab6a0", "name": "Example Bank", "email": "asha@example.test", "role": "owner"},
"role": "owner",
"email_verified": false
}wallet is in major units of the billing currency. The token is shown once.
Errors
| Status | Message |
|---|---|
| 400 | name is required |
| 400 | A valid email is required |
| 400 | One of the password rules |
| 409 | An account with this email already exists |
Log in
POST /auth/login
Checks an email and password. Without two-factor login it returns a session. With two-factor login it returns a challenge to exchange at POST /auth/login/2fa.
Auth: none.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
email | body | string | yes | The login's email address. |
password | body | string | yes | The login's password. |
Request
curl -X POST https://api.sevennadi.si/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "asha@example.test", "password": "a-long-passphrase-42"}'Response 200 OK
{
"token": "vs-0123456789abcdef0123456789abcdef",
"account": {"id": "03d064aa-fceb-4c80-b915-2b6090c7021f", "name": "Example Bank", "email": "asha@example.test", "wallet": 5.0},
"member": {"id": "0144c9aa-d4fe-4341-99fd-aa27689ab6a0", "name": "Asha Rao", "email": "asha@example.test", "role": "owner"},
"role": "owner"
}When two-factor login is on, the response is instead:
{"two_factor_required": true, "challenge": "Zk3v...opaque-string...Q"}Rate limit: 10 failed attempts per email address, and 10 per client address, in a 15-minute window. After that every attempt is refused until the window ends.
Errors
| Status | Message |
|---|---|
| 401 | Invalid email or password |
| 403 | This login has been disabled. Ask an admin of your workspace. |
| 429 | Too many login attempts. Try again in 15 minutes. |
Complete a two-factor login
POST /auth/login/2fa
Exchanges the challenge from POST /auth/login and a code for a session. The challenge is valid for 5 minutes. Send either code (from the authenticator app) or recovery_code. Each recovery code works once.
Auth: none.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
challenge | body | string | yes | The challenge returned by the login. |
code | body | string | one of | The current 6-digit code. |
recovery_code | body | string | one of | A recovery code, for example abcde-fgh23. |
Request
curl -X POST https://api.sevennadi.si/auth/login/2fa \
-H "Content-Type: application/json" \
-d '{"challenge": "Zk3v...opaque-string...Q", "code": "123456"}'Response 200 OK
The same body as a login, plus two_factor ("totp" or "recovery"). After a recovery code it also has recovery_codes_left.
{
"token": "vs-0123456789abcdef0123456789abcdef",
"account": {"id": "03d064aa-fceb-4c80-b915-2b6090c7021f", "name": "Example Bank", "email": "asha@example.test", "wallet": 5.0},
"member": {"id": "0144c9aa-d4fe-4341-99fd-aa27689ab6a0", "name": "Asha Rao", "email": "asha@example.test", "role": "owner"},
"role": "owner",
"two_factor": "totp"
}Rate limit: 5 wrong codes lock the second step for that login for 15 minutes and discard the challenge.
Errors
| Status | Message |
|---|---|
| 401 | This sign-in has expired. Enter your email and password again. |
| 401 | That code isn't right. Try again. |
| 429 | Too many incorrect codes. Try again in 15 minutes. |
Log out
POST /auth/logout
Revokes the session token used for the request. Called with an API key it does nothing and still answers success.
Auth: API key or session token. Role: viewer and up.
Request
curl -X POST https://api.sevennadi.si/auth/logout \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{"message": "success", "state": "logged_out"}Errors
| Status | Message |
|---|---|
| 401 | Access denied |
| 401 | Session expired |
Get the current login
GET /auth/me
Returns who the token belongs to, its role, and the permission table the server enforces.
Auth: API key or session token. Role: viewer and up.
Request
curl https://api.sevennadi.si/auth/me \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{
"account": {"id": "03d064aa-fceb-4c80-b915-2b6090c7021f", "name": "Example Bank", "email": "asha@example.test", "wallet": 5.0},
"member": {"id": "0144c9aa-d4fe-4341-99fd-aa27689ab6a0", "name": "Asha Rao", "email": "asha@example.test", "role": "owner"},
"role": "owner",
"auth": "session",
"email_verified": false,
"two_factor_enabled": false,
"require_verified_email": false,
"dev_outbox": true,
"telephony_live": false,
"email_delivery": "outbox",
"permissions": [
{"methods": ["*"], "path": "/team/transfer-ownership", "role": "owner", "area": "transfer ownership"}
]
}With an API key, member is null, auth is "api_key" and role is "admin". permissions is the full rule list; see GET /team/permissions. require_verified_email tells you whether the operator requires a confirmed email address before placing calls or creating API keys.
telephony_live is false while the platform runs simulated (mock) telephony: calls complete but no phone rings. email_delivery says how email leaves the platform: smtp and resend deliver it; outbox keeps it in the development outbox, dryrun only logs it and off drops it, so with these three nobody receives the email.
Errors
| Status | Message |
|---|---|
| 401 | Access denied |
| 401 | Session expired |
Email confirmation
Confirm an email address
POST /auth/verify-email
Confirms the address with the single-use token from the confirmation email. The token is valid for 24 hours.
Auth: none (the token is the proof).
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
token | body | string | yes | The token from the link in the email. |
Request
curl -X POST https://api.sevennadi.si/auth/verify-email \
-H "Content-Type: application/json" \
-d '{"token": "TOKEN_FROM_THE_EMAIL"}'Response 200 OK
{"message": "Email address confirmed", "state": "verified", "email": "asha@example.test"}Errors
| Status | Message |
|---|---|
| 400 | This verification link is invalid or has expired. Request a new one. |
Resend the confirmation email
POST /auth/resend-verification
Issues a new confirmation link for the logged-in person.
Auth: session token only. Role: viewer and up.
Request
curl -X POST https://api.sevennadi.si/auth/resend-verification \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{"message": "We sent a new link to asha@example.test", "state": "sent"}If the address is already confirmed, the response is {"message": "This email address is already confirmed", "state": "already_verified"}.
Rate limit: 3 per login per hour.
Errors
| Status | Message |
|---|---|
| 403 | This needs a dashboard login, not an API key. |
| 429 | We've already sent several links. Check your inbox, or try again in an hour. |
Passwords
Request a password reset
POST /auth/forgot-password
Issues a reset link if the email has a login. The answer is the same whether or not it does, so the endpoint cannot be used to discover addresses. The link works once, for 1 hour.
Auth: none.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
email | body | string | yes | The login's email address. |
Request
curl -X POST https://api.sevennadi.si/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email": "asha@example.test"}'Response 200 OK
{"message": "If that email has a login, we've sent a link to reset the password.", "state": "sent"}Rate limit: 20 requests per client address per hour (then 429). At most 3 links per email address per hour; further requests get the same 200 answer and no email.
Errors
| Status | Message |
|---|---|
| 429 | Too many requests. Try again in an hour. |
Reset a password
POST /auth/reset-password
Sets a new password with the token from the reset email. It also confirms the email address, ends every session of that login, and issues a "password changed" email. An invalid password does not use up the token.
Auth: none (the token is the proof).
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
token | body | string | yes | The token from the reset link. |
password | body | string | yes | The new password. See Password rules. |
Request
curl -X POST https://api.sevennadi.si/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"token": "TOKEN_FROM_THE_EMAIL", "password": "another-long-passphrase-7"}'Response 200 OK
{"message": "Password changed. Log in with the new password.", "state": "reset"}Errors
| Status | Message |
|---|---|
| 400 | This reset link is invalid or has expired. Request a new one. |
| 400 | One of the password rules |
Change a password
POST /auth/change-password
Changes the logged-in person's password. Every other session of that login is ended; the one making the request stays logged in.
Auth: session token only. Role: viewer and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
current_password | body | string | yes | The password in use now. |
new_password | body | string | yes | See Password rules. Must differ from the current one. |
Request
curl -X POST https://api.sevennadi.si/auth/change-password \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"current_password": "a-long-passphrase-42", "new_password": "another-long-passphrase-7"}'Response 200 OK
{"message": "Password changed", "state": "changed", "sessions_revoked": 2}Rate limit: 5 wrong current passwords lock password checks for that login for 15 minutes. The same counter is used by every endpoint that asks for the current password.
Errors
| Status | Message |
|---|---|
| 400 | Current password is incorrect |
| 400 | The new password must be different from the current one |
| 400 | One of the password rules |
| 403 | This needs a dashboard login, not an API key. |
| 429 | Too many incorrect passwords. Try again in 15 minutes. |
Two-factor login
Two-factor login uses time-based one-time codes (6 digits, 30-second period, SHA-1), which standard authenticator apps support. A code for the previous or next period is accepted to allow for clock drift, and a code cannot be used twice.
Start two-factor setup
POST /auth/2fa/setup
Creates a new secret for the logged-in person and returns it for the authenticator app. Two-factor login is not on until you confirm a code with POST /auth/2fa/enable.
Auth: session token only. Role: viewer and up.
Request
curl -X POST https://api.sevennadi.si/auth/2fa/setup \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{
"secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/7Nadi:asha%40example.test?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=7Nadi&algorithm=SHA1&digits=6&period=30",
"issuer": "7Nadi",
"account": "asha@example.test"
}issuer is the operator's configured brand name.
Errors
| Status | Message |
|---|---|
| 400 | Two-factor login is already on. Turn it off first to set it up again. |
| 403 | This needs a dashboard login, not an API key. |
Turn two-factor login on
POST /auth/2fa/enable
Confirms the setup with a code from the authenticator app and returns 8 recovery codes. The codes are shown only in this response.
Auth: session token only. Role: viewer and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
code | body | string | yes | The current 6-digit code. |
Request
curl -X POST https://api.sevennadi.si/auth/2fa/enable \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'Response 200 OK
{
"message": "Two-factor login is on",
"state": "enabled",
"recovery_codes": ["abcde-fgh23", "jkmnp-qrs45", "tuvwx-yz678", "bcdef-ghj29", "kmnpq-rst34", "uvwxy-zab56", "cdefg-hjk78", "mnpqr-stu92"]
}Errors
| Status | Message |
|---|---|
| 400 | Two-factor login is already on. |
| 400 | Start the setup first. |
| 400 | That code isn't right. Check the clock on your phone and enter the newest code. |
| 403 | This needs a dashboard login, not an API key. |
Turn two-factor login off
POST /auth/2fa/disable
Removes the secret and the recovery codes. Needs the current password and either a current code or a recovery code.
Auth: session token only. Role: viewer and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
password | body | string | yes | The login's current password. |
code | body | string | one of | The current 6-digit code. |
recovery_code | body | string | one of | An unused recovery code. |
Request
curl -X POST https://api.sevennadi.si/auth/2fa/disable \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"password": "a-long-passphrase-42", "code": "123456"}'Response 200 OK
{"message": "Two-factor login is off", "state": "disabled"}Errors
| Status | Message |
|---|---|
| 400 | Two-factor login is not on. |
| 400 | Current password is incorrect |
| 400 | That code isn't right. |
| 403 | This needs a dashboard login, not an API key. |
| 429 | Too many incorrect passwords. Try again in 15 minutes. |
| 429 | Too many incorrect codes. Try again in 15 minutes. |
Sessions
List sessions
GET /auth/sessions
Lists the logged-in person's active sessions, newest first.
Auth: session token only. Role: viewer and up.
Request
curl https://api.sevennadi.si/auth/sessions \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
[
{
"id": "d2877093-fac7-4677-9ffd-c7016d6f76ce",
"device": "Chrome on macOS",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36",
"ip": "203.0.113.7",
"created_at": "2026-10-13T07:21:47Z",
"last_used_at": "2026-10-13T07:21:47Z",
"expires_at": "2026-10-20T07:21:47Z",
"current": true
}
]last_used_at is updated at most once every 5 minutes.
Errors
| Status | Message |
|---|---|
| 403 | This needs a dashboard login, not an API key. |
Revoke a session
DELETE /auth/sessions/{session_id}
Ends one of the logged-in person's own sessions.
Auth: session token only. Role: viewer and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
session_id | path | string (UUID) | yes | A session id from the list. |
Request
curl -X DELETE https://api.sevennadi.si/auth/sessions/d2877093-fac7-4677-9ffd-c7016d6f76ce \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{"message": "success", "state": "revoked", "current": false}current is true when you revoked the session you are using.
Errors
| Status | Message |
|---|---|
| 403 | This needs a dashboard login, not an API key. |
| 404 | Session not found |
Log out everywhere
POST /auth/sessions/revoke-all
Ends every session of the logged-in person.
Auth: session token only. Role: viewer and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
keep_current | body | boolean | no | true leaves the session making the request logged in. Default false. |
Request
curl -X POST https://api.sevennadi.si/auth/sessions/revoke-all \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"keep_current": true}'Response 200 OK
{"message": "success", "state": "revoked", "revoked": 2}Errors
| Status | Message |
|---|---|
| 403 | This needs a dashboard login, not an API key. |
Invitations
An admin sends invitations with the Team endpoints. These two are the invited person's side. An invitation token is valid for 7 days and works once.
Read an invitation
POST /auth/invite-info
Returns what an invitation token is for, so a sign-up form can show it.
Auth: none (the token is the proof).
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
token | body | string | yes | The token from the invitation link. |
Request
curl -X POST https://api.sevennadi.si/auth/invite-info \
-H "Content-Type: application/json" \
-d '{"token": "TOKEN_FROM_THE_EMAIL"}'Response 200 OK
{"email": "ravi@example.test", "role": "member", "workspace": "Example Bank", "invited_by": "Asha Rao"}Errors
| Status | Message |
|---|---|
| 400 | This invitation is invalid or has expired. Ask for a new one. |
Accept an invitation
POST /auth/accept-invite
Sets the invited person's name and password, marks their email address as confirmed, and logs them in.
Auth: none (the token is the proof).
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
token | body | string | yes | The token from the invitation link. |
name | body | string | yes | The person's name (up to 200 characters are kept). |
password | body | string | yes | See Password rules. |
Request
curl -X POST https://api.sevennadi.si/auth/accept-invite \
-H "Content-Type: application/json" \
-d '{"token": "TOKEN_FROM_THE_EMAIL", "name": "Ravi Menon", "password": "a-long-passphrase-42"}'Response 200 OK
{
"token": "vs-0123456789abcdef0123456789abcdef",
"account": {"id": "03d064aa-fceb-4c80-b915-2b6090c7021f", "name": "Example Bank", "email": "asha@example.test", "wallet": 5.0},
"member": {"id": "7b1c2a90-5a51-4e0e-9c43-0f7f8f1f2a11", "name": "Ravi Menon", "email": "ravi@example.test", "role": "member"},
"role": "member"
}Errors
| Status | Message |
|---|---|
| 400 | This invitation is invalid or has expired. Ask for a new one. |
| 400 | name is required |
| 400 | One of the password rules |
API keys
API keys are vk- + 32 hex characters. A key acts with the admin role, whoever created it. That means a key can do everything except owner-only actions such as topping up the wallet, changing the plan or deleting the workspace.
List API keys
GET /auth/api-keys
Lists the workspace's active API keys, oldest first. Only the first 8 characters of each key are stored in readable form.
Auth: API key or session token. Role: admin and up.
Request
curl https://api.sevennadi.si/auth/api-keys \
-H "Authorization: Bearer vk-0123456789abcdef0123456789abcdef"Response 200 OK
[
{
"id": "5f0c1c2e-8f0a-4a6a-9f43-6a8c1a0b2d11",
"name": "Production server",
"prefix": "vk-01234",
"created_at": "2026-10-13T07:30:00Z",
"last_used_at": null,
"created_by": "asha@example.test"
}
]created_by is the email address of the login that created the key, or null for keys created before this was recorded.
Errors
| Status | Message |
|---|---|
| 403 | Your role (member) can't do this. It needs admin access; ask an owner or admin of the workspace. |
Create an API key
POST /auth/api-keys
Creates a key. The full key is in this response and is never shown again.
Auth: API key or session token. Role: admin and up. When the operator requires confirmed email addresses, the login's address must be confirmed first.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
name | body | string | no | A label, up to 200 characters. Default API key. |
Request
curl -X POST https://api.sevennadi.si/auth/api-keys \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef" \
-H "Content-Type: application/json" \
-d '{"name": "Production server"}'Response 201 Created
{
"id": "5f0c1c2e-8f0a-4a6a-9f43-6a8c1a0b2d11",
"name": "Production server",
"prefix": "vk-01234",
"created_at": "2026-10-13T07:30:00Z",
"last_used_at": null,
"key": "vk-0123456789abcdef0123456789abcdef"
}Errors
| Status | Message |
|---|---|
| 403 | Your role (member) can't change API keys. It needs admin access; ask an owner or admin of the workspace. |
| 403 | Verify your email address first (Settings → Security, or the link we emailed you). |
Revoke an API key
DELETE /auth/api-keys/{key_id}
Revokes a key at once. Requests that use it then get 401 Access denied.
Auth: API key or session token. Role: admin and up.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
key_id | path | string (UUID) | yes | The key's id (not the key itself). |
Request
curl -X DELETE https://api.sevennadi.si/auth/api-keys/5f0c1c2e-8f0a-4a6a-9f43-6a8c1a0b2d11 \
-H "Authorization: Bearer vs-0123456789abcdef0123456789abcdef"Response 200 OK
{"message": "success", "state": "revoked"}Errors
| Status | Message |
|---|---|
| 403 | Your role (member) can't change API keys. It needs admin access; ask an owner or admin of the workspace. |
| 404 | API key not found |