Calling the API
The web dashboards talk to the backend over one shared API contract: two auth planes, a Tenant-ID header on the tenant side, money as quoted decimal strings, PascalCase enums, and a problem+json error shape the client branches on. This page orients you to the pieces you touch most from client code. The authoritative, complete reference lives in the web repo at nexis-omni-web/docs/backend-integration.md - read it before writing code that calls the API. For the cross-cutting rationale, see the API contract overview.
Two planes, two apps
Section titled “Two planes, two apps”The backend is a hybrid multi-tenant API with two independent authentication planes. Each has its own user store, bearer scheme, audience, and route subtree. A token minted for one plane is rejected (403) on the other plane’s routes.
| Aspect | Admin plane | Tenant plane |
|---|---|---|
| Frontend app | apps/platform-admin |
apps/hq-dashboard |
| Scheme / audience | AdminBearer / nexisomni-admin |
TenantBearer / nexisomni-tenant |
| User store | PlatformUser (central DB) |
AppUser (per-tenant DB) |
| Auth route prefix | /admin/identity/* |
/identity/* |
Tenant-ID header |
Never | Required on every request |
Authentication and the refresh cookie
Section titled “Authentication and the refresh cookie”The access token is a short-lived RS256 JWT kept in memory only (never localStorage). The refresh token lives in an httpOnly, Secure, SameSite=Strict cookie that JavaScript cannot read, scoped by path to its plane (nexisomni_tenant_rt at /identity, nexisomni_admin_rt at /admin/identity).
POST /identity/loginreturns{ tokenType, accessToken, expiresIn }- no refresh token in the body.- On any
401that is not itself the refresh call,POST /identity/refresh(cookie-driven, no body), then retry the original request once. Concurrent401s trigger exactly one refresh (single-flight). POST /identity/logoutconsumes the refresh token and expires the cookie; clear the in-memory access token.
The two-plane client, single-flight refresh-on-401, and the Tenant-ID header wiring already live in @workspace/api-client and @workspace/auth, so most callers get this behaviour for free.
Two-factor login
Section titled “Two-factor login”A login for a 2FA-enrolled user returns 200 with no access token:
{ "requiresTwoFactor": true, "twoFactorToken": "<5-min challenge>", "methods": ["Authenticator", "Email"] }Post the code to POST /identity/login/2fa with { twoFactorToken, code } (a 6-digit TOTP, an emailed code, or a recovery code) to receive the normal token response. methods tells the client whether to offer an emailed code via POST /identity/login/2fa/send-email. Note that lockout after 5 failed attempts in 15 minutes counts both the password step and the 2FA step, and both locked-out and deactivated accounts return 401 - surface a friendly message.
Authorization: permissions and modules
Section titled “Authorization: permissions and modules”Permissions are not baked into the JWT. Gate UI off two calls, both re-checked server-side on every request:
GET /me/permissionsreturnsroles[], verbpermissions[](e.g.sales.void,cash.manage),isGlobalAdmin, andbranchIds[]. Cache it, and re-fetch on every app load and after login to catch role, branch, or deactivation changes.GET /capabilitiesreturns the tenant’senabledModules[]. Core features (catalogue, inventory, orders, cash, branches, identity, customers) are never gated; feature modules (Credit, Loyalty, Promotions, Purchasing, Reporting, Returns, StockTransfers) are.
Money and enums on the wire
Section titled “Money and enums on the wire”Every decimal field serializes as a quoted string in invariant culture ("118.00", pattern ^-?\d+(\.\d+)?$).
Enums are strings too, in PascalCase - for example "Loyalty", "Created", "InsufficientStock".
Error shapes and common codes
Section titled “Error shapes and common codes”Non-validation errors share one application/problem+json body (ApiProblemDetails): the standard type/title/status, plus detail (a human message) and errorCode (a stable machine code the client branches on). Validation failures use the separate ASP.NET errors-map variant.
| Status | When | Key field |
|---|---|---|
400 validation |
DataAnnotation failure, malformed decimal or enum | errors map |
400 business |
Insufficient stock, session closed, tender too low | errorCode (e.g. insufficient_stock) |
401 |
No/expired/invalid token, lockout, deactivated | empty body - re-authenticate |
403 module |
Gated module disabled for the tenant | errorCode: "module_disabled" + module |
403 branch |
Not assigned to the branch, not global admin | errorCode: "branch_forbidden" + branchId |
409 |
State conflict, duplicate with diverging content, name taken | errorCode + detail |
Branch on errorCode: module_disabled routes to an upsell or feature-locked state, branch_forbidden routes to a branch-access message, and anything else falls back to showing detail as a generic access-denied message.
The typed client is generated
Section titled “The typed client is generated”The hey-api client in packages/api-client is generated from the backend’s OpenAPI document (/openapi/v1.json, served in Development). The OpenAPI schema is the source of truth for DTO shapes; the integration guide documents the behaviours the schema alone cannot express.
Regenerate and commit the client whenever the backend contract changes - CI regenerates and diffs against the committed copy. Generated decimal fields come through as string and map to Big.js; the client.ts facade wires both planes (in-memory access token, credentials: "include", refresh-on-401, and the Tenant-ID header for hq-dashboard only).