Skip to content

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.

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

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/login returns { tokenType, accessToken, expiresIn } - no refresh token in the body.
  • On any 401 that is not itself the refresh call, POST /identity/refresh (cookie-driven, no body), then retry the original request once. Concurrent 401s trigger exactly one refresh (single-flight).
  • POST /identity/logout consumes 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.

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.

Permissions are not baked into the JWT. Gate UI off two calls, both re-checked server-side on every request:

  • GET /me/permissions returns roles[], verb permissions[] (e.g. sales.void, cash.manage), isGlobalAdmin, and branchIds[]. Cache it, and re-fetch on every app load and after login to catch role, branch, or deactivation changes.
  • GET /capabilities returns the tenant’s enabledModules[]. Core features (catalogue, inventory, orders, cash, branches, identity, customers) are never gated; feature modules (Credit, Loyalty, Promotions, Purchasing, Reporting, Returns, StockTransfers) are.

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

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