Skip to content

Administration API

Applies to: All subscriptions

Purpose

Describe the administrative surface for teams automating provisioning or operational tasks, and state plainly where automation is appropriate and where the console is the better tool.

Audience

Integration developers automating administration, and system administrators.

Prerequisites

Reference

These endpoints exist to serve Complifly's own admin console. They are documented for automation, but the console is the supported path for occasional administration. Automate provisioning and bulk work; do not rebuild the console.

Users and access

Base path /api/admin

Method Path Purpose
GET /users List users
POST /users Create a user
PUT /users/{userId} Update a user
DELETE /users/{userId} Remove a user
POST /users/{userId}/end-sessions Sign one user out of every device without changing the account
GET /company-gstins Registrations for the company
GET /all-companies Companies visible to the caller
GET /approval-roles The assignable Approval Engine role ladder
POST /features/toggle Set a feature flag — a GSTIN override or a company default
GET /feature-definitions The catalogue of operational controls
GET /companies Companies the caller may configure
GET /companies/{companyId}/defaults Company-tier feature defaults
GET /companies/{companyId}/gstins GSTINs under a company, paged, with override counts
GET /companies/{companyId}/summary Counts and the last change, for the summary above the Operational Controls tabs
GET /companies/{companyId}/gstins/{gstin}/effective Effective value + source (Platform/Company/GSTIN) per feature
GET /companies/{companyId}/feature-history Change history for a company or one GSTIN
POST /gstins/bulk-toggle Apply one feature value to several GSTINs at once
DELETE /gstins/{gstin}/features/{featureKey} Remove a GSTIN override (reverts to inherited)
DELETE /companies/{companyId}/features/{featureKey} Remove a company default (reverts to platform default)
GET /audit-logs Audit trail
GET /audit-logs/document Audit trail for one document
POST /audit/log Record a browser-reported event. The action is always stored with a CLIENT_ prefix, so it can never read as a server-recorded action
GET /fiscal-years Fiscal calendar
POST /fiscal-years/toggle Lock or open a year. Needs fiscal.manage (Finance Admin or Super Admin); an Org Admin gets 403 PERMISSION_DENIED
POST /fiscal-years/seed Seed the next year. Needs fiscal.manage, as above

Prefer disabling a user over deleting one. Deletion breaks the attribution of past actions in the audit trail, which a compliance system must preserve.

Which company, and which GSTINs, a caller may name

A non-Super caller's company comes from the verified token, never from the X-Org-Id header. If the token carries no company, GET, POST, PUT and DELETE on /users return 403 ("No organization context found") whatever header is sent. A Super Admin names the company in the body of POST /users.

An Org Admin may grant only GSTINs registered under their own company. On POST /users every entry of gstins, and primaryGstin, must be one; on PUT /users/{userId} every GSTIN being added, and a changed primaryGstin. The primary GSTIN counts because sign-in seeds the user's permitted list with it. A refusal is:

Response Condition
403 with code: "GSTIN_NOT_PERMITTED" and gstins: [...] One or more GSTINs are not registered under the caller's company. Nothing is saved, and the same response is given whether the GSTIN belongs to another company or does not exist. Matching ignores letter case and surrounding spaces

Only GSTINs being added are checked on PUT. A GSTIN of another company that a Super Admin gave the user earlier is kept when an Org Admin resends it unchanged, and removing any GSTIN is always allowed. A Super Admin is not subject to the check.

Changes that end the user's sessions

An edit that changes what a person can reach signs them out of every device, because a role, an approval role and a GSTIN list are fixed into the token when they sign in. Without this, a disabled or demoted user would keep working with the old access until the token expired, 8 hours by default.

Change on PUT /users/{userId} or DELETE /users/{userId} Sessions ended? reason the person sees
isActive: false, including on an account that is already inactive Yes ACCOUNT_DEACTIVATED
DELETE Yes ACCOUNT_DELETED
role different from the stored role Yes ROLE_CHANGED
approvalRole, primaryGstin or the gstins list different from what is stored (order and letter case are ignored) Yes ACCESS_CHANGED
Anything else, including a form that re-sends the values it loaded No none

The response carries sessionsRevoked, the number of sessions ended. The edit and the ending of the sessions are one all-or-nothing step. If the sessions cannot be ended, the response is 500 with code: "SESSION_REVOKE_FAILED" and changeSaved: false: the edit or delete was rolled back, the person keeps their old access and their sessions, and repeating the same request is a real retry. A 500 here never means "saved but not applied". If several changes apply at once, deactivation outranks a role change, which outranks an access change.

Audit of access changes

POST /users and PUT /users/{userId} write GSTIN_ACCESS_GRANTED_AT_CREATE or GSTIN_ACCESS_CHANGED (before, after, added, removed, crossCompany, sessions ended) against the person's company, and GSTIN_GRANT_REFUSED when a grant is refused with 403 GSTIN_NOT_PERMITTED. See GSTIN Access Control.

POST /users/{userId}/end-sessions

Ends every session of one user without changing the account. Use it for a lost device or a suspected takeover; the person can sign in again at once.

Permission admin.manage_users
Scope A user in the caller's own company. A Super Admin may name any company's user
Body None
200 { "success": true, "revokedCount": 2 }; revokedCount is 0 when there was nothing to end
403 The caller lacks the permission, or the user holds a Complifly platform role and the caller is not a Super Admin (PLATFORM_USER_NOT_MANAGEABLE), or the user's role is unclassified (ROLE_NOT_CLASSIFIED). See Assigning roles on a user
404 No such user visible to the caller. Another company's user is invisible to an Org Admin, so a missing user and another company's user look the same
500 SESSION_REVOKE_FAILED: the sessions could not be ended. There is no change to roll back on this endpoint, and the body has no changeSaved. Retry

Every successful call that ends at least one session is recorded in the audit log as SESSIONS_REVOKED, with the actor, the target, the reason and the count. The caller's organisation comes from the verified token, never from the X-Org-Id header.

Assigning roles on a user

POST /users and PUT /users/{userId} accept two independent role fields. They are not interchangeable and a user usually needs both.

Field Required Values Effect
role Yes on POST, optional on PUT Tenant roles, assignable by an Org Admin or a Super Admin: org_admin, finance_admin, tax_controller, tax_executive, approver, transport_manager, logistics_operator, external_auditor. Platform roles, assignable by a Super Admin only: super_admin, support_engineer, platform_engineer The permission set — which endpoints the user may call
approvalRole No One of the keys from GET /approval-roles, or null to clear Which level of a multi-level approval the user occupies

An Org Admin can never give a platform role, and can never touch a platform account. Platform roles are Complifly's own and reach across every customer: a Super Admin writes any company, and a support or platform engineer reads any company. Every role is classified on the server as a tenant role or a platform role; a role with no classification cannot be assigned by anyone, a Super Admin included. The check runs on the server whatever the screen offers, on POST /users, PUT /users/{userId}, DELETE /users/{userId} and POST /users/{userId}/end-sessions.

Caller May assign May edit, deactivate, delete or end the sessions of
Super Admin Every classified role Anyone, in any company
Org Admin Tenant roles only People in their own company who hold a tenant role
Any other role Nothing Nobody

Both fields are validated against a server-side allowlist. Refusals to expect (all carry a code; each refusal of a platform-role attempt is also recorded as ACCESS_DENIED in the audit log):

Response Condition
403 UNKNOWN_ROLE — Unknown system role: {role}. Valid roles: … role is not a role that exists. The list names only the roles this caller may assign. A typo would otherwise create a user with an empty permission set who can sign in and do nothing. Role names are exact: Super_Admin and super_admin are unknown
403 ROLE_NOT_CLASSIFIED The role exists but has no tenant or platform classification (for example the legacy admin). Nobody can assign it. As the role a person holds now, only a Super Admin may manage them, or an Org Admin may move them onto a tenant role: that edit is the repair
403 PLATFORM_ROLE_REQUIRES_SUPER_ADMIN — Only a Super Admin may assign the {role} role An Org Admin attempted to grant super_admin, support_engineer or platform_engineer, on POST or on PUT (including to themselves)
403 PLATFORM_USER_NOT_MANAGEABLE An Org Admin tried to edit, deactivate, delete or sign out someone who holds a platform role in their company. People in another company are invisible to an Org Admin, so for them the answer is still 404 and does not reveal what role they hold
403 ROLE_ASSIGNMENT_NOT_PERMITTED The caller holds a role that is not allowed to assign or manage roles at all
400 — Unknown approval role: {value} approvalRole is not in the ladder served by GET /approval-roles

POST /api/auth/signup is retired and answers 410 SIGNUP_RETIRED. It created users with no role and no company. Create people with POST /users.

To clear an approval role, send approvalRole: null (or "") explicitly on PUT. Omitting the field leaves the current value unchanged — the two cases are distinguished deliberately, so an unrelated edit cannot unassign someone.

To let a user approve invoices in production, set both: a role that holds invoice.approve (approver or tax_controller) and an approvalRole naming their level. Setting one without the other is accepted and returns 200, but the user will never be able to complete an approval. Nothing warns you.

Operational Controls — feature configuration

A feature flag's value for a registration is the first of: its GSTIN override, its company's default, the platform default. Every endpoint in this section reads or writes one of the first two tiers. To change a flag for a whole company, write the company default once; do not loop over GSTINs. See Feature Flags.

Relaxing segregation of duties needs reason. POST /features/toggle, POST /gstins/bulk-toggle and both DELETE routes answer 400 ACCESS_WIDENING_REASON_REQUIRED when the change would move a registration's effective SOD_ENFORCED from on to off and no reason was sent (body for POST, query string for DELETE). Nothing is changed. This applies to every caller, a Super Admin included; force is not involved and a non-Super caller may supply the reason. A change that is not a relaxation needs none. Once the company has started live work, relaxing is locked (live work is a live IRN, a generated e-way bill or a decided approval; work on a registration whose GSP configuration is SANDBOX does not count): an Org Admin gets 403 SOD_LOCKED with gstins[], lockedSince and evidence, and a Super Admin must also send approvalRef (3 to 120 characters; body for POST, query string for DELETE), or gets 400 SOD_APPROVAL_REF_REQUIRED. approvalRef is stored at the front of the reason in Audit History, written to the ACCESS_WIDENED entry as customerApprovalRef, and carried in the notice. Turning SOD_ENFORCED on is never locked. SOD_ENFORCED is company-wide only: a GSTIN-scoped write is refused with 400 FEATURE_SCOPE_NOT_ALLOWED, while removing an older GSTIN override is still allowed (the direction is judged on the registration's effective value). On success the response carries accessWidened: { gstins, notice: { recipients, sent, skipped } }, where skipped is NO_OTHER_ADMIN, EMAIL_NOT_CONFIGURED or SEND_FAILED when the other administrators could not be emailed (the change still stands). A bulk call sends one notice per company and returns accessWidened as a list.

Who may call what:

Caller Read (GET) Write (POST, DELETE) Force
Super Admin Any company Any control, any company Yes
Organization administrator Own company Customer-policy controls only, own company No
External auditor Own company No No
Complifly support / platform engineer Any company (each read audited against that company) No No

Reads need admin.view; writes need admin.manage_users and a control the caller may change. Which controls an organization administrator may change is the control's writeRole in GET /feature-definitions: ORGANISATION (they may) or PLATFORM (Complifly Super Admin only). Every read response says, per control, whether this caller may change it (writable), and for the caller as a whole (access):

"access": { "canForce": false, "readOnly": false }

writable and access come from the same check the write path enforces, so a client that enables only writable controls never offers a change the API refuses. A companyId other than the caller's own, or a GSTIN outside it, gets 403.

Changed 2026-10-03. Licensing controls and every force are now Super-Admin-only (403 FEATURE_WRITE_NOT_PERMITTED, 403 FORCE_NOT_PERMITTED); the read routes moved from admin.manage_users to admin.view; responses carry writeRole, writable and access.

Changed 2026-10-02. GET /operational-matrix and GET /feature-flags/platform were removed. Read GET /companies/{companyId}/gstins/{gstin}/effective instead of the matrix. GET /companies/{companyId}/gstins now returns a paged object, not an array. POST /features/toggle now validates the value and refuses a company-only flag at the GSTIN tier.

Method Path Purpose
GET /api/admin/feature-definitions Every control: key, module, label, description, dataType (BOOLEAN | NUMBER | ENUM), allowedValues, numberMin / numberMax, defaultValue, companyOnly, helpKey
GET /api/admin/companies [{ id, name, status, gstinCount }] — every company for a Super Admin, the caller's own otherwise. With ?include=agent each row also carries agentEnabled (the company-tier value of the Complifly Agent control, else the platform default, which is off) and hasProfile (a saved compliance-profile revision exists). Without the parameter the response is unchanged and no extra query runs. Used by the Compliance Profiles company selector
GET /api/admin/companies/{companyId}/defaults { companyId, defaults: [...] } — each control's definition plus value and isSet (false = the platform default applies). A control that is locked by live activity (SOD_ENFORCED) also carries governance: { locked, since, evidence, summary, confirm[], activeCheckers }: locked is null when it could not be read, and each confirm entry (whenValue, severity, title, message) is what the screen asks the administrator to confirm before writing that value
GET /api/admin/companies/{companyId}/gstins ?search=&limit=&offset= (default 100, max 500) &sort=overrides (most overrides first; default GSTIN order) → { total, limit, offset, gstins: [{ gstin, tradeName, status, overrideCount }] }
GET /api/admin/companies/{companyId}/summary { gstinCount, companyDefaultCount, controlCount, overrideCount, gstinsWithOverrides, compliflyManagedOn, compliflyManagedTotal, lastChange }. compliflyManaged* counts the PLATFORM on/off controls that are on at company level (a GSTIN override can still differ). lastChange is the newest company- or GSTIN-scope history row ({ at, by, action, scope, scopeId, featureKey, label }), or null
GET /api/admin/companies/{companyId}/gstins/{gstin}/effective { gstin, companyId, features: [...] } — each control's definition plus value, source (PLATFORM | COMPANY | GSTIN), inheritedValue and inheritedSource (what the GSTIN would have without its own override), overridable
GET /api/admin/companies/{companyId}/feature-history ?scope=company\|gstin&scopeId= — old and new value, who, when, reason, for every change at that scope
POST /api/admin/features/toggle Set one value. See below
POST /api/admin/gstins/bulk-toggle Set one value on up to 500 GSTINs. See below
DELETE /api/admin/gstins/{gstin}/features/{featureKey} Remove a GSTIN override; the GSTIN reverts to the company default, or the platform default
DELETE /api/admin/companies/{companyId}/features/{featureKey} Remove a company default; every GSTIN without its own override reverts to the platform default
POST /api/admin/features/toggle

A GSTIN override:

{ "gstin": "29AAACW3775F000", "featureKey": "AUTO_IRN_ENABLED", "value": true }

A company default:

{ "scope": "company", "scopeId": "1001", "featureKey": "AUTO_IRN_ENABLED", "value": true }

Optional on either: "force": true with "reason": "...", "approvalRef": "..." (only when a Super Admin relaxes a locked SOD_ENFORCED; see below), and "rowVersion" (Base64, from a previous response) for optimistic concurrency.

value must be one the control accepts — true/false for a BOOLEAN, a number inside numberMin–numberMax for a NUMBER, exactly one of allowedValues[].value for an ENUM. Read GET /feature-definitions rather than hardcoding the list.

Two changes are refused unless forced, and they are checked for every GSTIN the write reaches — the GSTIN named, or every GSTIN that inherits the company default being written:

  • IGM_APPROVAL_ENGINE going off for a GSTIN with documents still awaiting approval.
  • EXCEL_UPLOAD_ENABLED or INVOICING_ENABLED going on for a GSTIN with an active ERP connection.
Response Condition
200 Written. Body: { success, message, rowVersion }, plus cancelledApprovals on a forced approval-engine disable
400 — UNKNOWN_FEATURE_KEY featureKey is not a control in this build
400 — INVALID_FEATURE_VALUE The control does not accept that value. expects says what it does accept
400 — FEATURE_SCOPE_NOT_ALLOWED A company-only control (MODULE_TDS_ENABLED, AGENT_ENABLED, SOD_ENFORCED and the other company-scoped controls) sent as a GSTIN override
400 — ACCESS_WIDENING_REASON_REQUIRED The change would relax segregation of duties and carried no reason
400 — SOD_APPROVAL_REF_REQUIRED A Super Admin relaxed segregation of duties for a company that has started live work, without a usable approvalRef. Body carries gstins[], lockedSince, evidence
403 — SOD_LOCKED An Org Admin tried to relax segregation of duties for a company that has started live work. Body carries gstins[], lockedSince, evidence. Turning it on is never refused
400 — REASON_REQUIRED force: true was sent without a reason
403 — FEATURE_WRITE_NOT_PERMITTED The control's writeRole is PLATFORM and the caller is not a Super Admin, or the caller's role may not change any control. Body carries writeRole
403 — FORCE_NOT_PERMITTED force: true from anyone but a Super Admin
403 — COMPANY_NOT_PERMITTED / GSTIN_NOT_PERMITTED The target is outside the caller's company
404 — UNKNOWN_COMPANY scopeId is not a known company
409 — APPROVALS_IN_FLIGHT Body carries gstins[] and open[], one row per blocking document with gstin, waitingOn and level
409 — NON_ERP_POSTING_BLOCKED Body carries gstins[] and connections[], the connected ERP(s) by name
409 — CONCURRENT_MODIFICATION rowVersion no longer matches — reload and retry

A Super Admin may force either guarded change through with "force": true and a "reason".

  • For IGM_APPROVAL_ENGINE, that path cancels the open approvals — OVERRIDDEN levels, CANCELLED requests, documents left with no approval decision — and never approves them. See Platform Error Codes.
  • For EXCEL_UPLOAD_ENABLED / INVOICING_ENABLED, that path only opens the channel; it does not enable ERP_WRITEBACK_ENABLED and does not add any cross-channel duplicate detection. See Platform Error Codes.

The reason is stored with the change and shown in Audit History.

Both EXCEL_UPLOAD_ENABLED and INVOICING_ENABLED are also written false automatically, as a GSTIN override — never overwriting an existing override — the moment an ERP connection for that GSTIN reaches ACTIVE (see Machine-to-Machine Authentication and Feature Flags).

POST /api/admin/gstins/bulk-toggle
{ "gstins": ["29AAACW3775F000", "27AAACW3775F000"], "featureKey": "AUTO_IRN_ENABLED", "value": true, "reason": "Pilot" }

Each GSTIN is its own write, with its own guard check and its own history row. A 200 does not mean every GSTIN was written — read results:

{ "results": [
  { "gstin": "29AAACW3775F000", "status": "OK" },
  { "gstin": "27AAACW3775F000", "status": "BLOCKED", "code": "APPROVALS_IN_FLIGHT", "message": "…" }
] }

status is OK, BLOCKED (refused, with code) or ERROR. An unknown key, an invalid value, a company-only control, a control the caller may not change or a force the caller may not use fails the whole call (400 / 403), because it would fail identically for every GSTIN. More than 500 GSTINs is 400 TOO_MANY_GSTINS.

Removing a value

DELETE on either route takes ?force=true&reason=... (and &approvalRef=... for a locked SOD_ENFORCED) and is guarded exactly like a write, because it changes the value in force like one: removing an override that was holding the approval engine on, or holding Excel Upload off, is refused the same way, and needs the same write permission for the control. 404 NO_STORED_VALUE means there was no override or default to remove.

Audit. A refused write — FEATURE_WRITE_NOT_PERMITTED, FORCE_NOT_PERMITTED, or a company/GSTIN outside the caller's own — is recorded in the audit log as ACCESS_DENIED / FORBIDDEN; a guard refusal (409) as FEATURE_CHANGE_BLOCKED. A read by Complifly support or platform staff is recorded as OPERATIONAL_CONTROLS_VIEWED against the company read.

GET /api/admin/approval-roles

The approval ladder an administrator may assign, in seniority order. Requires admin.manage_users. Tenant-independent — the ladder is the same for every organisation.

{
  "roles": [
    { "key": "finance_executive",  "label": "Finance Executive",  "rank": 10 },
    { "key": "finance_manager",    "label": "Finance Manager",    "rank": 20 },
    { "key": "finance_controller", "label": "Finance Controller", "rank": 30 },
    { "key": "business_head",      "label": "Business Head",      "rank": 40 },
    { "key": "plant_head",         "label": "Plant Head",         "rank": 40 },
    { "key": "director",           "label": "Director",           "rank": 50 },
    { "key": "cfo",                "label": "CFO",                "rank": 60 }
  ]
}

rank orders the ladder; equal ranks are peers at the same level, not consecutive steps. Read the list rather than hardcoding it — it is the same allowlist the write endpoints validate against, so a hardcoded copy can only ever go stale.

Response Condition
200 Success
403 The caller does not hold admin.manage_users

GET /api/admin/audit-logs

The tenant audit trail, newest first, last 500 rows. Requires audit.view. Optional filters: module, action, status.

A caller other than Super Admin never receives Complifly's internal events about the company (Health Check, Customer Success, go-live decisions, commercial and company-status changes), whichever filter is used: ?action= naming one of them returns an empty list, not an error. Only a Super Admin can read them. This holds for audit.view held by every role, including support and platform engineers, because the permission says who may open the screen, not which events it contains.

Each row carries LogId, Timestamp, UserId, Gstin, Action, Status, IpAddress and Details (JSON text), plus two fields that hold what changed:

Changed 2026-10-02: OrgId is no longer in the default response. A caller other than Super Admin only ever receives its own organisation's rows, so the value was constant. Super Admin can ask for it with ?cols=orgId; ?cols=sessionId,userAgent adds the session and client for any role. See List Columns and Saved Layouts API.

Field Meaning
OldValues JSON text of the field values before the change, or null
NewValues JSON text of the field values after the change, or null

OldValues/NewValues are null for actions that have no "before" state (sign-in) and for any action not yet instrumented. Rows written before 2026-10-01 for the session-policy actions have null here and keep their before/after as previousMax/newMax inside Details; the Audit Logs screen reads both forms. Integrators consuming this endpoint should treat both fields as optional.

GET /api/admin/audit-logs/document

The audit trail for one document, not the whole tenant. Requires audit.view. The same rule applies: a caller other than Super Admin never receives Complifly's internal events, even one that names the same document. sys_audit_log has no dedicated document-reference column — it was designed for admin/config actions, not per-invoice or per-EWB ones — so the identifiers a caller logged (invHdrId, irn, ewbNo, docNo) live inside the free-text Details JSON, written differently action by action. This endpoint is the one place that knows to look there.

At least one of ?invHdrId=, ?irn=, ?ewbNo=, ?docNo= is required — matching on nothing would return the whole tenant's log, not one document's.

Response Condition
200 Up to 200 matching rows, most recent first, with the same fields as GET /audit-logs including OldValues / NewValues
422 None of the four identifiers were supplied
403 The caller's token has no organisation context

Machine credentials

Base path /api/admin/api-keys

Method Path Purpose
GET / List static keys, without their secrets
GET /connections List the OAuth connections — the credentials live integrations actually use
POST / Issue a static key
PATCH /{keyId}/oauth-only Require OAuth for this client, or allow its static key again. Reversible
DELETE /{keyId} Revoke a static key. Permanent

A static key's secret is returned once, at issue. It cannot be retrieved afterwards. Capture it into a secret manager in the same operation that creates it — a provisioning script that issues a key and does not store it has created an unusable credential.

GET / returns a computed still_accepted for each key, which answers a different question from is_active: would this key be accepted by the API right now? A key can be dormant in every ordinary sense — unused for months, its ERP long since moved to OAuth — and still authenticate the moment anyone presents it. Use still_accepted, not the active flag, when auditing what is open.

Where a key has been superseded by an OAuth connection, superseded_by names it. A key that is both superseded and still accepted is an open path nobody is watching.

ERP connection onboarding

Base path /api/admin/erp-connections. Platform Super Admin only — these routes decide which external directory may post documents into a tenant.

Method Path Purpose
GET / Connections at every stage, plus the consent configuration
POST / Create a connection and its first consent link
POST /{id}/consent-link Mint a fresh consent link
PATCH /{id}/tenant Record the customer's directory ID by hand
PATCH /{id}/client-app Record their application ID — this activates the connection
PATCH /{id}/status Suspend or resume

There is one public route, and it is public by necessity:

Method Path Purpose
GET /api/erp-consent/callback Where Microsoft Entra returns the customer's administrator after consent

That callback has no session and no bearer token — the person arriving is an administrator at another company who has never signed in. A single-use, short-lived state nonce is the whole control, and it is redeemed once. Do not treat it as an ordinary admin endpoint, and do not put it behind the admin gate: every real customer would be refused.

A connection's status is PENDING_CONSENT, AWAITING_CLIENT, ACTIVE or SUSPENDED. See Machine-to-Machine Authentication for what each stage is waiting on.

Feature configuration

See Operational Controls — feature configuration under Users and access. A script enabling a module across a group should write the company default once rather than iterate registrations; use POST /gstins/bulk-toggle only where specific registrations must differ from their company.

Session policy

Requires admin.manage_users.

Method Path Purpose
GET /api/admin/session-policy The caller's own organisation's effective concurrent-session limit
POST /api/admin/session-policy Set ({ maxConcurrentSessions: 1-20 }) or clear ({ maxConcurrentSessions: null }) an override

Unlike a feature flag, which can be overridden per GSTIN, this is per organisation only — a user's device limit does not change depending on which registration they are currently viewing. An organisation with no override uses the platform-wide SESSION_MAX_CONCURRENT default. See User Authentication.

A Platform Super Admin may target another organisation by adding orgId — as a query parameter on the GET, or a body field on the POST. For every other caller orgId always comes from their own token; a value supplied any other way is ignored, the same rule every other cross-tenant admin read in this API follows.

Per-user exceptions

One person can hold a different limit than the rest of their organisation — e.g. someone who genuinely works from two devices at once — without raising the org-wide setting above for everyone else.

Method Path Purpose
GET /api/admin/user-session-policy?userId=X One user's own override (if any), plus their organisation's effective limit for context
GET /api/admin/user-session-policy Every user in an organisation that currently has an override — the exceptions list
POST /api/admin/user-session-policy Set ({ userId, maxConcurrentSessions: 1-20 }) or clear ({ userId, maxConcurrentSessions: null }) one user's override

Resolution order at login is: this user's own override, else their organisation's override, else the platform-wide default. A user with no row here simply inherits whatever the organisation is set to.

The list GET (no userId) scopes to the caller's own organisation, or another one via ?orgId= for a super_admin. The org-membership boundary is enforced server-side the same way PUT /users/:userId enforces it: a non-super caller can only view or set an exception for a user in their own organisation, regardless of what userId or orgId they pass.

Observability

Base path /api/admin/observability. Every endpoint is GET, read-only, and requires observability.platform, held only by Complifly's own roles (Super Admin, Support Engineer, Platform Engineer). A customer's Org Admin or External Auditor receives 403. These endpoints read platform-wide records that are not separated by customer, so the permission is deliberately not admin.view, which customer roles also hold. Customer-scoped dashboards are planned. Backs the Admin & Logs > Observability tab; see Logs and Observability for what each panel means.

Method Path Purpose
GET /platform SQL status, open alerts, today's critical events, and a 5-minute in-memory HTTP metrics snapshot (requests/min, error rate, success rate, avg response time — resets on process restart)
GET /gst Invoice posting success/failure counts and trend
GET /einvoice IRN volume and per-GSP-provider performance
GET /ewb E-Way Bill generation/extension/cancellation activity
GET /session Active session count, concurrent-limit violations, forced logouts
GET /gsp Per-provider success rate and latency
GET /security-health Latest npm audit snapshot (critical/high/moderate counts) — visibility only, not the full Vulnerability Management program. Carries a status of OK, PARTIAL or UNKNOWN: an audit that could not run is UNKNOWN and its counts are null, never zero, and the response says which side failed and why
GET /support-readiness Whether support is able to do its job: who receives P1 and P2 alerts (set, and not a placeholder), outbound mail, the health sweep, error tracking, the support matrix (NOT_SUPPLIED until the business supplies all of it; while only part is recorded it says what is recorded and what is missing), a degraded policy gate, and the dependency audit. Never returns a full email address or a secret. See Support Readiness
GET /security-events Authentication monitoring: failed sign-ins (15 min / 1 h / 24 h), split by unknown email / wrong password / inactive user, rate-limit blocks, successes that followed 5+ failures, an hourly series, the five most-targeted accounts, the five top source addresses, the alert thresholds with their current values, and whether login rate limiting is on for the answering server

None of these tables carry Row-Level Security — they hold operational, not tenant, data — so the route-level observability.platform guard is the only access control. See the RLS decision recorded in migrations/20260916_platform_event_log.sql.

Housekeeping

Five more GET endpoints under the same /api/admin/observability base and the same observability.platform guard, backing the Administration > Observability > Housekeeping panels — retention, archive, quarantine lifecycle and legal hold status.

Method Path Purpose
GET /housekeeping-overview Total DB size, 30-day growth delta, records archived/purged (30d), active legal holds, failed jobs (30d)
GET /housekeeping-growth Largest tables (latest snapshot), fastest-growing tables (latest snapshot vs the earliest snapshot in the last 30 days, returned with fastestGrowingSince), a trend series for the top five tables, and a least-squares linear forecast of total database size 90 days ahead. A snapshot is every arc_growth_metric row sharing one snapshot_at. Each part returns insufficientHistory: true (the forecast also a reason) instead of a number when there is too little history; the forecast needs at least 4 snapshots spanning 7 days.
GET /housekeeping-quarantine Quarantine record counts by LifecycleStatus, an aging breakdown, open case-reference count, and active legal holds on quarantine records, from vw_hk_quarantine_lifecycle
GET /housekeeping-execution Recent arc_run rows (table, action, outcome, rows affected, duration, storage recovered), failed-execution count (30d), and archive/purge row totals (30d)
GET /housekeeping-governance Retention policies (hk_retention_policy) each with last_run_at, last_outcome and a runtimeState (ACTIVE, DISABLED, ENABLED_NOT_SCHEDULED, SCHEDULED_POLICY_OFF — the policy row combined with the answering server's job environment flag), active and recently-released legal holds, and policy violations (an enabled policy whose latest run failed)

None of these introduce new tables beyond what the Housekeeping module already owns (arc_growth_metric, arc_run, arc_legal_hold, hk_retention_policy, vw_hk_quarantine_lifecycle) — every figure is a direct aggregate, never a fabricated or estimated metric.

Base path /api/housekeeping. Every endpoint requires housekeeping.legalhold.manage — a narrower, separately-granted permission, not folded into admin.view or admin.manage_users, since placing or releasing a hold blocks or unblocks an archive job from ever touching a record again. arc_legal_hold carries no Row-Level Security (it is a control-plane table reached only through this permission-gated API, never a tenant-scoped screen).

Method Path Purpose
GET /legal-hold List holds. Query params: entityType (optional filter), activeOnly (default true)
POST /legal-hold Place a hold. Body: entityType, entityId, reason (required), gstin, caseRef (optional). Rejects with 400 for an unsupported entityType and 409 if that entity already has an active hold
POST /legal-hold/release Release a hold. Body: entityType, entityId, note (optional). 404 if no active hold exists for that entity

entityType is enforced for archiving on QUARANTINE_HEADER (quarantine archive) and SYS_AUDIT_LOG (audit-log archive, entityId = the audit LogId) — POST /legal-hold rejects any other entityType with 400, because no job would check it and the hold would protect nothing.

Operations Control Tower

Base path /api/admin/ops-control-tower. GET, read-only, Complifly staff only — same access model as Observability above.

Method Path Purpose
GET /summary Open platform alerts, 24h critical failures by category, ERP integration queue counts, background job counts, and today's IRN/EWB failure counts

Aggregates existing tables only — platform_alert_log, platform_event_log, integration_outbound_event, Upload_Job_Log — no new dataset. Backs the Admin & Audit > Operations tab; the integration-queue counts it reports are actioned through the existing Write-back operations endpoints below (requeue/replay/skip), not through this endpoint.

Support Investigation

Base path /api/admin/support. GET, read-only, requires support.investigate (Org Admin, Platform Super Admin, Complifly Support Engineer). It reads only data Complifly already holds — it never calls the GSP or the government systems — and every call writes an audit entry (SUPPORT_INVESTIGATION_SEARCH / SUPPORT_INVESTIGATION_VIEW).

Method Path Purpose
GET /search?q=&from=&to=&gstin= Find documents, intake refusals, quarantined rows, jobs and events by document number (exact or prefix), IRN, e-way bill number, acknowledgement number, ERP reference, request ID or job number. With no q: the company's recent problems in the date range (default: last 30 days)
GET /document/{invHdrId} One document: current status, next step with who fixes it, and the full ordered timeline
GET /rejection/{rejectionId} One refused send: reasons, sender, repeat count, the payload as sent and as mapped

The caller's own company is always used. A Platform Super Admin may add ?companyId= to any of the three; the parameter is ignored for every other role. Stored GSP responses and payloads are returned up to 4,000 characters. The screen-by-screen procedure is Investigate a Document with Support Investigation.

Response Condition
403 The caller's role does not hold support.investigate
404 No such document or rejection for that company — an id belonging to another company is indistinguishable from one that does not exist

Access (read-only)

Base path /api/admin/access. Every route is read-only and requires admin.view, so an Org Admin, an External Auditor, Complifly support and platform engineers, and a Super Admin can call it. Nothing here changes a role, a control or anyone's access, and nothing here refuses a request: the middleware stays the only thing that does, and a test runs the real middleware against this API's answers for every role, permission and switch state so the two cannot drift.

Method Path Purpose
GET /permissions Every permission, each with the module switch that gates its routes (module, moduleScope GSTIN or ORG, and partial when only some of its routes sit behind the switch)
GET /people?companyId= The people and registrations of one company, for choosing who to ask about. Org Admin and auditor always get their own company and companyId is ignored. Complifly staff and a Super Admin must name one
POST /explain One question. Body { "userId", "gstin", "permission" }. gstin is omitted for an organisation-level permission (the tds.* family)
GET /review?companyId=&from=&to=&asOf=&format=&part= The quarterly access-review export. See below
GET /controls-matrix One row per operational control: module, owner class, effect on access, default, scope, and for each role None, View, Edit or Edit and force

What /explain returns. allowed, then gates: seven entries in the order a request meets them (L0 account, L1 role, L2 registration in the person's access list, L3 module switch, L4 customer policy, L5 segregation of duties, L6 document workflow). Each carries status (pass, refuse, n/a, info, per-document, unknown), a plain detail, who owns it, when a change would apply and what to do. firstRefusal is the gate the server would actually refuse at, with the same code the real call returns (PERMISSION_DENIED, GSTIN_NOT_PERMITTED, FEATURE_DISABLED_BY_OPERATOR, SESSION_REVOKED for a deactivated account). alsoRefused lists the gates that would refuse next, so fixing one does not leave a surprise.

The access-review export (GET /review). Returns, for one company: people (each person's role, approval role, registrations, and for each of eleven sensitive permissions ENFORCED (held under the designed rules on at least one registration they can use), RELAXED_ONLY (held only because segregation of duties is relaxed on one) or NONE), registrations (segregation of duties on the date and now, when and by whom it last changed, and why), changes (the ledger for the period), accessBasisActivity (actions per person taken under a relaxed rule), summary and gaps. from and to default to the last 90 days, at most 400 days apart. asOf is optional, not in the future, and rebuilds the people and the segregation-of-duties state for that date; each person then carries verification: VERIFIED, or UNVERIFIED with verificationNotes when a later change did not record what it replaced. format=csv needs part (people, registrations, changes, basis or summary), one table per file, with a leading byte-order mark and formula-neutralised cells. Every call is recorded as ACCESS_REVIEW_EXPORTED against the company reviewed, whoever ran it. A company-scoped caller always gets their own company and companyId is ignored; Complifly staff must name one.

Who may ask about whom. An Org Admin or External Auditor may ask only about people and registrations of their own company, taken from the token. A person or registration in another company is answered 404 NOT_FOUND, identical to one that does not exist, so the endpoint cannot be used to discover other customers. Complifly support and platform engineers and a Super Admin may ask about any company; a question from support or platform staff is written to that customer's audit log as ACCESS_EXPLAINED.

Two behaviours worth knowing before you build on it. First, segregation of duties is read per registration, so an organisation-level permission such as tds.view names no registration and the server always applies the enforced rules to it: relaxing segregation of duties never widens TDS. Second, L6 is never predicted: the approval level, the person who submitted a document and its state are decided when the action is attempted.

Response Condition
400 INVALID_PERMISSION permission is not a permission in this build
400 GSTIN_REQUIRED The permission is registration-level and no gstin was given
400 USER_REQUIRED / COMPANY_REQUIRED userId missing; or staff called /people without companyId
403 PERMISSION_DENIED The caller's role lacks admin.view
403 NO_ORGANISATION A company-scoped caller whose token names no organisation
404 NOT_FOUND No such user or registration in the caller's company; or, for /review, no such company
400 PART_REQUIRED, INVALID_FORMAT, INVALID_DATE, INVALID_RANGE, INVALID_ASOF, RANGE_TOO_LARGE /review input: a CSV without a part; a format that is not json or csv; a date that is not a date; from after to; an asOf in the future; a period over 400 days

Elevated access

Mounted at /api/admin/elevated. Every route needs a signed-in user; reads need admin.view, every write needs admin.manage_users. A customer's person always acts on their own company (from the token); a Super Admin names it with companyId. See Elevated Access for what each does.

Method Path Purpose
GET /overview?companyId= { companyId, mode, strict, can, emergency, support, grants[], windows[], requests[], me }. can says what this caller may do. A Complifly support or platform engineer may read it; in a Strict company they need an open support window (403 SUPPORT_WINDOW_REQUIRED)
GET /mine { grants: [{ grantId, roleKey, expiresAt }] }: the caller's own active emergency grants
POST /emergency-grants Lend the rights of one role. Body { companyId?, granteeUserId, roleKey, durationMinutes, reason, approvalRef? }. approvalRef is required in Strict. 201 { grant, notice }
POST /emergency-grants/{id}/revoke End one early. Body { reason }
POST /support-windows The company's own Org Admin opens one. Body { hours?, reason, ticketRef? } (1 to 168 hours, default 24). 201 { window, notice }
POST /support-windows/{id}/close End one early (Org Admin or Super Admin)
POST /role-requests/{id}/approve A second person approves a privileged role. Body { note? }
POST /role-requests/{id}/reject Body { note } (required)
POST /role-requests/{id}/cancel The requester withdraws

Changes to the user routes in a Strict company. POST /api/admin/users with role org_admin or super_admin creates the account inactive and answers 202 { userId, approvalRequired: true, requestId }. PUT /api/admin/users/{userId} with such a role applies every other field, leaves the role unchanged, and answers 200 with approvalRequired: true, requestId. Both accept an optional roleChangeReason. While a request is pending, activating the person or changing their role by PUT answers 409 ROLE_REQUEST_PENDING.

Errors are listed in Platform Error Codes.

Integration policies

Base path /api/admin/policies. Platform Super Admin only. Limits resolve connection override → company override → global, per limit. Endpoints, body and behaviour are on Integration Policies.

Provider configuration

Base path /api/gsp and /api/admin/gsp

Method Path Purpose
GET /capabilities Capability matrix
GET /capabilities/document Capabilities for a specific document, following its provenance
GET /providers Known providers
GET /config Current assignment
POST /config Assign a provider
DELETE /config/{id} Remove an assignment
POST /test Test the connection
GET /live-documents Documents still live against a provider

live-documents is the endpoint to consult before decommissioning a provider's credentials after a switch. Documents issued by that provider must still be serviceable by it.

Document routing

Base path /api/admin/doc-type-routing

Method Path Purpose
GET / Current rules
GET /resolve/{docType} How a type resolves
POST / Create a company rule (no update; see below)
DELETE /{id} Remove a company rule

POST and DELETE require admin.manage_users (Org Admin, Platform Super Admin) and return 403 otherwise; until 2026-10-02 any signed-in user could call them. The two GETs remain open to any signed-in user.

These are the only way to manage routing — there is no admin console screen for it, and there is no update operation: changing a route means deleting the company rule and creating it again. Global defaults cannot be deleted. See Document Type Routing.

Write-back operations

Base path /api/v1/integration/admin

Method Path Purpose
GET /events Inspect events
GET /events/{eventPk}/attempts Delivery attempt history
GET /requeue/preview Preview what a requeue would affect
POST /requeue Requeue failed events
POST /events/{eventPk}/replay Replay one event
POST /skip Skip events deliberately

Always run the preview before a requeue. A requeue with a wider scope than intended replays a large volume into a consumer that may not be ready for it.

Write-back subscriptions

Base path /api/admin/writeback-subscriptions. Platform Super Admin only.

Method Path Purpose
GET / Subscriptions, the connections eligible for one, and the emitted event catalogue
POST / Create a subscription for a connection and registration
PATCH /{id}/active Pause or resume

GET / reports which active connections have no subscriber, which is the condition worth acting on: without a subscription, that ERP's documents emit no events at all and nothing errors on either side.

There is deliberately no delete. Pausing stops delivery while events keep accumulating and drain on resume; a deleted row is indistinguishable from one that was never created, which is the state this endpoint exists to make visible.

The event catalogue is served by the API rather than held by any client. A subscription's event filter is an allowlist, so a type missing from a stale local list would silently drop that whole event type for that ERP.

Onboarding

Base path /api/admin/onboarding

Method Path Purpose
GET /defaults Default provisioning settings
GET /companies Existing companies, each with its status and any issues
GET /companies/{companyId} One company's profile
GET /companies/{companyId}/lifecycle Status, what it means at runtime, why and when it last changed, and the audited history
PATCH /companies/{companyId}/status Change a company's status. The only way a status changes
GET /preflight Check before provisioning
POST /provision Provision a company or registration

Provisioning creates a tenant boundary, so it is a platform-level act and is restricted accordingly. Run the preflight check first. Provisioning into a suspended or archived company is refused with 409 TENANT_SUSPENDED or 409 TENANT_ARCHIVED before anything is written; reactivate the company first.

Changing a company's status

All of /api/admin/onboarding is Complifly Super Admin only; an Organisation Admin receives 403. What each status means is in Company Status.

PATCH /api/admin/onboarding/companies/{companyId}/status

{ "status": "SUSPENDED", "reasonCode": "SECURITY", "reason": "Credentials reported stolen, pending review" }
Field Rule
status ACTIVE, SUSPENDED or ARCHIVED
reasonCode SECURITY, LEGAL, CUSTOMER_REQUEST, OPERATIONAL or OFFBOARDING
reason 5 to 300 characters; kept in the audit trail

A commercial reason code (NON_PAYMENT, OVERAGE, PLAN, QUOTA and others) is refused with 422 COMMERCIAL_SUSPENSION_NOT_SUPPORTED: commercial matters are escalated to the customer first, and no commercial suspension has been approved. The change is conditional on the status you last read, so two administrators acting together cannot both succeed (409 STATUS_CHANGED_MEANWHILE). Every attempt, including a refused one, is audited as COMPANY_STATUS_CHANGED.

The response returns companyId, from, to, reasonCode, reason, changedAt and changedBy.

Usage

Base path /api/usage — read-only counts of IRNs, e-way bills, cancellations, uploads, prints and ERP events, by registration. Documented in full in the Usage API.

Integration visibility

Base path /api/admin/integration-visibility — Complifly Super Admin only; read-only.

Method Path Purpose
GET /connections Every ERP connection, OAuth client, API key, GSP configuration, agent and write-back subscription, in one vocabulary. ?companyId= narrows to one company
GET /vocabulary The states and kinds, so a client need not hard-code them

Each item states its kind, a state from one shared vocabulary (ACTIVE, PENDING, ATTENTION, SUSPENDED, BLOCKED, EXPIRED, REVOKED, DISABLED), the raw state it came from, and the facts held about it: last activity, credential expiry and days remaining, documents accepted and rejected in the last 7 and 30 days, circuit state and event backlog. It reports facts and no score. It never returns a secret: no key hash, credential reference or certificate.

Validation

Check Method Pass condition
Administrative authority required Call with a non-administrative token Refused
User creation works Create one and sign in as it Succeeds
Credential issue captures the secret Inspect your provisioning script The secret is stored at issue
Revocation is immediate Revoke and call an ingest endpoint Refused
Feature toggle applies per registration Toggle for one registration and check another Only the intended one changed
Provider test works Run it Success
Live documents checked before decommissioning Query before withdrawing old credentials An empty result before withdrawal
Requeue previewed Preview before requeuing The scope matches expectation
Audit entries appear Perform an administrative action Recorded with the correct actor

Troubleshooting

Symptom Cause Action
403 on every administrative call Token lacks an administrative role Use an administrative account
403 UNKNOWN_ROLE when creating a user role misspelt, wrong case, or not a real role Send one of the values the message lists for your account
403 PLATFORM_ROLE_REQUIRES_SUPER_ADMIN An org administrator tried to grant super_admin, support_engineer or platform_engineer Correct and permanent: platform roles are Complifly's own and reach across customers. Ask Complifly if a platform-level account is genuinely needed
403 PLATFORM_USER_NOT_MANAGEABLE when editing, deleting or signing out a user The person holds a Complifly platform role Correct. Only a Super Admin can change that account. If a platform account appears in your company and you did not expect it, tell Complifly support
403 ROLE_NOT_CLASSIFIED on an old account The person holds the legacy admin role or another role that no longer exists Edit the person and choose a standard role. That edit is allowed and is the repair
A user was created successfully but cannot approve anything Only one of role / approvalRole was set Both are required. Check the user record and set the missing one
Clearing an approval role has no effect The field was omitted rather than sent as null Send approvalRole: null explicitly on PUT
A credential is unusable after provisioning The secret was not captured at issue Revoke and re-issue, capturing it this time
A feature toggle appears not to work Applied to one registration only, or the brief cache has not expired Iterate all registrations; wait a minute
Cancellations fail after decommissioning a provider Live documents still existed for it Restore the credentials; check live-documents before withdrawal
A requeue flooded a consumer Requeued without previewing Preview first. Pause the consumer before a large replay
Audit trail cannot attribute an old action The user was deleted rather than disabled Disable, never delete
Provisioning fails Preflight conditions not met Run the preflight check and resolve what it reports
An automation broke after an upgrade It depends on the console's own endpoints, which follow the interface Automate provisioning and bulk work only; use the console for occasional administration