IAM Platform — REST API Reference (v1)
All responses use the standard envelope:
{ "success": true, "data": ... }
{ "success": false, "error": "message", "error_code": "optional_code" }
Authenticated endpoints require a header: Authorization: Bearer <access_token>.
All request bodies must be Content-Type: application/json.
Authentication — /api/auth
POST /api/auth/login
Request: { "username": "<username|email>", "password": "..." }
Password verification happens first. If it succeeds, login proceeds through
up to two further gates before real tokens are issued: TOTP (SIMS-183,
if the user has it enabled) is checked before organization selection (if
the user belongs to more than one organization). A user with both enabled
sees the requires_totp branch on this call, then (once TOTP clears) the
requires_org_selection branch on the POST /api/auth/totp/verify-login
call that follows — see the Two-Factor Authentication (TOTP)
section below.
Plain success (no TOTP, single org) — Response 200:
{ access_token, refresh_token, user, organizations }
TOTP required (SIMS-183) — Response 200:
{ requires_totp: true, totp_pending_token, user }
No access/refresh tokens are issued. The client must call
POST /api/auth/totp/verify-login with the totp_pending_token and a TOTP
or backup code to continue.
Multi-org user (no TOTP enabled, or TOTP already cleared) — Response 200:
{ requires_org_selection: true, pending_token, user, organizations }
No access/refresh tokens are issued. The client must call
POST /api/auth/select-organization with the pending_token and chosen
organization_id to complete login.
Errors: 401 invalid credentials · 403 inactive · 423 locked · 422 validation.
POST /api/auth/register-org
Self-service trial signup: creates an Organization plus its first admin user
in one call — no separate login step needed.
Request: { "org_name": "...", "org_code": "...", "admin_email": "...", "admin_username": "...", "admin_password": "...", "admin_first_name": "...", "admin_last_name": "..." }
Response 201: { access_token, refresh_token, org }. The admin user is
granted the org_admin role in the new organization.
Errors: 409 org_code already taken, or admin_email/admin_username
already registered · 422 weak password. Rate-limited like forgot-password.
POST /api/auth/select-organization
Request: { "pending_token": "...", "organization_id": <int> }
Response 200: { access_token, refresh_token, user, organizations }
Completes a multi-org login after the user picks an organization.
The pending_token is short-lived (5 min) and single-purpose.
Errors: 401 invalid/expired pending token · 403 not a member of that organization.
POST /api/auth/refresh
Request: { "refresh_token": "..." } → 200 { access_token, refresh_token }.
Rotates the refresh token; 401 if invalid/expired/revoked.
POST /api/auth/verify
Validates an access token — consumer apps (LMS, CRM, ...) use this instead of
sharing JWT_SECRET_KEY. Header: Authorization: Bearer <access_token>.
Response 200: the token's decoded claims — { sub, org_id, roles, permissions, scope, iat, exp }.
Errors: 401 missing/invalid/expired token.
Unscoped (default — every existing LMS call, unchanged):
POST /api/auth/verify
Authorization: Bearer <access_token>
→ permissions is the user's full list across every application their roles grant.
Scoped to one application (SIMS-186) — pass application as a query
param or a JSON body field, e.g. the application's code (CRM, LMS, ...):
POST /api/auth/verify?application=CRM
Authorization: Bearer <access_token>
or
POST /api/auth/verify
Authorization: Bearer <access_token>
Content-Type: application/json
{ "application": "CRM" }
→ permissions is filtered to only that application's codes (matched via
Permission.application_id); every other claim is unchanged. An unknown or
mistyped application code simply yields an empty permissions list rather
than an error.
POST /api/auth/logout (auth required)
Request: { "refresh_token": "..." } → 200. Revokes the refresh token. 400 if unknown/already revoked.
GET /api/auth/me (auth required)
200 { user, organizations, roles, permissions, scope }.
PUT /api/auth/profile (auth required)
Self-service update of the caller's own profile fields (SIMS-178). Partial update — only fields present in the body are changed; omitted fields are left untouched.
Request — any of:
{
"first_name": "...", "last_name": "...", "mobile": "...",
"timezone": "Asia/Karachi", "preferred_language": "en",
"address_line1": "...", "address_line2": "...", "city": "...",
"state": "...", "postal_code": "...", "country": "...",
"designation": "...", "date_of_birth": "1990-01-01"
}
timezone must be a valid IANA zone name. Response 200: the updated user
object (same shape as the user field returned by /api/auth/me).
Forbidden fields — username, email, password, and status cannot
be changed here; each needs its own dedicated, more tightly-guarded flow.
Sending any of them → 400 forbidden_field (checked before the rest of
the body is even parsed).
Errors: 400 forbidden_field · 404 not found · 422 validation (e.g.
unknown timezone, empty string on a field that requires one).
POST /api/auth/change-password (auth required)
Request: { "current_password": "...", "new_password": "..." } → 200.
400 wrong current / unchanged · 422 weak password.
POST /api/auth/forgot-password
Request: { "email": "..." } → always 200 (no user enumeration).
POST /api/auth/reset-password
Request: { "token": "...", "new_password": "..." } → 200. 400 invalid/expired token · 422 weak.
POST /api/auth/send-email-verification (auth required)
Issues a new email-verification token and emails the link to the caller
(SIMS-182). Response 200: { "message": "Verification email sent" }.
Idempotent no-op if the caller's email is already verified — still returns
200, sends nothing. Link expires in 24 hours.
POST /api/auth/verify-email
Request: { "token": "..." } → 200. Marks the token's owner
email_verified: true.
Errors: 400 verification_token_expired (token exists and is unused, but
past its 24h expiry) · 400 invalid_verification_token (unknown or
already-used token).
Profile photo — /api/auth/profile/photo (auth required)
| Method | Notes |
|---|---|
| POST | multipart file (PNG/JPG/WEBP, ≤5MB) → 200 { photo_url } (presigned, expiring). Replaces any existing photo. |
| GET | 302 redirect to a presigned URL, or to a generated initials-avatar SVG if none is set — never 404. |
| DELETE | 200. Removes the photo. 404 if none set. |
Passkey Authentication — /api/auth/passkeys
WebAuthn/FIDO2 passkey support. Users can register device-bound passkeys as an additional login method alongside passwords. Consumer apps are completely unaware of passkeys — they receive the same JWT regardless of auth method.
Known limitation: Passkey login does not currently support the multi-org
selection flow. Users belonging to multiple organizations who authenticate via
passkey will receive tokens scoped to their first organization. Use
POST /api/auth/switch-organization after login to switch context.
Registration (auth required — password re-entry enforced)
POST /api/auth/passkeys/register/begin (auth required)
Request: { "password": "..." }
Response 200: { options: <WebAuthn PublicKeyCredentialCreationOptions> }
Errors: 401 wrong password · 409 max 5 passkeys reached.
POST /api/auth/passkeys/register/complete (auth required)
Request: { "credential": { "id": "...", "rawId": "...", "type": "public-key", "response": { "clientDataJSON": "...", "attestationObject": "..." } }, "nickname": "Work MacBook", "transports": ["internal"] }
Response 200: { id, nickname, created_at }.
Errors: 400 challenge expired / verification failed · 409 credential already registered.
Authentication (no auth — this is the login flow)
POST /api/auth/passkeys/authenticate/begin
Request: { "username": "<username|email>" }
Response 200: { options: <WebAuthn PublicKeyCredentialRequestOptions>, user_id }
Errors: 401 invalid credentials · 400 no passkeys registered · 403 inactive · 423 locked.
POST /api/auth/passkeys/authenticate/complete
Request: { "credential": { "id": "...", "rawId": "...", "type": "public-key", "response": { "clientDataJSON": "...", "authenticatorData": "...", "signature": "..." } } }
Response 200: { access_token, refresh_token, user, organizations } — same shape as password login.
Errors: 401 invalid credentials · 400 challenge expired · 423 locked.
Credential Management (auth required)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/auth/passkeys |
Bearer | List own passkeys [{ id, nickname, created_at, last_used_at }] |
| DELETE | /api/auth/passkeys/{id} |
Bearer | Revoke own passkey |
| GET | /api/admin/users/{user_id}/passkeys |
user.view |
Admin: list user's passkeys |
| DELETE | /api/admin/users/{user_id}/passkeys/{id} |
user.manage |
Admin: revoke user's passkey |
Two-Factor Authentication (TOTP) — /api/auth/totp
Authenticator-app (TOTP, PyOTP-compatible) second factor (SIMS-183). Once
enabled for a user, every primary-credential login path (password,
WebAuthn passkey) is gated by it before completing — see the requires_totp
branch on POST /api/auth/login above. This is the trickiest state machine
in the API: password → TOTP-pending (if enabled) → org-selection (if
multi-org) → real tokens.
Self-service enrollment (auth required)
POST /api/auth/totp/setup (auth required)
Starts (or restarts) enrollment. Requires the current password — like
passkey registration, registering a new second factor is sensitive.
Request: { "password": "..." }
Response 200: { secret, provisioning_uri } — render provisioning_uri
as a QR code for the user's authenticator app.
Errors: 401 invalid_password · 409 totp_already_enabled (must call
disable before starting a new setup — overwriting the secret in place
would silently break the working second factor).
POST /api/auth/totp/verify (auth required)
Confirms enrollment with the first code from the authenticator app, enabling
TOTP. Request: { "code": "..." }
Response 200: { backup_codes: [...] } — 10 single-use recovery codes
(format XXXXX-XXXXX), generated and shown exactly once; store them
securely, they cannot be retrieved again (only regenerated, which
invalidates the old set).
Errors: 400 totp_setup_not_started · 409 totp_already_enabled ·
401 invalid_totp_code.
POST /api/auth/totp/disable (auth required)
Request: exactly one of { "current_password": "..." } or
{ "totp_code": "..." } — never a backup code; disabling 2FA requires
something you know or currently have set up, not a recovery code.
Response 200: { "message": "TOTP disabled" }.
Errors: 409 totp_not_enabled · 401 reauthentication_required ·
422 validation (both or neither field set).
POST /api/auth/totp/backup-codes (auth required)
Regenerates backup codes, invalidating all previous ones. Same re-auth
rules and request shape as disable.
Response 200: { backup_codes: [...] }.
Errors: 409 totp_not_enabled · 401 reauthentication_required.
Completing a TOTP-gated login (no auth — this is part of the login flow)
POST /api/auth/totp/verify-login
Request: { "totp_pending_token": "...", "code": "..." } or
{ "totp_pending_token": "...", "backup_code": "..." } — exactly one of
code/backup_code.
Response — same branches POST /api/auth/login uses once TOTP clears:
either { access_token, refresh_token, user, organizations } (single org)
or { requires_org_selection: true, pending_token, user, organizations }
(multi-org).
A wrong code counts toward the same failed_login_attempts/lockout
tracking as a bad password, so the 6-digit code space can't be brute-forced
without eventually tripping the lockout.
Errors: 401 invalid_pending_token (missing/expired/already-used) ·
401 invalid_totp_code (wrong code and wrong/missing backup code) ·
423 account locked (too many failures).
Notification Preferences — /api/auth/notification-prefs (auth required)
Generic per-user event-key preferences (SIMS-180). Keys are app-namespaced
event identifiers (e.g. "crm.lead_assigned") mapped to a bool. A key
absent from the stored preferences means the event is enabled — new
users have no row until their first write, so GET returns {} rather
than an error.
GET /api/auth/notification-prefs
Response 200: { "<event_key>": <bool>, ... } — only keys the user has
explicitly overridden are present.
PUT /api/auth/notification-prefs
Request: { "<event_key>": <bool>, ... } — a partial merge: only the
keys present in the body are changed; existing keys not mentioned are left
untouched.
Response 200: the full merged preferences object.
Documented event keys (not an allowlist — any other "<app>.<event>" key
is still accepted and stored, just logged server-side as unexpected):
crm.lead_assigned · crm.follow_up_due · crm.lead_converted
Errors: 404 not found.
Device Tokens — /api/auth/device-tokens (auth required)
Push-notification device token registration (SIMS-181).
POST /api/auth/device-tokens
Request: { "token": "...", "platform": "android" | "ios" | "web" }
Response 201: { "message": "Device token registered" }. Upserts on
(user_id, token) — re-registering the same token (e.g. on every app
launch) is idempotent, never a duplicate row or a conflict.
Errors: 422 validation (unrecognized platform, empty token).
DELETE /api/auth/device-tokens
Request: { "token": "..." } → 200 { "message": "Device token removed" }.
Always succeeds, even if the token doesn't exist or belongs to another
user — deliberate, so the endpoint never leaks whether a given token exists
for someone else.
Team Invitations — /api/auth/invites (SIMS-184)
An org admin invites a teammate by email; the invitee sets their own name/password on acceptance. Invitation links expire after 7 days.
POST /api/auth/invites (auth required, organization.manage)
Request: { "email": "...", "organization_id": <int>, "role_ids": [<int>, ...],
"redirect_uri": "..." (optional) }. redirect_uri, if given, must exactly
match one of ALLOWED_CORS_ORIGINS (SIMS-191) — it points the emailed invite
link at {redirect_uri}/{token} (the consumer app's own accept-invitation
route) instead of SIMS's hosted /admin/accept-invitation page. Omitted, the
link behaves exactly as before this option existed.
Response 201: { "id": <int>, "message": "Invitation sent" }. Emails the
invite link; the raw token is never included in the response, only in the
email (SIMS-135).
Errors: 404 role_not_found · 409 invitation_pending (an
un-accepted, un-expired invitation already exists for this email in this
organization) · 400 redirect_uri_not_allowed.
POST /api/auth/invites/{id}/resend (auth required, organization.manage)
Rotates the invitation's token and extends its expiry, reusing the same row
rather than creating a duplicate.
Response 200: { "message": "Invitation resent" }.
Errors: 404 not found · 409 invitation_already_accepted · 400
invitation_revoked.
POST /api/auth/invites/{token}/accept
Request: { "first_name": "...", "last_name": "...", "password": "..." }
Response 200: { access_token, refresh_token, user } — the new user is
logged in immediately, scoped to the inviting organization. The account is
created pre-verified (email_verified: true) since accepting the emailed
link already proves control of the inbox.
Errors: 400 invalid_invitation_token · 400 invitation_revoked ·
400 invitation_already_accepted · 400 invitation_expired · 409
email_already_registered (an account with this email already exists — log
in instead) · 422 weak_password.
Single Sign-On (SSO) — /sso, /api/auth/sso (docs/adr/0003)
SIMS is the shared login server for every *.technovative.in consumer app.
Instead of prompting for credentials itself, a consumer app redirects an
unauthenticated visitor to SIMS's hosted login page; SIMS redirects back with
a one-time code the app's own backend exchanges for tokens. See
docs/integration/sso.md for the full integration
walkthrough.
GET /sso/authorize
Browser-navigated (not an API call — no JSON envelope). Query params:
redirect_uri (must exactly match one of ALLOWED_CORS_ORIGINS, same rule
as Team Invitations above) and application (must match a registered
Application.code, e.g. CRM, ODD-IT, TODO).
- If the browser already carries a valid
sims_sso_sessioncookie: 302 redirect to{redirect_uri}?code={code}immediately, no login screen. - Otherwise: renders SIMS's hosted login page (password, TOTP, passkey — the WebAuthn ceremony runs here, on SIMS's own origin). On success the page re-requests this same endpoint, which now redirects as above.
Errors: standard JSON envelope (like every other endpoint) even though this
route is otherwise browser-navigated: 400 redirect_uri_not_allowed ·
404 application_not_found.
POST /api/auth/sso/token
Server-to-server only — called by the consumer app's own backend, never the
browser.
Request: { "code": "...", "application": "CRM" }
Response 200: { access_token, refresh_token }, scoped via the aud claim
to application (SIMS-186) — same mechanism POST /api/auth/verify?application=
already uses to filter permissions.
Errors: 400 code_invalid (unknown, expired — 60s TTL — or already used) ·
400 application_mismatch (code was issued for a different application).
Users — /api/users (auth + permission)
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/users?page&per_page&status |
user.view |
paginated { users, total, page, per_page } |
| POST | /api/users |
user.manage |
body { username, email, password, first_name, last_name, middle_name?, mobile? } → 201 |
| GET | /api/users/{id} |
user.view |
|
| PUT | /api/users/{id} |
user.manage |
body any of { email, first_name, last_name, middle_name, mobile, status } |
| DELETE | /api/users/{id} |
user.manage |
soft delete; 409 if already disabled |
| GET/POST | /api/users/{id}/organizations |
user.view/user.manage |
assign membership |
| DELETE | /api/users/{id}/organizations/{org_id} |
user.manage |
|
| GET/POST | /api/users/{id}/roles |
user.view/user.manage |
body { role_id, organization_id } |
| DELETE | /api/users/{id}/roles/{role_id}?organization_id=N |
user.manage |
|
| GET/POST | /api/users/{id}/scopes |
user.view/scope.manage |
body { organization_id, office_id? } |
| DELETE | /api/users/{id}/scopes/{scope_id} |
scope.manage |
Organizations & Hierarchy
| Method | Path | Permission |
|---|---|---|
| GET/POST | /api/organizations |
organization.view/organization.manage |
| GET/PUT/DELETE | /api/organizations/{id} |
view / manage / manage |
| GET | /api/organizations/{org_id}/tree |
organization.view |
| GET/POST | /api/organizations/{org_id}/offices |
view / manage |
| GET | /api/offices/{office_id}/children |
organization.view |
| POST | /api/offices/{office_id}/children |
organization.manage |
| PUT/DELETE | /api/offices/{office_id} |
organization.manage |
Offices form a self-referencing tree (parent_id). Organization code is uppercase
alphanumeric (≤20). Delete returns 409 if it has members.
Roles & Permissions
| Method | Path | Permission |
|---|---|---|
| GET/POST | /api/roles |
role.view/role.manage |
| PUT/DELETE | /api/roles/{id} |
role.manage (system roles → 409) |
| GET/POST | /api/roles/{id}/permissions |
view / manage (body { permission_id }) |
| DELETE | /api/roles/{id}/permissions/{permission_id} |
role.manage |
| GET/POST | /api/permissions?module= |
role.view/role.manage |
| PUT/DELETE | /api/permissions/{id} |
role.manage (code immutable; 409 if in use) |
Applications — /api/applications
GET (application.view), POST/PUT/DELETE (application.manage).
Body: { name, code, description?, status? }. code unique.
Standard applications (SIMS, LMS, IMS, ODD-IT, NUDGE, CRM) are seeded via
src/utils/seed.py::STANDARD_APPLICATIONS. See
integration/crm.md for CRM's crm.<module>.<action>
permission-code convention.
Audit Logs — /api/audit-logs (permission audit.view)
GET /api/audit-logs?page&per_page&organization_id&user_id&action&date_from&date_to
→ { audit_logs, total, page, per_page }. GET /api/audit-logs/{id} for one entry.
Dates are ISO‑8601. Logs are append-only.
Standard error codes
401 unauthorized / token_expired / invalid_token ·
403 forbidden / organization_required ·
404 not_found · 409 duplicate_ / _in_use ·
415 unsupported_media_type · 422 validation_error / weak_password ·
423 account_locked · 429 rate_limited.