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 fieldsusername, 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_session cookie: 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.


Back to documentation index