On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Not a developer? The step-by-step guides begin at Start Here, and a problem on your screen is in When something goes wrong.
API Reference
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it.
A hand-checked subset of your instance’s API: the routes an operator reaches for most, each read from the platform’s route definitions. It is not the full list. Your instance also serves a machine-readable OpenAPI file for a smaller part of it: see Machine-Readable Spec below. The catalogue on this page can itself be downloaded as two files: see Download The Catalogue below. Every path is relative to https://<your-instance>-api.zonitix.com/api/v1.
https://<your-instance>-api.zonitix.com is your instance’s API address: your instance name followed by -api. It is a different address from the one people sign in at, https://<your-instance>.zonitix.com, which serves the screens and the static page at /api/docs and does not answer /api/v1 calls. Every example in these docs uses the two placeholders the same way; API Authentication says which address each header takes.
How To Read This
- Public — callable without a session, and rate-limited accordingly.
- Session — needs a signed-in session cookie, or a personal API token sent as
Authorization: Bearer zx_…(see Authenticating below). - Session + permission — the listed permissions are alternatives; holding any one suffices.
- + sudo — additionally needs a fresh step-up re-authentication. Session-only: a personal API token cannot do a step-up, so it cannot call these.
- administrator role — needs the administrator role itself, not a permission. Session-only for the same reason: a token never carries a role.
- (licensed) — also requires the module to be active on your plan.
Scope Note
This page documents part of the surface reachable through your instance’s gateway, not all of it. The tables below hold 869 routes in 873 rows across 41 groups (a few routes are listed in more than one group); each group says where it is a subset. For the release this page was checked against, the platform’s own route counter reports 1,059 route declarations: 917 in the twelve services it reads and 142 that the gateway answers itself. That figure is not a total to subtract these tables from: it includes routes that only another service on your instance can call, and it does not read the recruitment or document-parser services, whose routes are listed below. What it does show is that routes exist which are not listed here. A few things are deliberately not documented here: the Zonitix Sign signature-service (its /signature/* routes are driven by the recruitment flow the docs above cover, and its candidate signing portal is served by one-time token links, not a developer API); and internal service-to-service routes that require the gateway’s internal token. Rows carry an inline body / returns / guard line read out of the controllers. Every row has a returns line. Field types are given for the objects listed under Response Objects below. Some other rows give types inline in their returns line; the rest name their fields without types. 40 of the 41 groups in the catalogue open with one worked request and reply; the others have none yet.
Machine-Readable Spec (OpenAPI)
Your Instance Ships Its Own Spec
In plain words: your instance carries a description of part of its API that a program can read, in the OpenAPI 3.1 format. Open https://<your-instance>.zonitix.com/api/docs in a browser to read it as a page, or fetch the file itself from /api/docs/openapi.json. No sign-in is needed for either: the file describes the product, is the same on every instance, and holds none of your data. Every operation it describes still checks who is calling.
# Fetch the spec. It is served at the address you sign in at, not the API address; no token or cookie is needed.
curl -sS https://<your-instance>.zonitix.com/api/docs/openapi.json -o zonitix-openapi.json
# List every operation in it, with whether a personal API token may call it.
jq -r '.paths | to_entries[] | .key as $p | .value | to_entries[] | "\(.key | ascii_upcase) \($p) token: \(.value["x-api-token"])"' zonitix-openapi.json
- How it is produced. A generator reads the gateway’s route mounts and each service’s route files, and takes every path, method and permission check from that source. Summaries, request bodies and response schemas are then merged in from a hand-written overlay keyed by method and path; an overlay entry that matches no real route makes the generator fail, so the file cannot describe a route that has been removed.
- It is a file, not a live answer. The result is saved with the release and served by the web container as a static file. It is not computed when you ask for it, and it does not look at your instance’s settings, plan or data. An automated check that runs when the product’s code changes compares the saved file with a fresh run and reports a difference. It did not stop the release this page was checked against, where the two differed (next point).
- It can lag the routes. In the release this page was checked against, the saved file listed 92 operations while a fresh run of the generator produced 94: the two password-expiry routes under
/user-management/userswere in the product and not yet in the file. A route that is missing from the file is not proof that the route does not exist. - The server address is a template. The file names its server as
https://{apiHost}/api/v1. The page at/api/docsfills in your instance’s API address, and its openapi.json button downloads a copy with that address first. A file fetched withcurlkeeps the template, so set the server yourself in the tool you load it into:https://<your-instance>-api.zonitix.com/api/v1with your own instance name. The spec file and the page are served at the address you sign in at; the operations they describe are called at the API address. - Its version field is not a release number.
info.versionis the fixed text1.0.0. It does not change from release to release, so do not use it to tell two releases apart.
What The Spec Covers
Nine areas, 92 operations in the saved file. 45 of them carry a response schema. The other 47 list their path, parameters and permissions, and say in the file itself that the response body is not yet documented; for many of those the catalogue rows on this page do give the reply. The spec is enough to generate a client for these nine areas only. Your instance serves no machine-readable description of any other part of the API; this documentation site publishes its own catalogue as files, with far fewer types, under Download The Catalogue below.
| Area | Operations | What is in it |
|---|---|---|
| Users | 17 | The directory: list, search, create, update, delete, bulk change, invites, onboard, offboard, import, export. |
| Groups | 5 | Groups: list, create, update, delete, read members. |
| Roles | 6 | Roles: list, create, update, delete, read members, set permissions. |
| Permissions | 1 | The permission list, read only. |
| Workflows | 14 | Pipelines: list, create, edit, promote, reject, clone, dry run, retire, import, export. |
| Workflow runs | 12 | Runs of a pipeline: list, start, read, update, claim, run a step, schedule, recall. |
| Devices | 28 | Device reads only: the inventory, one device, software, groups, profiles, MDM status. No device write is in it. |
| Audit logs | 3 | List, export and verify the audit log. |
| API tokens | 6 | Token management, marked as not callable with a token. |
Besides the standard OpenAPI fields, operations carry these extension fields, which a script can read to decide whether it may call an operation at all. The generator can also write x-license-feature (a module must be on your plan) and x-permissions-stripped-by-gateway; no operation in the saved file carries either. The second one lists permissions that the service’s route accepts and the gateway does not forward on that path: before a request reaches a service, the gateway keeps only the permissions whose name matches the keywords for that route prefix, so a permission in that list does not open the operation. x-required-permissions already leaves those out. The rule is set out under Gateway-To-Service Trust.
| Field | Meaning |
|---|---|
| x-required-permissions | The permissions that open the operation. Holding any one is enough. An empty list means any signed-in caller. |
| x-auth | In the saved file: permission (one of the listed permissions is needed) or authenticated (any signed-in caller). |
| x-api-token | Whether a personal API token can call it: allowed, not-allowed, or "allowed (not for password changes)". |
| x-requires-sudo | Present when a fresh step-up is needed, which a token cannot do: true, or conditional when only a password change needs it. |
| x-source | The service and route file the operation was read from. |
What The Spec Does Not Cover
Most of the API. The generator’s own count for the same release is 1,059 route declarations: 917 in the twelve services it reads and 142 that the gateway answers itself. The saved spec file holds 92 of them. The count takes in routes that only another service on your instance can call, and the generator does not read the recruitment or document-parser services at all, so their routes are in neither number. Outside the spec are:
- Every module other than the nine areas above: compliance and reporting, recruitment, finance, Security Lab, cloud discovery, HR records, training, the AI routes, identity hooks and alignment.
- The sign-in, MFA, passkey and settings routes under
/auth. - Every device write: commands, enrolment, groups, profiles and MDM changes.
- SCIM. The spec names the SCIM base address and says it follows the SCIM standard, without listing its operations.
- Four things left out on purpose inside the nine areas: the org-chart reads, the one-time credential reveal on a run, the directory-driven leaver route that only another service calls, and deleting audit logs, which is always refused.
For those, the Route Catalogue on this page is the written reference and the fuller of the two lists: its scope note above gives the number of routes it holds, which is several times the 92 in the spec. It is hand-checked against the same route source, and it is also a subset. It can be downloaded as JSON and as an OpenAPI document in which most replies and many request bodies are untyped: see Download The Catalogue below.
Download The Catalogue
In plain words: the Route Catalogue on this page can be downloaded as two files, so a script or a tool can read it without copying from the page. They hold 869 routes in 873 rows across 41 groups, and the typed objects. Both come from this documentation site, not from your instance, are the same for every customer and contain none of your data.
- Route catalogue (JSON)
api-catalogue.jsonEvery row of the Route Catalogue below, in the order printed: group, method, path, purpose, the auth text, and the request, reply and guard lines word for word. Read out of that text and added beside it: the permission names, whether a step-up or the administrator role is needed, whether the module must be on your plan, the paging parameters the row names, and the reply split wherever the catalogue separates replies with a bar, with the entries that start with an error status listed on their own; a row that writes its errors in a sentence has them in the text of that entry. The typed objects under Response Objects are in the same file, field by field.
It is not OpenAPI. The request and reply of a row are a line of prose, not a schema.
- The same rows as OpenAPI 3.1
api-catalogue.openapi.jsonOne operation per route, built from the same rows, for tools that load OpenAPI. Each operation carries the catalogue’s text in its description and the same auth details in extension fields (x-auth, x-permissions-named, x-step-up, x-administrator-role, x-licensed). The typed objects are real schemas under components.schemas. Where a request or reply line opens with a typed object, a list of one, or an object whose keys hold them, the operation’s schema points at it; an operation also names every typed object its text mentions in x-typed-objects.
Where the catalogue gives no field types, the request or reply schema is the empty schema, which means “not described”, not “anything is accepted”. Not every part is typed: some path parameters stay plain strings, query parameters are listed for some operations only (an operation that lists none may still read some), and some request bodies and replies are the empty schema, so a client generated from this file will have untyped parts. The file counts each of these itself, in x-catalogue.schemaCoverage.
- Neither file is the one your instance serves. The file at
/api/docs/openapi.jsonon your instance is a saved file built from the product’s route definitions and shipped with the release it runs. It covers fewer routes than these files, nine areas, and gives response schemas for some of them. These files cover more routes and type fewer of them. Where both describe a route, the instance’s file is the one built from the route definitions. - Both are a subset. A route that is in neither file may still exist. The scope note at the top of this page says what is left out.
- They are hand-checked text. Each row was read from the product’s route definitions and handlers at one revision, which both files name (
appRevisionin the JSON,info.versionin the OpenAPI file). They do not change when your instance is updated, and they do not look at your plan or settings. - Personal API tokens. The files do not say, route by route, whether a personal API token may call it. An operation that needs a step-up or the administrator role cannot be called with a token; for the rest, see Authenticating below.
Authenticating
A script or integration authenticates with a personal API token, sent as Authorization: Bearer zx_…. The web app itself uses a session cookie; both reach the same routes and pass the same permission checks. To create a token, follow Create an API token; for the sign-in and step-up flow, see API Authentication.
- Who can create one: anyone holding
create_api_tokens. Administrators hold it; no other built-in role does until you add it to one. Creating a token asks you to re-confirm your identity. - What it can do: only the permissions you tick when creating it, and never more than you hold. This is re-checked on every call, so a token loses a permission the moment its owner does, and stops working when its owner is blocked or offboarded. One permission is never offered for a token:
create_api_tokens. - What it looks like:
zx_followed by 43 characters fromA-Z a-z 0-9 - _. It is shown once, when it is created. - Limits: up to 20 active tokens per person; expiry is 90 days by default and one year at most; 300 requests per minute per token. Over the limit you get
429withRateLimit-*headers saying when to retry. - What a token cannot call: anything marked + sudo (a token cannot do a step-up); routes that require the administrator role rather than a permission (a token never carries a role); the sign-in, MFA, password and
/auth/meroutes; and token management itself, so a leaked token cannot mint a replacement. - Calling with a session cookie instead: the routes a token cannot call are reached with the signed-in session cookie (
__Host-token). A cookie-authenticated write (anything other thanGET,HEADorOPTIONS) must also send anOrigin(orReferer) header for your instance’s own address, or the gateway answers403before the route runs. A request that carries anAuthorizationheader is not subject to that check, so token calls need noOrigin. - Shown once: the token value is displayed only at creation and stored only as a hash. Revoke it from System → API Tokens; an administrator with
manage_securitycan see and revoke every token on the instance.
# Keep the token in an environment variable, never in the script.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/users
# Session-only route (step-up or administrator role): the cookie plus an Origin header on a write.
curl -X PUT https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/release-channel \
-H 'Origin: https://<your-instance>.zonitix.com' -H 'Content-Type: application/json' \
--cookie '__Host-token=<session-jwt>' -d '{ "channel": "stable" }'
# Without a fresh step-up this answers 400 { "error": "SUDO_REQUIRED" }
Your instance’s own OpenAPI document marks, operation by operation, whether a token may call it. Where it is, how to fetch it and what it leaves out are in Machine-Readable Spec (OpenAPI) above.
Response Objects (Typed Fields)
In plain words: the catalogue rows further down say what a call answers in one line, such as “an array of users”. This section says what is inside those objects: each field, its type, and whether it can be empty. Open an object to see its fields.
- Where the list comes from. The first two groups are the objects your instance’s own OpenAPI document declares, so the same field lists are in
/api/docs/openapi.jsonundercomponents.schemas. They cover users, groups, roles, permissions, workflows, workflow runs, devices, audit-log entries and API tokens. The groups after the first two (scheduled reports and sealed runs, share links, the compliance registers, the backup inventory, Security Lab issues and remediations; the company data export job, the environment profile and the period report; the HR objects; the hiring objects; the finance objects; the device objects; the sign-in, MFA and passkey objects; the single sign-on, SCIM, access review, access request, log forwarding and support access objects; and the security settings, identity hook, cloud map, local AI model and notification objects) are not in that file: those field lists were read from the code that builds each reply, and for them a field marked nullable can benull; the other fields are normally sent, and a field’s or an object’s note says when one is present only sometimes. No group is typed in full: a row whose reply is not one of these objects names its fields in the row, some with types and some without. - Types.
integer,string,boolean,objectand arrays written asinteger[]. A string that is a date and time is writtenstring (date-time)and is ISO 8601 in UTC;string (date)isYYYY-MM-DD. A name such asUserRefis another object in this list. A type followed by a colon and values separated by|accepts only those values. - In a reply: “nullable” means the field can be
null; “read-only” means the server sets it and ignores it in a write. In the first two groups the source marks only one reply field as always present,erroron theErrorobject; treat every other field there as possibly absent. In every group, do not fail on one you do not recognise: a reply can carry fields beyond these. - In a request body: “required” fields must be sent; everything else is optional. “null clears it” marks a field that accepts
nullas the way to empty it. Unknown keys are dropped by the write handlers described in the section below. - Where a catalogue row says more, follow the row. The rows were read from the handlers and are sometimes more specific than these lists. One example: an API token’s
statusis listed here as active, expired or revoked, and the token routes can also answerinvalidated. Another: an audit-log entry’slevelis listed here as info, warning, error or critical, and a stored level can also bewarnordebug. - Never returned: password hashes, MFA secrets, recovery codes, invite tokens, a device’s agent key, and an API token’s value after the moment it is created.
Directory: users, groups, roles, permissions
Errorin a reply2 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| error | string | no | |
| message | string | no |
OkResponsein a reply1 field
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | boolean | no |
Userin a reply35 fields
A directory user. Credential material (password hash, MFA secrets, recovery codes, invite token) is never returned.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| firstName | string | nullable | |
| lastName | string | nullable | |
| string (email) | no | ||
| sAMAccountName | string | nullable | Login / directory account name (unique). |
| position | string | nullable | Job title. |
| department | string | nullable | |
| status | string | no | e.g. active, inactive, disabled, pending, offboarded, deleted |
| isPrimaryAdmin | boolean | read-only | |
| source | string | no | local or the directory it syncs from |
| sourceProviderId | integer | nullable | |
| photoUrl | string | nullable | |
| managerId | integer | nullable | |
| externalId | string | nullable | |
| employeeId | string | nullable | |
| phoneNumber | string | nullable | |
| personalEmail | string | nullable | |
| attributes | object | no | Custom directory attributes. |
| mfaEnabled | boolean | read-only | |
| mfaEnabledAt | string (date-time) | nullable, read-only | |
| mustChangePassword | boolean | read-only | |
| passwordLastChanged | string (date-time) | nullable, read-only | |
| personaId | integer | nullable | Persona (role template / bill of materials). |
| lastActiveAt | string (date-time) | nullable, read-only | |
| terminationDate | string (date-time) | nullable | Scheduled last working day; null cancels a scheduled offboarding. |
| inviteExpiresAt | string (date-time) | nullable, read-only | |
| invitedAt | string (date-time) | nullable, read-only | |
| failedLoginAttempts | integer | read-only | |
| lockoutUntil | string (date-time) | nullable, read-only | |
| sessionsInvalidatedAt | string (date-time) | nullable, read-only | |
| Groups | GroupSummary[] | read-only | |
| Roles | RoleSummary[] | read-only | |
| Manager | UserRef | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
UserCardin a reply7 fields
Minimal picker view of a user (returned to callers without directory permissions).
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| firstName | string | nullable | |
| lastName | string | nullable | |
| string (email) | no | ||
| position | string | nullable | |
| department | string | nullable | |
| photoUrl | string | nullable |
UserRefin a reply4 fields
A labelled user reference.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| firstName | string | nullable | |
| lastName | string | nullable | |
| string (email) | no |
UserPagein a reply2 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| count | integer | no | Total matching users. |
| rows | (User | UserCard)[] | no |
UserCreaterequest body16 fields
| Field | Type | Required | Note |
|---|---|---|---|
| firstName | string | optional | |
| lastName | string | optional | |
| string (email) | required | ||
| sAMAccountName | string | optional | |
| position | string | optional | |
| department | string | optional | |
| managerId | integer | optional | |
| personaId | integer | optional | |
| status | string | optional | |
| source | string | optional | |
| employeeId | string | optional | |
| phoneNumber | string | optional | |
| attributes | object | optional | |
| roleIds | integer[] | optional | |
| groupIds | integer[] | optional | |
| sendInvite | boolean | optional | Email a set-password link (recommended for API use). |
UserUpdaterequest body15 fields
| Field | Type | Required | Note |
|---|---|---|---|
| firstName | string | optional | |
| lastName | string | optional | |
| string (email) | optional | ||
| sAMAccountName | string | optional | |
| position | string | optional | |
| department | string | optional | |
| managerId | integer | optional, null clears it | |
| personaId | integer | optional, null clears it | |
| photoUrl | string | optional, null clears it | |
| employeeId | string | optional, null clears it | |
| phoneNumber | string | optional, null clears it | |
| terminationDate | string (date) | optional, null clears it | |
| status | string | optional | Non-terminal statuses only (use Offboard to terminate). |
| roleIds | integer[] | optional | Replaces the user's roles (requires manage_roles). |
| groupIds | integer[] | optional | Replaces the user's groups (requires manage_groups). |
UserBulkUpdaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| userIds | integer[] | required | |
| action | string: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole | required | |
| value | any | optional | Action argument (manager id, department, persona id, status in active|inactive|disabled, group/role id). |
Groupin a reply13 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| source | string | no | |
| externalId | string | nullable | |
| sourceProviderId | integer | nullable | |
| requiresMfa | boolean | no | Legacy mirror of mfaPolicy = require. |
| mfaPolicy | string: inherit | require | exempt | no | |
| mfaExemptUntil | string (date-time) | nullable | |
| mfaExemptReason | string | nullable | |
| Users | object[] | read-only | Member ids only. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
GroupSummaryin a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| description | string | nullable |
GroupWriterequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | |
| description | string | optional | |
| userIds | integer[] | optional | Replaces the membership. |
| mfaPolicy | string: inherit | require | exempt | optional | Requires manage_security. |
| mfaExemptUntil | string (date-time) | optional, null clears it | Requires manage_security. |
| mfaExemptReason | string | optional | Requires manage_security. |
Rolein a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| Permissions | Permission[] | read-only | |
| Users | object[] | read-only | Member ids only. |
| Groups | object[] | read-only | Groups the role is dynamically bound to. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
RoleSummaryin a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| description | string | nullable |
RoleWriterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | |
| description | string | optional | |
| userIds | integer[] | optional | |
| permissionIds | integer[] | optional | |
| groupIds | integer[] | optional |
Permissionin a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| module | string | nullable | |
| description | string | nullable | |
| Roles | RoleSummary[] | read-only | |
| Users | object[] | read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
Workflows, runs, devices, audit logs, API tokens
WorkflowNodein a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | no | |
| type | string | no | |
| data | object | no |
Workflowin a reply22 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| version | integer | read-only | |
| isCurrent | boolean | read-only | |
| type | string | no | onboarding | offboarding | custom type |
| description | string | nullable | |
| createdBy | string | nullable, read-only | |
| approvedBy | string | nullable, read-only | |
| approvedAt | string (date-time) | nullable, read-only | |
| isActive | boolean | read-only | |
| isDefault | boolean | read-only | |
| automationMode | string: manual | provisioning | auto | no | |
| status | string | no | draft | pending | approved | rejected |
| rejectionReason | string | nullable, read-only | |
| simulationRun | boolean | read-only | |
| personaOverrides | object | no | Per-persona exclusions: { "<personaId>": { excluded: [...] } } |
| nodes | WorkflowNode[] | no | |
| edges | object[] | no | |
| purgeable | boolean | read-only | Whether this never-used draft may be permanently deleted. |
| deletedAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
WorkflowWriterequest body8 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | |
| type | string | optional | |
| description | string | optional | |
| status | string: draft | pending | optional | Submit for approval with pending. |
| automationMode | string: manual | provisioning | auto | optional | |
| personaOverrides | object | optional | |
| nodes | WorkflowNode[] | optional | |
| edges | object[] | optional |
WorkflowRunin a reply18 fields
A run of a workflow (a "task").
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| workflowId | integer | no | |
| status | string | no | |
| title | string | no | |
| description | string | nullable | |
| currentStep | integer | no | |
| assignedTo | string | nullable | Queue that owns the current step. |
| assignedToUserId | integer | nullable | The one person who owns the step, when person-owned. |
| data | object | no | Run payload (temporary credentials are never included). |
| createdBy | string | nullable | |
| userId | integer | nullable | The subject user of the run. |
| sourceCandidateId | integer | nullable | |
| startDate | string (date) | nullable | |
| dueDate | string (date) | nullable | |
| nodes | WorkflowNode[] | nullable, read-only | Built server-side. Lists carry id + label/type only. |
| edges | object[] | nullable, read-only | Omitted in lists. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
WorkflowRunPagein a reply2 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| rows | WorkflowRun[] | no | |
| total | integer | no |
WorkflowRunCreaterequest body11 fields
| Field | Type | Required | Note |
|---|---|---|---|
| workflowId | integer | required | |
| title | string | required | |
| description | string | optional | |
| status | string | optional | |
| assignedTo | string | optional | |
| currentStep | integer | optional | |
| data | object | optional | |
| userId | integer | optional | |
| sourceCandidateId | integer | optional | |
| startDate | string (date) | optional | |
| dueDate | string (date) | optional |
WorkflowRunUpdaterequest body4 fields
| Field | Type | Required | Note |
|---|---|---|---|
| status | string | optional | |
| assignedTo | string | optional | |
| data | object | optional | |
| currentStep | integer | optional |
Devicein a reply40 fields
A device (read-only view). The device's agent key is never returned.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| type | string | nullable | |
| serialNumber | string | nullable | |
| status | string | no | |
| manufacturer | string | nullable | |
| userId | integer | nullable | |
| assetTag | string | nullable | |
| department | string | nullable | |
| location | string | nullable | |
| costCenter | string | nullable | |
| customFields | object | nullable | |
| platform | string | nullable | macOS | linux | windows |
| mdmUdid | string | nullable | |
| pendingCommand | string | nullable | |
| reEnrollAllowed | boolean | no | |
| enrollmentApproved | boolean | nullable | false = pending approval; null = grandfathered/trusted. |
| retiredAt | string (date-time) | nullable | |
| retiredReason | string | nullable | |
| hostname | string | nullable | |
| osVersion | string | nullable | |
| osBuild | string | nullable | |
| architecture | string | nullable | |
| chip | string | nullable | |
| modelName | string | nullable | |
| cpuCores | integer | nullable | |
| memoryBytes | integer | nullable | |
| diskTotalBytes | integer | nullable | |
| diskFreeBytes | integer | nullable | |
| osUpdateAvailable | boolean | no | |
| agentVersion | string | nullable | |
| agentVersionPin | string | nullable | |
| loggedInUser | string | nullable | |
| enrolledAt | string (date-time) | nullable | |
| lastCheckinAt | string (date-time) | nullable | |
| lastCheckinIp | string | nullable | |
| inventory | object | nullable | Raw agent/MDM inventory. |
| Owner | UserRef | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
DeviceFleetPagein a reply7 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| items | object[] | no | |
| total | integer | no | |
| page | integer | no | |
| pageSize | integer | no | |
| kpis | object | no | |
| staleDays | integer | no | |
| truncated | boolean | no |
AuditLogin a reply11 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| level | string | no | info | warning | error | critical |
| action | string | no | |
| details | string | nullable | |
| payload | object | nullable | Structured context; origin service in payload.source. |
| userEmail | string | nullable | Actor. |
| chainHash | string | nullable | SHA-256 of this row chained to the previous one. |
| prevHash | string | nullable | |
| chainSeq | integer | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
AuditLogPagein a reply7 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| total | integer | no | |
| logs | AuditLog[] | no | |
| counts | object | no | |
| windowDays | integer | no | |
| retentionDays | integer | nullable | |
| limit | integer | no | |
| page | integer | no |
AuditLogExportin a reply5 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| exportedAt | string (date-time) | no | |
| count | integer | no | |
| truncated | boolean | no | |
| windowDays | integer | no | |
| logs | AuditLog[] | no |
AuditChainVerificationin a reply6 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | boolean | no | |
| chained | integer | no | |
| unchained | integer | no | |
| firstBrokenId | integer | nullable | |
| brokenType | string | nullable | |
| anchor | object | no |
ApiTokenin a reply14 fields
An API token's metadata. The token value itself is only ever returned once, at creation; its hash is never returned.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| userId | integer | no | Owner. |
| owner | string | nullable | |
| name | string | no | |
| tokenPrefix | string | no | Non-secret leading characters (e.g. zx_AbC123), to recognise a token. |
| scopes | string[] | no | Permissions granted at creation. At use they are intersected with the owner's current permissions. |
| createdAt | string (date-time) | read-only | |
| expiresAt | string (date-time) | no | |
| lastUsedAt | string (date-time) | nullable | |
| lastUsedIp | string | nullable | |
| revokedAt | string (date-time) | nullable | |
| revokedBy | string | nullable | |
| revokeReason | string | nullable | |
| status | string: active | expired | revoked | no |
ApiTokenScopeCatalogin a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| scopes | object[] | no | |
| defaultExpiryDays | integer | no | |
| maxExpiryDays | integer | no |
ApiTokenCreaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| expiresInDays | integer | optional | |
| scopes | string[] | required | Subset of your permissions. |
ApiTokenCreatedin a reply1 field
Every field of ApiToken, plus:
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| token | string | no | The plaintext zx_… token. Shown once. |
Reports, sealed runs, share links, auditor links, company data export
ReportSchedulein a reply29 fields
Answered by the schedule list, create and update routes. The webhook address is never returned in full.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| kind | string | no | compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2 |
| framework | string | nullable | |
| queries | string[] | no | Data set names. Empty array when none. |
| filters | object | no | Filter values keyed by filter name. Empty object when none. |
| window | object | nullable | { from, to } |
| sections | string[] | nullable | summary | controls | tables |
| columns | object | nullable | Query reports only. |
| groupBy | string | nullable | |
| variant | string | nullable | Budget reports only. |
| lifecycleType | string | nullable | leaver | joiner |
| formats | string[] | no | pdf | csv | xlsx | pptx |
| recipients | string[] | no | Email addresses. |
| everyHours | integer | no | 1 to 2160 |
| enabled | boolean | no | |
| webhookUrl | string | nullable, read-only | Masked: the host and the last four characters. |
| webhookConfigured | boolean | read-only | |
| webhookSigned | boolean | read-only | True when a signing secret is stored. |
| webhookSecretRotatedAt | string (date-time) | nullable, read-only | |
| webhookSigningSecret | string | read-only | Present only on the reply that issued it. |
| lastRunAt | string (date-time) | nullable, read-only | |
| lastStatus | string | nullable, read-only | The status of the last run. |
| failureAlertedAt | string (date-time) | nullable, read-only | |
| owner | string | nullable, read-only | updatedBy, or createdBy when it was never edited. |
| createdBy | string | nullable, read-only | Email address. |
| updatedBy | string | nullable, read-only | Email address. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
ReportRunin a reply41 fields
One row of GET /compliance/reports/runs, in both the run history and the evidence vault (?vault=1). File bytes are never in the row.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| scheduleId | integer | nullable | null for a run that no schedule started. |
| scheduleName | string | nullable | |
| kind | string | no | |
| framework | string | nullable | |
| trigger | string | no | scheduled | manual |
| triggeredBy | string | nullable | |
| status | string | no | running | ok | failed | interrupted |
| error | string | nullable | Why the run failed. |
| readinessScore | integer | nullable | |
| passCount | integer | nullable | |
| failCount | integer | nullable | |
| manualCount | integer | nullable | |
| totalControls | integer | nullable | |
| naCount | integer | nullable | |
| applicable | integer | nullable | |
| delivered | boolean | no | Whether the email was sent. |
| deliveryError | string | nullable | |
| deliveryAttempts | integer | no | |
| deliveryMode | string | nullable | |
| nextRetryAt | string (date-time) | nullable | |
| claimedAt | string (date-time) | nullable | |
| durationMs | integer | nullable | |
| observationFrom | string (date-time) | nullable | |
| observationTo | string (date-time) | nullable | |
| artifactHash | string | nullable | SHA-256 of the stored PDF. |
| csvHash | string | nullable | |
| xlsxHash | string | nullable | |
| pdfSignature | object | nullable | Carries status: signed | unsigned. |
| hasPdf | boolean | read-only | A PDF is stored for this run. |
| hasCsv | boolean | read-only | |
| hasXlsx | boolean | read-only | |
| sealed | boolean | read-only | The seal value itself is never returned. |
| sealMissing | boolean | read-only | A stored file that should carry a seal has none. |
| canonical | boolean | nullable | |
| redacted | object | nullable | |
| rowCounts | object | no | Empty object when the run stored no row counts. |
| retainUntil | string (date-time) | nullable | |
| legalHold | boolean | no | |
| verifyUrl | string | nullable, read-only | null when the run stored no file hash. |
| createdAt | string (date-time) | read-only |
SavedReportin a reply16 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | Up to 120 characters. |
| kind | string | no | compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2 |
| framework | string | nullable | |
| queries | string[] | no | Data set names. Empty array when none. |
| filters | object | no | Filter values keyed by filter name. Empty object when none. |
| window | object | nullable | { from, to } |
| sections | string[] | nullable | summary | controls | tables |
| columns | object | nullable | Query reports only. |
| groupBy | string | nullable | |
| variant | string | nullable | Budget reports only. |
| lifecycleType | string | nullable | leaver | joiner |
| format | string | no | table | pdf |
| createdBy | string | nullable, read-only | Email address. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
ReportShareLinkin a reply12 fields
One link to one sealed file. The create reply adds token (string), once; lists carry tokenHint only.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| runId | integer | read-only | |
| format | string | no | pdf | csv |
| expiresAt | string (date-time) | no | |
| revoked | boolean | no | |
| viewCount | integer | read-only | |
| lastViewedAt | string (date-time) | nullable, read-only | |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| tokenHint | string | nullable, read-only | The last few characters of the token. |
| recipientEmail | string | nullable | |
| recipientLabel | string | nullable | Up to 80 characters. |
AuditorSharein a reply10 fields
An auditor workspace link. The create reply adds token (string), once.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| framework | string | no | |
| label | string | nullable | Up to 80 characters. |
| expiresAt | string (date-time) | no | |
| revoked | boolean | no | |
| viewCount | integer | read-only | |
| lastViewedAt | string (date-time) | nullable, read-only | |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| tokenHint | string | nullable, read-only |
AuditorRequestin a reply10 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | 16 hex characters. |
| shareId | integer | read-only | The auditor link it was raised on. |
| framework | string | read-only | |
| controlId | string | read-only | |
| note | string | read-only | Up to 500 characters. |
| status | string | read-only | open | resolved |
| response | string | nullable | Up to 500 characters. |
| respondedBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| respondedAt | string (date-time) | nullable, read-only |
TenantExportJobin a reply16 fields
One company data export. Answered by the Company Data Export routes. The encryption parameters and the download link are never part of it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| status | string | read-only | queued | running | ready | failed | expired |
| progress | integer | read-only | 0 to 100. |
| stage | string | nullable, read-only | The current step, in words. |
| requestedBy | string | nullable, read-only | The email or username of the administrator who started it. |
| createdAt | string (date-time) | read-only | |
| startedAt | string (date-time) | nullable, read-only | |
| completedAt | string (date-time) | nullable, read-only | |
| expiresAt | string (date-time) | nullable, read-only | When the file is deleted: 24 hours after it became ready. |
| expiredAt | string (date-time) | nullable, read-only | When the file was deleted. |
| sizeBytes | integer | nullable, read-only | The size of the ZIP. |
| zipSha256 | string | nullable, read-only | The SHA-256 the downloaded ZIP must have. |
| summary | object | nullable, read-only | { totals, datasets: [{ name, rows, incomplete }], notes[] } once ready. |
| error | string | nullable, read-only | Why a failed job failed. |
| downloadCount | integer | read-only | |
| lastDownloadedAt | string (date-time) | nullable, read-only | When the current link was used. |
EnvironmentProfilein a reply4 fields
The systems the organisation has declared it uses. Answered by the environment profile routes.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| selected | string[] | no | Identifiers of the declared systems. Empty array when none. |
| custom | object[] | no | Each { id: string, name: string, category: string }. Empty array when none. |
| updatedAt | string (date-time) | nullable, read-only | |
| updatedBy | string | nullable, read-only | The email of whoever saved it last. |
TypeTwoReportin a reply12 fields
How each control of one framework held over a period. Answered by the type-two route.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| framework | string | no | |
| windowDays | integer | no | |
| from | string (date) | no | |
| to | string (date) | no | |
| snapshotCount | integer | no | Automatic snapshots found in the period. |
| firstAt | string (date-time) | nullable | The first snapshot in the period. |
| lastAt | string (date-time) | nullable | The last snapshot in the period. |
| heldThroughout | integer | no | Count of rows with that verdict. |
| hadGaps | integer | no | Count of rows with the verdict Gaps in period. |
| noData | integer | no | Count of rows with the verdict No period data. |
| incompleteCoverage | integer | no | Count of rows with that verdict. |
| rows | object[] | no | Each { id: string, title: string, clauseRef: string, observed: integer, passing: integer, gaps: integer, verdict: string }. verdict is Held throughout | Gaps in period | Incomplete coverage | No period data | Not applicable. |
ReportRunHoldrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| legalHold | boolean | optional | The state you want. Anything that is not true or false answers 400. Left out, the hold is flipped. |
ReportShareCreaterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| format | string: pdf | csv | optional | Default pdf. |
| confirmCsv | boolean | optional | Must be true to share the CSV; otherwise 400 with code CSV_CONFIRM_REQUIRED. |
| ttlHours | integer | optional | Default 168. 1 to 2160. |
| recipientEmail | string | optional | |
| recipientLabel | string | optional | Up to 80 characters. |
AuditorEvidenceRequestCreaterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| controlId | string | required | A control of the framework the link was made for. |
| note | string | required | Up to 500 characters. |
EvidenceItemCreaterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| controlId | string | required | |
| url | string | required | A link must be http or https. |
| kind | string | optional | file for a file already uploaded to this server. Anything else is a link. |
| label | string | optional | |
| framework | string | optional |
SecurityExceptionRequestrequest body2 fields
Asks to accept the risk of one finding until a date.
| Field | Type | Required | Note |
|---|---|---|---|
| justification | string | required | Empty, or only spaces, answers 400 with code JUSTIFICATION_REQUIRED. |
| acceptedUntil | string (date-time) | required | When the acceptance ends. Missing, not a date, or not in the future answers 400 with code EXPIRY_REQUIRED. |
SecurityExceptionRevokerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| note | string | optional |
FindingsArchiverequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| scope | string: all | remote | local | optional | Default all. Also read from ?scope when the body does not carry it. Any other value answers 400. |
AssetCriticalityUpdaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| criticality | string | required | low | medium | high | crit. critical is accepted and stored as crit. |
RemediationHandoffrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| handoffRef | string | optional | A reference to where the fix is tracked. Cut to 255 characters. |
TicketingUpdaterequest body2 fields
Both values are stored on every call: a key left out is stored as empty.
| Field | Type | Required | Note |
|---|---|---|---|
| providerId | string | optional | The connected ticketing system that receives the tickets. Empty switches automatic ticketing off. |
| projectKey | string | optional |
Compliance registers, backups, Security Lab
Policyin a reply27 fields
One entry of the policy register. No reply carries the policy text (body): the list, the create reply and the PUT reply all leave it out and send hasDocument instead. A PUT reply can add resetToDraft, changedFields or signingChanged when the edit returned the policy to draft or changed its signing rules. Entries are returned as stored: a reply can carry further fields, and an older entry can lack one listed here.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | 16 hex characters. |
| name | string | no | Up to 140 characters. |
| version | string | no | Up to 20 characters; '1.0' when not sent. |
| owner | string | no | Empty string when not set. |
| link | string | no | Empty string when not set. |
| description | string | no | |
| frameworks | string[] | no | Up to 8. |
| status | string | read-only | draft | approved |
| requireAck | boolean | no | |
| audience | object | nullable | Present only once it was set. { kind: 'everyone' } or { kind: 'rule', departments: string[], positions: string[], locations: string[] }. |
| ackEveryMonths | integer | no | Present only once it was set. 0 to 36. |
| ackDueDays | integer | no | Present only once it was set. 1 to 180. |
| signSections | boolean | no | Present only once it was set. |
| controlIds | string[] | no | Present only once it was set. |
| nextReviewDue | string (date) | nullable | |
| reviewedAt | string (date) | nullable, read-only | Set by approval, never by a write. |
| reviewedBy | string | nullable, read-only | |
| draftedBy | string | nullable, read-only | |
| draftedAt | string (date-time) | read-only | |
| templateId | string | nullable | |
| templateVersion | string | nullable | |
| hasDocument | boolean | read-only | Whether the policy has text. |
| mappedControls | string[] | read-only | The controls the policy counts towards. |
| ackSettings | object | read-only | { everyMonths: integer | null, dueDays: integer, signSections: boolean, audience: object | null }. |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
Riskin a reply14 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | 16 hex characters. |
| title | string | no | Up to 140 characters. |
| description | string | no | Up to 1,000 characters. |
| category | string | no | 'General' when not sent. |
| likelihood | integer | no | 1 to 5 |
| impact | integer | no | 1 to 5 |
| score | integer | read-only | likelihood x impact |
| status | string | no | open | treating | accepted | closed |
| treatment | string | no | |
| owner | string | no | |
| controlIds | string[] | no | At most 40. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| createdBy | string | nullable, read-only |
Vendorin a reply18 fields
One entry of the vendor register. Entries are returned as stored: a reply can carry further fields, and an older entry can lack one listed here.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | 16 hex characters. |
| name | string | no | Up to 140 characters. |
| category | string | no | 'SaaS' when not sent. |
| tier | string | no | low | medium | high | critical |
| owner | string | no | |
| notes | string | no | Up to 500 characters. |
| soc2OnFile | boolean | no | |
| dataShared | boolean | no | |
| reviewStatus | string | no | none | requested | received | reviewed |
| reviewedAt | string (date) | nullable | |
| reviewer | string | no | |
| riskDecision | string | no | approved | conditional | rejected, or an empty string before a decision. |
| nextReviewDue | string (date) | nullable | |
| documents | object | no | { soc2: boolean, iso: boolean, pentest: boolean, dpa: boolean, questionnaire: boolean } |
| reviews | VendorReview[] | read-only | At most 20, oldest first. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| createdBy | string | nullable, read-only |
VendorReviewin a reply11 fields
One entry of a vendor’s review history.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | 12 hex characters. |
| kind | string | read-only | requested | completed |
| at | string (date-time) | read-only | |
| by | string | nullable, read-only | |
| reviewer | string | read-only | |
| dueDate | string (date) | nullable, read-only | On a requested entry. |
| decision | string | read-only | On a completed entry: approved | conditional | rejected. |
| riskRating | string | read-only | On a completed entry: low | medium | high | critical. |
| summary | string | read-only | On a completed entry. Up to 1,000 characters. |
| documents | object | read-only | On a completed entry: the same five flags as Vendor.documents. |
| nextReviewDue | string (date) | nullable, read-only | On a completed entry. |
EvidenceItemin a reply8 fields
Evidence a person attached to a control.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string (uuid) | read-only | |
| controlId | string | read-only | |
| kind | string | read-only | file | link |
| url | string | read-only | |
| label | string | read-only | Empty string when none was given. |
| sha256 | string | nullable, read-only | For a file: computed by the server from the stored bytes. |
| addedBy | string | nullable, read-only | |
| addedAt | string (date-time) | read-only |
TrustCenterSettingsin a reply10 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| published | boolean | no | |
| headline | string | no | Up to 140 characters. |
| intro | string | no | Up to 1,000 characters. |
| frameworks | string[] | no | Up to 8 catalogue names. |
| showReadiness | boolean | no | |
| practices | object[] | no | Each { title: string, detail: string }. Up to 20. |
| autoPractices | boolean | no | |
| contactEmail | string | no | |
| updatedAt | string (date-time) | nullable, read-only | |
| updatedBy | string | nullable, read-only |
BackupPointin a reply16 fields
One row of GET /compliance/reports/backups.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| filename | string | read-only | |
| type | string | read-only | dump | base |
| restorable | boolean | read-only | |
| fetch | boolean | read-only | Present only on a backup with no local file: true when it is copied back from your storage before it is restored. |
| foundInStorage | boolean | read-only | Present (true) only on a backup found in your storage that this server holds no record of. |
| sizeBytes | integer | read-only | |
| modifiedAt | string (date-time) | nullable, read-only | |
| pgVersion | string | nullable, read-only | The PostgreSQL version the dump was taken from; null when it could not be read. |
| serverPgMajor | integer | nullable, read-only | |
| versionCompatible | boolean | read-only | true when either version is not known. |
| local | boolean | read-only | A copy is on this server. |
| offbox | boolean | read-only | A copy is in your storage. |
| offboxMissing | boolean | read-only | |
| previousDestination | boolean | read-only | Not sent on a foundInStorage row. |
| destinationKey | string | nullable, read-only | |
| offboxInfo | object | nullable, read-only | { provider: string, bucket: string | null, region: string | null, key: string | null } |
BackupAuditEntryin a reply6 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| action | string | read-only | |
| details | string | nullable, read-only | |
| userEmail | string | nullable, read-only | |
| level | string | nullable, read-only | |
| createdAt | string (date-time) | read-only |
WalArchiveSummaryin a reply7 fields
With ?list=1 the reply is { count: integer, segments: WalSegment[] } instead.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| count | integer | read-only | |
| totalBytes | integer | read-only | |
| newest | string (date-time) | nullable, read-only | |
| oldest | string (date-time) | nullable, read-only | |
| provider | string | read-only | |
| bucket | string | nullable, read-only | |
| region | string | nullable, read-only |
WalSegmentin a reply7 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| name | string | read-only | |
| sizeBytes | integer | read-only | |
| uploadedAt | string (date-time) | read-only | |
| key | string | nullable, read-only | |
| bucket | string | nullable, read-only | |
| region | string | nullable, read-only | |
| provider | string | read-only |
ScanSchedulein a reply12 fields
GET and POST /auth/security-lab/scan-schedule. Every field is optional in the POST body; send at least one.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| enabled | boolean | no | |
| intervalHours | integer | no | 1 to 168; 24 when never set. |
| lastRunAt | string (date-time) | nullable, read-only | |
| nextDueAt | string (date-time) | nullable, read-only | null when the schedule is off, has never run, or is already due. |
| reportDeliver | boolean | no | |
| reportRecipients | string | no | Empty string when none. |
| externalEnabled | boolean | no | |
| externalIntervalHours | integer | no | |
| externalLastRunAt | string (date-time) | nullable, read-only | |
| trivyImages | string | no | Empty string when none. |
| slaAlertEnabled | boolean | no | true when never set. |
| slaAlertPermission | string | no | The permission whose holders are emailed. |
SecurityIssuein a reply32 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string (uuid) | read-only | |
| findingId | string (uuid) | read-only | |
| fingerprint | string | read-only | |
| probe | string | read-only | |
| target | string | read-only | |
| title | string | nullable, read-only | |
| severity | string | read-only | Critical | High | Medium | Low | Inconclusive | Informational; treat any other value as the lowest rank. |
| cvssVector | string | nullable, read-only | |
| cvssScore | number | nullable, read-only | |
| cveId | string | nullable, read-only | |
| pkgName | string | nullable, read-only | |
| installedVersion | string | nullable, read-only | |
| fixedVersion | string | nullable, read-only | |
| location | string | nullable, read-only | |
| status | string | no | open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive | verified_fixed |
| statusNote | string | nullable | |
| owner | string | nullable | |
| ticketRef | string | nullable | |
| ticketProvider | string | nullable, read-only | |
| ticketStatus | string | nullable, read-only | |
| slaDueAt | string (date-time) | nullable, read-only | |
| slaBreachAlertedAt | string (date-time) | nullable, read-only | |
| kevListed | boolean | read-only | |
| epssScore | number | nullable, read-only | 0 to 1 |
| confirmedExposure | boolean | read-only | true only when the issue is KEV-listed and the installed version is known to be older than the fixed one. |
| regressed | boolean | read-only | |
| firstSeenAt | string (date-time) | read-only | |
| lastSeenAt | string (date-time) | read-only | |
| evidence | object | nullable, read-only | |
| archivedAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
SecurityRemediationin a reply17 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string (uuid) | read-only | |
| findingId | string (uuid) | nullable, read-only | |
| issueId | string (uuid) | nullable, read-only | |
| findingProbe | string | nullable, read-only | |
| findingTarget | string | nullable, read-only | |
| targetKind | string | read-only | |
| targetId | string | nullable, read-only | |
| targetLabel | string | nullable, read-only | |
| recommendedPayload | object | read-only | |
| status | string | read-only | Values the service writes include proposed, handed_off, resolved and failed. |
| handoffRef | string | nullable, read-only | |
| createdBy | string | nullable, read-only | |
| timeline | object[] | read-only | |
| evidence | object | nullable, read-only | |
| resolvedAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
HR: employee files, time off, goals, reviews, surveys, training, phishing simulation, and request bodies
EmployeeListItemin a reply18 fields
One row of GET /user-management/employees.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| firstName | string | nullable | |
| lastName | string | nullable | |
| string | no | ||
| name | string | no | First and last name joined; the email address when both are empty. |
| position | string | nullable | Job title. |
| department | string | nullable | |
| managerId | integer | nullable | |
| managerName | string | nullable | |
| status | string | nullable | The account status, for example active. |
| employeeId | string | nullable | Your own employee number, not the Zonitix id. |
| photoUrl | string | nullable | |
| terminationDate | string (date-time) | nullable | |
| hireDate | string (date) | nullable | |
| employmentType | string | nullable | |
| workLocation | string | nullable | |
| level | string | nullable | |
| reminders | EmployeeReminder[] | no | Empty for a person who has left. |
EmployeeReminderin a reply5 fields
An upcoming date on a file. Worked out on each read, not stored.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| kind | string: probation | contract | right_to_work | no | |
| label | string | no | "Probation ends", "Contract ends" or "Right to work expires". |
| date | string (date) | no | |
| days | integer | no | Days from today; negative once the date has passed. |
| overdue | boolean | no | true when days is below 0. |
EmployeeFilein a reply7 fields
GET /user-management/employees/:userId, and the reply of every write on a file.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| user | EmployeeFileUser | no | |
| profile | EmployeeProfile | no | |
| emergencyContacts | EmergencyContact[] | no | In the stored order. |
| history | EmploymentEvent[] | no | The newest 200 job changes, newest effective date first. |
| reminders | EmployeeReminder[] | no | |
| authority | HrAuthority | no | Which system owns each governed field. |
| people | object | no | { "<user id>": name } for the managers named in history. |
EmployeeFileUserin a reply18 fields
The identity half of an employee file.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| firstName | string | nullable | |
| lastName | string | nullable | |
| string | no | ||
| name | string | no | |
| position | string | nullable | |
| department | string | nullable | |
| managerId | integer | nullable | |
| managerName | string | nullable | |
| status | string | nullable | |
| employeeId | string | nullable | |
| phoneNumber | string | nullable | |
| personalEmail | string | nullable | |
| photoUrl | string | nullable | |
| terminationDate | string (date-time) | nullable | |
| personaId | integer | nullable | |
| source | string | nullable | Where the account came from, for example local. |
| createdAt | string (date-time) | no |
EmployeeProfilein a reply18 fields
The HR half of an employee file. Every field is null until it is set.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| preferredName | string | nullable | |
| legalName | string | nullable | |
| nationality | string | nullable | |
| hireDate | string (date) | nullable | |
| originalHireDate | string (date) | nullable | |
| employmentType | string | nullable | |
| workLocation | string | nullable | |
| level | string | nullable | |
| costCenter | string | nullable | |
| legalEntity | string | nullable | |
| probationEndDate | string (date) | nullable | |
| contractEndDate | string (date) | nullable | |
| noticePeriod | string | nullable | Free text, up to 255 characters. |
| rightToWorkStatus | string: verified | pending | expired | not_required | nullable | |
| rightToWorkVerifiedAt | string (date) | nullable | |
| rightToWorkExpiresAt | string (date) | nullable | |
| hasDateOfBirth | boolean | no | The date itself is never in a file; reveal it with POST /reveal. |
| hasHomeAddress | boolean | no | The address itself is never in a file; reveal it with POST /reveal. |
EmergencyContactin a reply4 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | nullable | null only when the stored value cannot be read. |
| relationship | string | nullable | |
| phone | string | nullable |
EmploymentEventin a reply13 fields
One job change in a file's history.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| userId | integer | no | |
| type | string | no | promotion | transfer | manager_change | title_change | other for a change a person recorded. |
| effectiveDate | string (date) | no | |
| status | string: applied | scheduled | cancelled | no | scheduled until its effective date arrives. |
| changes | object | no | { "<field>": { from, to } } for each field that changed. |
| reason | string | nullable | |
| source | string | no | manual for a change a person recorded. |
| sourceRef | string | nullable | |
| actorEmail | string | nullable | |
| appliedAt | string (date-time) | nullable | |
| createdAt | string (date-time) | no | |
| updatedAt | string (date-time) | no |
HrAuthorityin a reply7 fields
Which system owns people data. Also the base of GET /employees/hr-settings.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| mode | string: zonitix | external | no | |
| providerId | integer | nullable | The connected HR system, when mode is external. |
| providerName | string | nullable | |
| providerMissing | boolean | no | true when mode is external and that connection no longer exists. |
| fields | object | no | { "<field key>": "zonitix" | "external" }: the owner of each governed field. |
| overrides | object | no | The per-field choices as saved. |
| lastSync | object | nullable | What the last sync from the HR system recorded. |
CompensationRecordin a reply13 fields
The amount is never part of this object: reveal one with POST /compensation/:recordId/reveal.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| effectiveDate | string (date) | no | |
| currency | string | no | A 3-letter code. |
| payPeriod | string: annual | monthly | hourly | no | |
| bonusPct | number | nullable | |
| reason | string | nullable | |
| source | string | no | manual for a record a person typed in. |
| sourceOfferId | integer | nullable | The signed offer the record was carried over from. |
| actorEmail | string | nullable | |
| createdAt | string (date-time) | no | |
| voidedAt | string (date-time) | nullable | |
| voidedBy | string | nullable | |
| voidReason | string | nullable |
EmployeeDocumentin a reply9 fields
The entry for a stored file. The file itself is fetched from the download route.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | Up to 180 characters. |
| category | string: contract | offer | job_change | policy | certificate | review | letter | other | no | |
| visibility | string: hr | employee | manager | no | |
| mimeType | string | nullable | null until the file has been uploaded. |
| sizeBytes | integer | no | 0 until the file has been uploaded. |
| expiresAt | string (date) | nullable | |
| uploadedBy | string | nullable | The email address of the person who created the entry. |
| createdAt | string (date-time) | no |
DepartureRecordin a reply9 fields
The leaving record on a file.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| lastDay | string (date) | no | |
| departureType | string: voluntary | involuntary | end_of_contract | nullable | |
| reasonCode | string | nullable | One of the keys in the reply's reasons list. |
| rehireEligible | boolean | nullable | |
| checklist | object[] | no | Each { key:string, label:string, done:boolean, doneBy:string|null, doneAt:string (date-time)|null }. |
| source | string | no | |
| recordedBy | string | nullable | |
| createdAt | string (date-time) | no |
LeaveBalancein a reply16 fields
One per type of time off.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| typeId | integer | no | |
| name | string | no | |
| paid | boolean | no | |
| tracksBalance | boolean | no | |
| requiresApproval | boolean | no | |
| annualDays | number | no | |
| allowNegative | boolean | no | |
| isActive | boolean | no | |
| allowHours | boolean | no | |
| minPartMinutes | integer | no | The smallest booking by the hour. |
| maxPartMinutes | integer | nullable | The most per booking by the hour. |
| balance | number | nullable | null for a type that does not track a balance. |
| pending | number | no | Days in requests still waiting for a decision. |
| available | number | nullable | balance minus pending, to two places; null without a balance. |
| availableExact | number | nullable | The same figure before rounding. |
| taken | number | no | Approved days that start in the current calendar year. |
LeaveRequestin a reply17 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| userId | integer | no | |
| leaveTypeId | integer | no | |
| typeName | string | nullable | Not sent in the reply to a cancel. |
| startDate | string (date) | no | |
| endDate | string (date) | no | |
| startHalf | boolean | no | |
| endHalf | boolean | no | |
| startTime | string (HH:MM) | nullable | Set only for time off by the hour. |
| minutes | integer | nullable | Set only for time off by the hour. |
| days | number | no | Working days the request uses. |
| status | string: pending | approved | declined | cancelled | no | |
| note | string | nullable | Up to 500 characters. |
| decidedByEmail | string | nullable | |
| decidedAt | string (date-time) | nullable | |
| decisionNote | string | nullable | |
| createdAt | string (date-time) | no |
LeaveTypein a reply15 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | Up to 60 characters; unique without regard to case. |
| paid | boolean | no | |
| requiresApproval | boolean | no | |
| tracksBalance | boolean | no | |
| annualDays | number | no | 0 to 366. |
| accrual | string: upfront | monthly | no | |
| accrueFrom | string (date) | nullable | |
| carryOverMaxDays | number | no | 0 to 366. |
| allowNegative | boolean | no | |
| allowHours | boolean | no | |
| minPartMinutes | integer | no | |
| maxPartMinutes | integer | nullable | |
| isActive | boolean | no | false for a retired type. |
| sortOrder | integer | no |
WorkingCalendarin a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| location | string | nullable | |
| workDays | integer[] | no | The working days of the week. |
| hoursPerDay | number | no | |
| dayStart | string (HH:MM) | no | |
| isDefault | boolean | no | |
| holidays | object[] | no | Each { date:string (date), name:string }, earliest first. |
Goalin a reply13 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| userId | integer | no | |
| title | string | no | |
| description | string | nullable | |
| period | string | nullable | |
| dueDate | string (date) | nullable | |
| status | string: on_track | at_risk | done | dropped | no | |
| progress | integer | no | |
| weight | integer | nullable | |
| createdByEmail | string | nullable | |
| completedAt | string (date-time) | nullable | |
| createdAt | string (date-time) | no | |
| updatedAt | string (date-time) | no |
GoalTemplatein a reply5 fields
An example goal.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| title | string | no | |
| description | string | nullable | Up to 2,000 characters. |
| isActive | boolean | no | |
| sortOrder | integer | no |
ReviewCyclein a reply16 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| periodLabel | string | nullable | |
| selfDueDate | string (date) | nullable | |
| managerDueDate | string (date) | nullable | |
| questions | object[] | no | Each { key:string, label:string, help:string|null }; at most 12. |
| includeRating | boolean | no | |
| department | string | nullable | null when the cycle covers everyone. |
| peerFeedback | string | no | The colleague-feedback mode of the cycle. |
| calibration | boolean | no | |
| ratingsReleased | boolean | no | |
| ratingTargets | object | nullable | null when the cycle does not use calibration. |
| goalRatings | boolean | no | |
| status | string | no | draft, open or closed. |
| launchedAt | string (date-time) | nullable | |
| closedAt | string (date-time) | nullable |
Surveyin a reply10 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| title | string | no | |
| intro | string | nullable | |
| questions | object[] | no | Each { key:string, label:string, type: scale | enps | text }; at most 20. |
| department | string | nullable | null when everyone is asked. |
| status | string | no | draft, open or closed. |
| closesAt | string (date) | nullable | |
| openedAt | string (date-time) | nullable | |
| closedAt | string (date-time) | nullable | |
| expired | boolean | no | true once the closing date has passed. |
TrainingCoursein a reply21 fields
A training item. The correct answers of its knowledge check are never part of this object.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| title | string | no | |
| description | string | nullable | |
| url | string | nullable | |
| renewEveryMonths | integer | nullable | |
| isActive | boolean | no | false for a retired item. |
| content | string | nullable | The text to read; up to 20,000 characters. |
| autoAssign | boolean | no | |
| dueDays | integer | no | |
| proof | string | no | What finishing takes; none when nothing is asked. |
| securityAwareness | boolean | no | |
| securityAwarenessSince | string (date) | nullable | |
| topic | string | nullable | The compliance topic the item counts under. |
| topicLabel | string | nullable | |
| videoUrl | string | nullable | |
| videoRequired | boolean | no | |
| hasVideoFile | boolean | no | |
| scorm | object | nullable | { title:string|null, launch:string, files:integer, bytes:integer, importedAt:string (date-time)|null }. |
| audience | object | nullable | { kind: "everyone" } or { kind: "rule", departments:string[], positions:string[], locations:string[] }; null when people are assigned by hand. |
| audienceText | string | no | The same audience in words. |
| quiz | TrainingQuiz | nullable |
TrainingQuizin a reply6 fields
A knowledge check as a person sees it: questions and options only.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| passMark | integer | no | |
| maxAttempts | integer | nullable | Tries per day; null when unlimited. |
| perTry | integer | nullable | How many questions one try draws from the pool. |
| draw | string | optional | Present on one person's try: identifies the questions drawn. Send it back with the answers. |
| pool | integer | optional | Present with draw. |
| questions | object[] | no | Each { id, text:string, options:string[], example? }. |
TrainingAssignmentin a reply15 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| courseId | integer | no | |
| userId | integer | no | |
| dueDate | string (date) | nullable | |
| status | string | no | assigned or completed. |
| completedAt | string (date-time) | nullable | |
| lastCompletedAt | string (date-time) | nullable | |
| expiresAt | string (date) | nullable | When a completion that has to be renewed runs out. |
| overdue | boolean | no | Worked out on each read: assigned, past its due date, and the item is not retired. |
| acknowledgedAt | string (date-time) | nullable | |
| hasCertificate | boolean | no | |
| quizTriesLeft | integer | nullable | null when tries are not limited or the item was not loaded. |
| quiz | TrainingQuiz | nullable | This try's questions, only while the item is still to be done. |
| course | TrainingCourse | nullable | |
| exemptUntil | string (date) | optional | Present only on an open item the person is excused from; overdue is then false. |
LearningPathin a reply10 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| title | string | no | |
| description | string | nullable | |
| isActive | boolean | no | false for a paused path. |
| autoAssign | boolean | no | |
| autoAssignFrom | string (date) | nullable | |
| steps | object[] | no | Each { courseId:integer, position:integer, title:string, isActive:boolean, dueDays:integer }, in order. |
| people | integer | optional | In the path list only: active members. |
| finished | integer | optional | In the path list only. |
| percent | integer | optional | In the path list only: finished as a whole percentage of people. |
PhishingCampaignin a reply20 fields
A practice phishing simulation with its totals.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| title | string | no | |
| templateKey | string | no | |
| difficulty | string | nullable | |
| subject | string | no | |
| status | string: draft | pending_approval | approved | sending | sent | cancelled | no | |
| createdBy | string | no | |
| createdById | integer | nullable | |
| approvedBy | string | nullable | |
| approvedAt | string (date-time) | nullable | |
| sendAt | string (date-time) | nullable | |
| sentAt | string (date-time) | nullable | |
| courseId | integer | nullable | The training item a person who clicks is pointed to. |
| resultsPrunedAt | string (date-time) | nullable | |
| recipients | integer | no | |
| sent | integer | no | |
| clicked | integer | no | |
| reported | integer | no | |
| clickRate | integer | nullable | A whole percentage of emails sent; null when none was sent. |
| reportRate | integer | nullable | A whole percentage of emails sent; null when none was sent. |
EmployeeProfileUpdaterequest body15 fields
Only the keys you send are changed. Job title, department, manager, level, employment type and work location are not read here: they change through a job change.
| Field | Type | Required | Note |
|---|---|---|---|
| preferredName | string | optional, null clears it | |
| legalName | string | optional, null clears it | |
| nationality | string | optional, null clears it | |
| costCenter | string | optional, null clears it | |
| legalEntity | string | optional, null clears it | |
| noticePeriod | string | optional, null clears it | |
| rightToWorkStatus | string: verified | pending | expired | not_required | optional, null clears it | Any other value answers 400. |
| hireDate | string (date) | optional, null clears it | A real calendar date, or the answer is 400. The same for every date below. |
| originalHireDate | string (date) | optional, null clears it | |
| probationEndDate | string (date) | optional, null clears it | |
| contractEndDate | string (date) | optional, null clears it | |
| rightToWorkVerifiedAt | string (date) | optional, null clears it | |
| rightToWorkExpiresAt | string (date) | optional, null clears it | |
| dateOfBirth | string (date) | optional, null clears it | Stored encrypted. |
| homeAddress | string | optional, null clears it | At most 1000 characters. Stored encrypted. |
EmergencyContactInputrequest body3 fields
One entry of { contacts: [...] }. A call replaces the whole list: at most 5, kept in the order sent.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| relationship | string | optional | |
| phone | string | optional | At most 40 characters. |
JobChangeCreaterequest body4 fields
| Field | Type | Required | Note |
|---|---|---|---|
| type | string: promotion | transfer | manager_change | title_change | other | required | |
| effectiveDate | string (date) | optional | Default today. A later date stores the change as scheduled; it is applied on that day. |
| changes | object | required | At least one of position, department, level, employmentType, workLocation (strings; null clears) and managerId (a user id; null removes the manager). |
| reason | string | optional | At most 1000 characters. |
MyDetailsUpdaterequest body4 fields
What a person may change on their own file. At least one key is needed, or the answer is 400.
| Field | Type | Required | Note |
|---|---|---|---|
| phoneNumber | string | optional, null clears it | At most 40 characters. |
| personalEmail | string (email) | optional, null clears it | Must be an email address and must differ from the work email. |
| preferredName | string | optional, null clears it | |
| homeAddress | string | optional, null clears it | At most 1000 characters. Stored encrypted. |
CompensationCreaterequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| amount | number | required | Greater than 0 and at most 10000000000. |
| currency | string | required | Three letters; upper-cased. |
| effectiveDate | string (date) | optional | Default today. |
| payPeriod | string: annual | monthly | hourly | optional | Default annual. |
| bonusPct | number | optional | 0 to 999. |
| reason | string | optional |
EmployeeDocumentWriterequest body4 fields
The entry a file is then uploaded to. An update (PUT on the document) takes the same keys and changes only those sent.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required on create | Characters that cannot be in a file name become spaces; cut to 180 characters. |
| category | string: contract | offer | job_change | policy | certificate | review | letter | other | optional | Default other. |
| visibility | string: hr | employee | manager | optional | Default hr. employee means HR and the person; manager means HR, the person and their manager. |
| expiresAt | string (date) | optional, null clears it |
LeaveRequestCreaterequest body10 fields
Whole or half days over a range, or hours on one day when minutes is sent.
| Field | Type | Required | Note |
|---|---|---|---|
| leaveTypeId | integer | required | A type that is in use. |
| startDate | string (date) | required | |
| endDate | string (date) | optional | Default startDate. Not before startDate and at most 366 days after it. With minutes it must be the same day. |
| startHalf | boolean | optional | The first day is a half day. |
| endHalf | boolean | optional | The last day is a half day. |
| minutes | integer | optional | Time off by the hour: at least 15, and it must end on the day it starts. |
| startTime | string | required with minutes | HH:MM. |
| note | string | optional | At most 500 characters. |
| repeatWeeks | integer | optional | 2 to 12: the same one-day request on that weekday for this many weeks. Weeks that hold no working day are reported in skipped. |
| replaceIds | integer[] | optional | At most 20 of your own bookings by the hour that this whole-day request replaces. |
LeaveDecisionrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| decision | string: approve | decline | required | |
| note | string | required for decline | At most 500 characters. Shown to the person. |
LeaveTypeWriterequest body14 fields
Create and update take the same keys, and an update sets every one of them: a key left out takes its default.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | At most 60 characters. |
| tracksBalance | boolean | optional | Default true. When false, annualDays and carryOverMaxDays are stored as 0. |
| annualDays | number | optional | 0 to 366. Default 0. |
| accrual | string: upfront | monthly | optional | Default upfront; any other value is stored as upfront. |
| carryOverMaxDays | number | optional | 0 to 366. Default 0. |
| paid | boolean | optional | Default true. |
| requiresApproval | boolean | optional | Default true. |
| allowHours | boolean | optional | Default true. |
| allowNegative | boolean | optional | Default false. |
| minPartMinutes | integer | optional, null clears it | The smallest amount by the hour: 15 to 1440. |
| maxPartMinutes | integer | optional, null clears it | The most by the hour in one request: 15 to 1440, not less than minPartMinutes. |
| isActive | boolean | optional | Default true. |
| sortOrder | integer | optional | Default 0. |
| accrueFrom | string (date) | optional | Create only: the day balances start to build from. Kept between 1 January of this year and today; default 1 January. |
HolidayCalendarWriterequest body7 fields
Create and update take the same keys. The holidays sent replace the stored list.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| location | string | optional, null clears it | The work location this calendar applies to. |
| workDays | integer[] | optional | Days of the week as 0 (Sunday) to 6 (Saturday). Default 1 to 5. |
| holidays | object[] | optional | Each { date:string (YYYY-MM-DD), name:string }, both required. At most 400, and no date twice. |
| hoursPerDay | number | optional | 1 to 24. |
| dayStart | string | optional | HH:MM. |
| isDefault | boolean | optional | true makes this the default calendar. The only calendar is always the default. |
LeaveBalanceAdjustrequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| leaveTypeId | integer | required | A type that tracks a balance. |
| days | number | required | Not 0, between -366 and 366; a negative number removes days. |
| note | string | required | Why the balance is changed. |
GoalWriterequest body8 fields
Create and update take the same keys; an update changes only the keys you send. A person can have at most 50 goals.
| Field | Type | Required | Note |
|---|---|---|---|
| title | string | required on create | |
| userId | integer | optional | Create only: whose goal it is. Default yourself. |
| description | string | optional, null clears it | At most 2000 characters. |
| period | string | optional, null clears it | At most 40 characters. |
| dueDate | string (date) | optional, null clears it | |
| status | string: on_track | at_risk | done | dropped | optional | done sets progress to 100. |
| progress | integer | optional | 0 to 100. 100 with no status sets status to done. |
| weight | integer | optional, null clears it | 0 to 100. |
ReviewCycleWriterequest body10 fields
Create and update take the same keys. The questions, department, rating and feedback options can be changed only while the cycle is a draft; a closed cycle cannot be changed.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| periodLabel | string | optional, null clears it | At most 60 characters. |
| selfDueDate | string (date) | optional, null clears it | |
| managerDueDate | string (date) | optional, null clears it | Not before selfDueDate. |
| department | string | optional, null clears it | Limits the cycle to one department. |
| questions | object[] | optional | 1 to 12, each { label:string (required), key?:string, help?:string (at most 300 characters) }. The default questions when left out. |
| includeRating | boolean | optional | Default true. |
| calibration | boolean | optional | Default false. Takes effect only with includeRating. |
| goalRatings | boolean | optional | Default false. |
| peerFeedback | string: off | reviewer | person | optional | Default off. Any other value answers 400. |
SurveyWriterequest body5 fields
Create and update take the same keys. The questions and the department can be changed only while the survey is a draft; a closed survey cannot be changed.
| Field | Type | Required | Note |
|---|---|---|---|
| title | string | required | |
| intro | string | optional, null clears it | At most 2000 characters. |
| department | string | optional, null clears it | Limits the survey to one department. |
| closesAt | string (date) | optional, null clears it | Not in the past. |
| questions | object[] | optional | 1 to 20, each { label:string (required, at most 300 characters), type: scale | enps | text (required), key?:string }. The default questions when left out. |
TrainingCourseWriterequest body14 fields
Create and update take the same keys. A course with a topic must be redone at least every 12 months and cannot use proof none.
| Field | Type | Required | Note |
|---|---|---|---|
| title | string | required | |
| description | string | optional, null clears it | At most 2000 characters. |
| url | string | optional, null clears it | An http or https address, at most 1000 characters. |
| content | string | optional | The text of the course, at most 20000 characters. |
| proof | string: none | acknowledge | certificate | quiz | optional | How a person marks it done. Default none. |
| quiz | object | optional | The knowledge check. With proof quiz it needs at least one question. |
| renewEveryMonths | integer | optional, null clears it | 1 to 120. |
| dueDays | integer | optional | Days to finish, 1 to 365. Default 30. |
| topic | string | optional, null clears it | One of the training topics the instance lists. |
| autoAssign | boolean | optional | Default false. |
| isActive | boolean | optional | Default true. false retires the course. |
| videoUrl | string | optional | |
| videoRequired | boolean | optional | Takes effect only when the course has a video. |
| audience | object | optional | Who autoAssign applies to. |
LearningPathWriterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| title | string | required | |
| courseIds | integer[] | required | 2 to 20 courses in order, each once, none retired. |
| description | string | optional, null clears it | At most 2000 characters. |
| isActive | boolean | optional | Default true. |
| autoAssign | boolean | optional | Default false. |
PhishingCampaignWriterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| templateKey | string | required | One of the built-in templates. |
| title | string | optional | Cut to 160 characters. Defaults to the name of the template. |
| userIds | integer[] | required | 1 to 2000 active people. |
| courseId | integer | optional, null clears it | A training course in use, linked to the simulation. |
| sendAt | string (date-time) | optional, null clears it | When to send. |
SensitiveFieldRevealrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| field | string: dateOfBirth | homeAddress | required | Any other value is refused with 400. |
HrAuthorityWriterequest body3 fields
Which system holds the people data: this instance, or a connected HR system.
| Field | Type | Required | Note |
|---|---|---|---|
| mode | string: zonitix | external | required | Any other value is refused with 400. |
| providerId | integer | required for external | A connected HR system; any other id is refused with 400. Not read for zonitix. |
| fields | object | optional | Per field: the field key, and zonitix or external as its value. |
HrRetentionWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| months | integer | required | 0 switches retention off; otherwise a whole number from 6 to 240. Anything else is refused with 400. |
CompensationVoidrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Cut to 255 characters; blank is refused with 400. |
LeaveApproveManyrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 50 request ids. An id sent twice is handled once. |
LeaveDeclineItemrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| id | integer | required | A request id. |
| note | string | required | The reason the person reads; cut to 500 characters. |
LeaveDeclineManyrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| items | LeaveDeclineItem[] | required | 1 to 50 items. When any item has no note, the whole call is refused with 400 and nothing is declined. |
FeedbackRequestrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| userIds | integer[] | required | Active colleagues; not the person reviewed and not the reviewer. |
ReviewerReassignrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reviewerId | integer | required | An active person other than the person reviewed. |
TrainingCompleterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| acknowledged | boolean | optional | Must be true when you finish your own item that asks you to confirm you have read it. |
| reason | string | optional | Read when HR marks an item done for someone else; cut to 500 characters. |
LibraryAdoptrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| replace | boolean | optional | true gives a training item you already have under the same title the library text and knowledge check. Only the value true counts. |
TrainingAssignrequest body4 fields
| Field | Type | Required | Note |
|---|---|---|---|
| audience | string: everyone | department | people | required | Any other value is refused with 400. |
| department | string | required for department | |
| userIds | integer[] | required for people | At most 2,000 ids. |
| dueDate | string (date) | optional | Default: the item's days to finish, counted from today. |
TrainingBatchFilterrequest body3 fields
Selects every assignment on the roster that matches, in place of a list of ids.
| Field | Type | Required | Note |
|---|---|---|---|
| courseId | integer | optional | |
| department | string | optional | |
| status | string | optional |
TrainingBatchActionrequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| action | string: remove | due | remind | complete | required | Any other value is refused with 400. |
| ids | integer[] | optional | At most 2,000 assignment ids. When empty or left out, filter is read. |
| filter | TrainingBatchFilter | optional | Read only when ids is empty or left out. |
| dueDate | string (date) | required for due | |
| reason | string | optional | Cut to 500 characters. Needed to remove open security-awareness training from someone, or to move its deadline later; without it that assignment is listed in skipped. |
CompletionRowrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| string | required | Matched to an active person without regard to letter case. An address listed twice is used once. | |
| completedAt | string (date) | required | YYYY-MM-DD; not in the future and not more than 5 years ago. |
CompletionsImportrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| source | string | required | Where the completions come from; at least 3 characters, cut to 200. |
| rows | CompletionRow[] | required | 1 to 5,000 rows. A row that cannot be used is listed in skipped with its reason. |
PathMembersAddrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| userIds | integer[] | optional | At most 500 people. Send this or userId. |
| userId | integer | optional | One person; read only when userIds is not a list. |
PhishingReportrequest body2 fields
Says which practice email you are reporting. Only your own practice email can match; nothing you send is stored.
| Field | Type | Required | Note |
|---|---|---|---|
| token | string | optional | The link in the email, or the 64-character code at the end of it. |
| campaign | integer | optional | A simulation id; read when token is absent or not a valid code. |
Hiring: job postings, candidates, offers, interviews, referrals, and request bodies
JobPostingin a reply36 fields
A requisition.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| title | string | no | |
| description | string | nullable | |
| department | string | nullable | |
| status | string: Draft | Open | Published | Closed | Filled | On Hold | no | Only Published roles are on the careers site. |
| financeApprovalStatus | string: Pending | Approved | Rejected | read-only | The headcount budget gate. |
| location | string | nullable | |
| employmentType | string | nullable | |
| seniority | string | nullable | |
| workMode | string | nullable | |
| minExperienceMonths | integer | nullable | |
| currency | string | nullable | |
| salaryRange | string | nullable | |
| salaryMin | integer | nullable | |
| salaryMax | integer | nullable | |
| payPeriod | string | nullable | |
| competencies | array | no | |
| requirements | array | no | |
| applicationQuestions | object[] | no | Each has id, label, type, required and options on the careers site. |
| hiringManagerId | integer | nullable | |
| ownerId | integer | nullable | |
| personaId | integer | nullable | |
| createdById | integer | nullable, read-only | |
| financeApprovedById | integer | nullable, read-only | |
| financeApprovedAt | string (date-time) | nullable, read-only | |
| financeRejectionReason | string | nullable, read-only | |
| financeRequestedAt | string (date-time) | nullable, read-only | |
| isTalentPool | boolean | read-only | true only for the talent-pool requisition, which the list leaves out. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| candidateCount | integer | read-only | In the list only: every applicant, not the preview. |
| stageCounts | object | nullable, read-only | In the list only: { "<STAGE IN CAPITALS>": integer }; null when the role has no candidates. |
| Candidates | object[] | read-only | In the list only: the newest applicants in your own candidate scope, each { id, firstName, lastName, email, stage, matchScore, jobPostingId }. |
| HiringManager | { id:integer, firstName:string|null, lastName:string|null, email:string } | nullable, read-only | In the list only. Owner, Creator and FinanceApprover have the same shape. |
| OfferTemplates | object[] | read-only | In the list only: each { id:integer, name:string, createdAt }. |
| InterviewTemplates | object[] | read-only | In the list only: each { id:integer, name:string, roundName:string|null, createdAt }. |
Candidatein a reply32 fields
One application to one requisition.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| firstName | string | no | |
| lastName | string | no | |
| string | no | ||
| phoneNumber | string | nullable | |
| resumeUrl | string | nullable | The stored file name; fetch the file with GET /recruitment/candidates/:id/resume. |
| source | string | nullable | |
| stage | string: Sourced | Screen | Interview | Offer | Hired | Rejected | no | |
| matchScore | integer | nullable | |
| aiSummary | string | nullable | |
| scoreSource | string | nullable | processing until the first screen has finished. |
| calibrationResult | object | nullable | null for an interviewer who has not yet submitted their own scorecard. |
| parsedProfile | object | nullable | |
| rejectionEmailSent | boolean | no | |
| onboardingData | object | no | |
| applicationAnswers | array | no | |
| knockoutFlag | boolean | no | |
| knockoutReasons | array | no | |
| jobPostingId | integer | no | |
| userId | integer | nullable | The linked user account, when there is one. |
| ownerId | integer | nullable | |
| consentAt | string (date-time) | nullable | |
| humanReviewRequested | boolean | no | |
| aiNoticeAckAt | string (date-time) | nullable | |
| tags | string[] | no | Each up to 40 characters. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| technicalFitScore | integer | nullable, read-only | In the list only: 0 to 100 from the completed scorecards; null without one. |
| Job | JobPosting | read-only | In the list only, with HiringManager. |
| Owner | { id:integer, firstName:string|null, lastName:string|null, email:string } | nullable, read-only | In the list only. |
| Offer | Offer | nullable, read-only | In the list only. |
| Interviews | Interview[] | read-only | In the list only. |
Offerin a reply29 fields
A candidate has at most one. The signing link and the candidate-portal link are never part of a reply.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| candidateId | integer | no | |
| salary | number | nullable | A number in the candidate list, for a caller who may see offer pay; null for anyone else. null in the reply to a write on the offer itself. |
| signOnBonus | string | nullable | In the candidate list only, under the same rule as salary. |
| currency | string | nullable | |
| payPeriod | string | nullable | |
| level | string | nullable | |
| targetBonusPct | integer | nullable | |
| startDate | string (date) | nullable | |
| status | string: Draft | Sent | Accepted | Declined | no | |
| declineReason | string | nullable | |
| signedAt | string (date-time) | nullable | |
| signedManuallyBy | integer | nullable | |
| approvalStatus | string: Pending | Approved | Rejected | no | |
| approverId | integer | nullable | |
| termsSetBy | integer | nullable | The person an approval is refused from. |
| offerTemplateId | integer | nullable | |
| supportingDocs | object[] | no | |
| documentUrl | string | nullable | |
| docusealSubmissionId | string | nullable | The signing submission, once the offer has been sent. |
| rescindStatus | string | nullable | |
| rescindRequestedBy | integer | nullable | |
| rescindReason | string | nullable | |
| rescindApprovedBy | integer | nullable | |
| tokenExpiresAt | string (date-time) | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| Template | OfferTemplate | nullable, read-only | In the candidate list only. |
| Approver | { id:integer, firstName:string|null, lastName:string|null, email:string } | nullable, read-only | In the candidate list only. |
Interviewin a reply21 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| candidateId | integer | no | |
| interviewerId | integer | no | |
| scheduledAt | string (date-time) | nullable | null while the candidate has not yet picked one of the proposed times. |
| feedback | string | nullable | "Hidden from your view" on another interviewer's row until you have submitted your own scorecard. |
| structuredFeedback | object | nullable | null on a masked row. |
| score | integer | nullable | 1 to 5; null on a masked row. |
| status | string: Scheduled | Completed | Cancelled | no | |
| externalEventId | string | nullable | |
| icsSequence | integer | no | |
| cancelledAt | string (date-time) | nullable | |
| cancelReason | string | nullable | |
| sequence | integer | nullable | |
| isShadow | boolean | nullable | A shadow interviewer's score does not count towards technicalFitScore. |
| interviewTemplateId | integer | nullable | |
| proposedSlots | array | no | The times offered for self-scheduling. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| masked | boolean | optional | true on a row whose score and feedback were hidden from you. |
| Interviewer | { id:integer, firstName:string|null, lastName:string|null, email:string } | nullable, read-only | In the candidate list only. |
| Template | InterviewTemplate | nullable, read-only | In the candidate list only, with its Questions. |
InterviewTemplatein a reply11 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| roundName | string | nullable | |
| jobPostingId | integer | nullable | null for a template that is not tied to one requisition. |
| sourceJobId | integer | nullable | |
| sourceJobTitle | string | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| Questions | InterviewQuestion[] | no | In order. |
| usageCount | integer | read-only | In the list only. |
InterviewQuestionin a reply12 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| interviewTemplateId | integer | no | |
| questionText | string | no | |
| category | string | nullable | |
| context | string | nullable | |
| idealAnswer | string | nullable | |
| weight | integer | nullable | Default 1. |
| mustHave | boolean | nullable | |
| anchors | object | nullable | |
| order | integer | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
OfferTemplatein a reply11 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| body | string | no | |
| docusealTemplateId | integer | nullable | |
| jobPostingId | integer | nullable | |
| sourceJobId | integer | nullable | |
| sourceJobTitle | string | nullable | |
| supportingDocs | object[] | no | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| usageCount | integer | read-only | In the list only. |
CandidateNotein a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| candidateId | integer | no | |
| authorId | integer | nullable | |
| body | string | no | |
| mentions | array | no | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| Author | { id:integer, firstName:string|null, lastName:string|null, email:string } | nullable, read-only | In the list only. |
CandidateMessagein a reply8 fields
One email to or from a candidate, as the message list returns it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| direction | string: outbound | inbound | no | |
| fromAddr | string | nullable | |
| toAddr | string | nullable | |
| subject | string | nullable | |
| body | string | nullable | |
| createdAt | string (date-time) | read-only | |
| sentById | integer | nullable | The user who sent an outbound message. |
Referralin a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| userId | integer | no | The employee who made the referral. |
| candidateId | integer | no | |
| notes | string | nullable | |
| status | string: Pending | Reviewed | Hired | Rejected | no | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| Candidate | object | read-only | In the list only: { id, firstName, lastName, stage, source, createdAt, Job:{ id, title, department } }. |
JobPostingWriterequest body20 fields
Create and update take the same keys; an update changes only the keys you send. The budget-approval fields and the creator are set by the server and are not read from the body.
| Field | Type | Required | Note |
|---|---|---|---|
| title | string | required on create | |
| description | string | optional | |
| department | string | optional | |
| status | string: Draft | Open | Published | Closed | Filled | On Hold | optional | Open and Published are refused with 409 until the headcount budget is approved. |
| location | string | optional | |
| employmentType | string | optional | |
| seniority | string | optional | |
| workMode | string | optional | |
| minExperienceMonths | integer | optional, null clears it | A number, or text that is a number: rounded, 0 to 2147483647. Blank is stored as empty; anything else answers 400 INVALID_NUMBER. |
| currency | string | optional | |
| salaryRange | string | optional | |
| salaryMin | integer | optional, null clears it | Read the same way as minExperienceMonths. |
| salaryMax | integer | optional, null clears it | Read the same way as minExperienceMonths. |
| payPeriod | string | optional | |
| competencies | array | optional | |
| requirements | array | optional | |
| applicationQuestions | object[] | optional | The extra questions on the public application form. |
| hiringManagerId | integer | optional, null clears it | |
| ownerId | integer | optional, null clears it | |
| personaId | integer | optional, null clears it | Assigning a persona is checked against what you are allowed to grant. |
CandidateCreaterequest body7 fields
A candidate added by a signed-in recruiter. The stage, the scores and the consent fields are set by the server.
| Field | Type | Required | Note |
|---|---|---|---|
| jobPostingId | integer | required | |
| string (email) | required | Stored in one normalised spelling. The same email on the same requisition answers 409. | |
| firstName | string | required | |
| lastName | string | required | |
| phoneNumber | string | optional | Stored encrypted. |
| source | string | optional | |
| ownerId | integer | optional | Read only from a caller who holds manage_recruitment. Otherwise the candidate takes the owner of the requisition, or you when it has none. |
CandidateBulkActionrequest body8 fields
One action for many candidates. Each candidate is handled on its own and lands in succeeded, skipped or failed.
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 200 candidate ids. |
| action | string: stage | reject | reassign | tag | required | |
| stage | string: Sourced | Screen | Interview | Offer | Hired | Rejected | required for stage | Any capitalisation. |
| sendEmail | boolean | optional | reject only: the rejection email is sent unless this is false. |
| reason | string | optional | reject only. |
| ownerId | integer | optional, null clears it | reassign only. |
| add | string[] | optional | tag only: labels to add. |
| remove | string[] | optional | tag only: labels to remove. |
CandidateTagsUpdaterequest body2 fields
A label is trimmed and cut to 40 characters; labels that differ only in capitalisation count as one; a candidate keeps at most 30.
| Field | Type | Required | Note |
|---|---|---|---|
| add | string[] | optional | |
| remove | string[] | optional | Matched without regard to capitalisation. |
CandidateNoteCreaterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| body | string | required | Trimmed and cut to 5000 characters; empty answers 400. |
| mentions | integer[] | optional | User ids to notify. Values that are not positive whole numbers, and repeats, are dropped. |
CandidateMessageSendrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| subject | string | required | Trimmed and cut to 500 characters. |
| body | string | required | Trimmed. A blank line starts a new paragraph in the email. |
InterviewCreaterequest body7 fields
| Field | Type | Required | Note |
|---|---|---|---|
| interviewerId | integer | required | A person who is allowed to conduct interviews. |
| scheduledAt | string (date-time) | optional, null clears it | Text that is not a date answers 400 INVALID_TIME. Without it the interview is created with no time. |
| sequence | integer | optional | The round number. Default 1. |
| interviewTemplateId | integer | optional | |
| isShadow | boolean | optional | Default false. |
| durationMins | integer | optional | Used when checking the interviewer's other bookings. |
| allowOverlap | boolean | optional | true books the time although the interviewer already has an interview then. |
InterviewSlotsProposerequest body6 fields
Offers the candidate a choice of times. Times in the past, times that are not dates and repeats are dropped, and the first 10 are kept.
| Field | Type | Required | Note |
|---|---|---|---|
| interviewerId | integer | required | 400 INTERVIEWER_REQUIRED without it. |
| slots | string (date-time)[] | required | At least two usable times, or the answer is 400 NOT_ENOUGH_SLOTS. |
| interviewTemplateId | integer | optional | |
| durationMins | integer | optional | |
| allowOverlap | boolean | optional | Without it, times that clash with the interviewer's bookings are dropped, and fewer than two left answers 409 DOUBLE_BOOKED. |
| isShadow | boolean | optional | Default false. |
ScorecardSubmitrequest body3 fields
Marks the interview Completed.
| Field | Type | Required | Note |
|---|---|---|---|
| score | integer | optional | 1 to 5; anything else answers 400 INVALID_SCORE. |
| feedback | string | optional | |
| structuredFeedback | object | optional | Your marks and notes per competency. The recommendation is carried inside it. |
InterviewQuestionInputrequest body8 fields
| Field | Type | Required | Note |
|---|---|---|---|
| questionText | string | required | |
| category | string | optional | |
| context | string | optional | |
| idealAnswer | string | optional | |
| weight | integer | optional | Default 1. |
| mustHave | boolean | optional | Default false. |
| anchors | object | optional | |
| order | integer | optional | Defaults to the position in the list. |
InterviewTemplateWriterequest body5 fields
Create and update take the same keys. On an update, sending questions replaces the question list; leaving it out keeps the questions as they are.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required on create | |
| description | string | optional | |
| roundName | string | optional | |
| jobPostingId | integer | optional, null clears it | null makes the template available to every requisition. |
| questions | InterviewQuestionInput[] | optional |
OfferTemplateWriterequest body4 fields
Create and update take the same keys; an update changes only the keys you send.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required on create | |
| body | string | required on create | The text of the offer letter. |
| jobPostingId | integer | optional, null clears it | null makes the template available to every requisition. |
| supportingDocs | object[] | optional | At most 20. A link is { type:"link", label, url } with an http or https address; an uploaded file is { type:"file", label, storedName, filename?, size? } as POST /recruitment/offer-documents returns it. An entry without a label, or with an address or file name that does not pass the check, is dropped. |
OfferTermsWriterequest body8 fields
The terms of a candidate's offer. Only the keys you send are changed; an empty text or null clears a value. Changing the terms of an approved offer sends it back for approval.
| Field | Type | Required | Note |
|---|---|---|---|
| salary | number | optional, null clears it | Not negative, or the answer is 400. |
| currency | string | optional, null clears it | |
| payPeriod | string | optional, null clears it | |
| startDate | string (date) | optional | YYYY-MM-DD, or the answer is 400. |
| level | string | optional, null clears it | |
| targetBonusPct | integer | optional, null clears it | 0 to 100; rounded to a whole number. |
| signOnBonus | number | optional, null clears it | Not negative. |
| hiringManagerId | integer | optional, null clears it | Written to the requisition, not the offer. |
OfferBulkDecisionrequest body4 fields
Approve or refuse many offers. Each one goes through the same checks as the single routes and lands in succeeded or failed.
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 200 candidate ids. |
| approve | boolean | required | Only true approves; any other value refuses. |
| reason | string | optional | Stored with a refusal. |
| termsVersions | object | optional | { "<candidate id>": string }: the version of the terms you saw for each offer, cut to 64 characters. An offer whose terms changed since lands in failed. |
RequisitionOwnerWriterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ownerId | integer | optional, null clears it | A user id. null, 0 or a missing key leaves the requisition without an owner. |
| cascade | boolean | optional | true also moves the requisition's candidates that belonged to the previous owner or had none. A candidate given to someone else by hand keeps that owner. |
CandidateOwnerWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| ownerId | integer | optional, null clears it | A user id. null, 0 or a missing key leaves the candidate without an owner. |
BudgetRefusalrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Trimmed; at least 3 characters, or the answer is 400 with code REASON_REQUIRED. Cut to 1000 characters and stored on the requisition. |
CompetencySuggestrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| jobTitle | string | required | Blank is refused with 400. |
| jobDescription | string | optional |
JobDescriptionSuggestrequest body1 field
The whole body is passed to the model as context, so the other fields of the requisition form may be sent with it.
| Field | Type | Required | Note |
|---|---|---|---|
| jobTitle | string | required | Blank is refused with 400. |
CandidateStageWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| stage | string | required | Sourced, Screen, Interview, Offer, Hired or Rejected, in any capitalisation. |
CandidateRejectrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| sendEmail | boolean | optional | Whether the candidate is sent the rejection email. |
| reason | string | optional |
InterviewReschedulerequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| scheduledAt | string (date-time) | required | Text that is not a date is refused with 400, code INVALID_TIME. |
| durationMins | integer | optional | |
| allowOverlap | boolean | optional | Only the value true books the time although the interviewer already has an interview then. |
InterviewCancelrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | Cut to 500 characters. |
DraftForRequisitionrequest body1 field
Asks for a generated draft: interview questions, or an offer template.
| Field | Type | Required | Note |
|---|---|---|---|
| jobPostingId | integer | optional | The requisition used as context. Without it the draft is written for a general role. |
OfferGatePolicyWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| requireScorecardsBeforeOffer | boolean | required | Anything that is not true or false is refused with 400. |
OfferApproverequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| termsVersion | string | optional | The version of the terms the approver saw; cut to 64 characters. When it no longer matches the stored terms the answer is 409, code OFFER_TERMS_CHANGED. |
OfferRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional |
OfferDeclineReasonrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | Trimmed and cut to 2000 characters. Blank, or anything that is not text, clears the stored reason. |
OfferRescindRequestrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Blank is refused with 400, code REASON_REQUIRED. |
OfferSendrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| offerTemplateId | integer | null | optional | The offer template the contract is generated from. null sends the offer with no template; a missing key keeps the template already on the offer. |
AssistantQueryrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| query | string | required | The question. The streaming route refuses a blank one with 400. |
FeedbackRefinerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| text | string | required | The feedback text to tidy. |
JobAlertSubscriberequest body4 fields
| Field | Type | Required | Note |
|---|---|---|---|
| string | required | Trimmed, lower-cased and cut to 200 characters; must look like an address. | |
| consent | boolean | required | Must be true. |
| department | string | optional | Cut to 120 characters. Without it the alert covers every department. |
| locale | string | optional | Cut to 8 characters. |
JobAlertTokenrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| token | string | required | The token from the link in the email. |
SelfScheduleChoicerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| chosen | string (date-time) | required | One of the times offered by the GET on the same path; any other time is refused with 400, code SLOT_NOT_OFFERED. |
Finance: invoices, vendors, purchase orders, approvals, budgets, and request bodies
Invoicein a reply31 fields
One row of GET /parser/invoices.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| db_id | integer | no | The same number as id; the path parameter of every route that takes :doc_id. |
| displayId | string | no | INV- followed by the id, padded to three digits. |
| filename | string | nullable | |
| vendor | string | nullable | The vendor name as read from the document. |
| canonicalVendor | string | nullable | The name of the vendor record it is linked to; otherwise the same as vendor. |
| vendorId | integer | nullable | |
| amount | number | no | In the invoice's own currency; 0 when none was read. |
| currency | string | no | A 3-letter code; USD when none is stored. |
| currencyDefaulted | boolean | no | true when no currency was on the document and your base currency was assumed. |
| amountBase | number | no | The amount in your base currency; the entered amount when no conversion is stored. |
| date | string | no | A display date such as "Oct 10, 2026", or "N/A". Use dateIso in code. |
| dateIso | string (date-time) | no | |
| renewalDate | string | no | A display date, or "N/A". |
| category | string | nullable | |
| costCenter | string | nullable | |
| department | string | nullable | |
| project | string | nullable | |
| status | string | no | The payment status; Pending when none is stored. |
| approvalStatus | string | no | Draft when none is stored. See the approval states on the Finance Operations page. |
| extractionConfidence | number | nullable | |
| invoiceNumber | string | nullable | |
| taxAmount | number | nullable | |
| poNumber | string | nullable | |
| glCode | string | nullable | |
| customFields | object | no | Your own allocation fields; {} when none is set. |
| matchStatus | string | no | unmatched when no match has run. |
| submittedBy | integer | nullable | |
| approvedBy | integer | nullable | |
| paidBy | integer | nullable | |
| rejectionReason | string | nullable |
InvoicePagein a reply6 fields
The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| items | Invoice[] | no | |
| total | integer | no | Every invoice matching the filters, not the page. |
| page | integer | no | Starts at 1. |
| limit | integer | no | Default 25, 1 to 200. |
| pages | integer | no | |
| filteredTotalAmount | number | no | The sum of every matching invoice in your base currency. |
Vendorin a reply11 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| canonicalName | string | no | |
| aliases | string[] | no | Other spellings that are matched to this vendor. |
| category | string | nullable | |
| owner | string | nullable | |
| contractRef | string | nullable | |
| contractRenewal | string (date-time) | nullable | |
| notes | string | nullable | |
| customFields | object | no | {} when none is set. |
| totalSpend | number | no | To two places. |
| mergedIntoId | integer | nullable | Set on a vendor that was merged into another. |
PurchaseOrderin a reply14 fields
One row of GET /parser/finance/purchase-orders.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| poNumber | string | no | |
| vendor | string | nullable | |
| currency | string | no | A 3-letter code. |
| totalAmount | number | no | In the order's own currency. |
| totalAmountBase | number | no | In your base currency: the figure budgets and matching use. |
| baseConverted | boolean | no | false when no converted value is stored; totalAmountBase is then the entered figure. |
| status | string | no | open until the order is closed. |
| department | string | nullable | |
| costCenter | string | nullable | |
| project | string | nullable | |
| lineCount | integer | no | In the list only. |
| grnCount | integer | no | In the list only: goods receipts recorded against the order. |
| orderedDate | string | nullable | A display date such as "Oct 10, 2026". |
MatchExceptionin a reply14 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| docId | integer | no | The invoice. |
| displayId | string | no | |
| vendor | string | nullable | |
| poNumber | string | nullable | |
| exceptionType | string | no | |
| severity | string | no | |
| status | string: open | waived | resolved | no | |
| expected | number | nullable | |
| actual | number | nullable | |
| variancePct | number | nullable | |
| tolerancePct | number | nullable | |
| reason | string | nullable | |
| createdAt | string (date-time) | nullable |
ApprovalQueueItemin a reply14 fields
One submitted invoice in GET /parser/finance/approvals-queue.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| db_id | integer | no | The invoice id. |
| displayId | string | no | |
| vendor | string | nullable | |
| amount | number | no | |
| amountBase | number | no | |
| currency | string | no | |
| matchStatus | string | no | |
| poNumber | string | nullable | |
| approvalsCount | integer | no | Approvals recorded so far. |
| approvalsRequired | integer | no | What the approval rule for this amount asks for. |
| approvers | string[] | no | The names of the people who have approved. |
| ageDays | integer | nullable | Days since it was submitted. |
| submittedAt | string (date-time) | nullable | |
| highValue | boolean | no |
ApprovalRulein a reply6 fields
How many approvers an invoice of a given amount needs.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| name | string | no | |
| minAmount | number | no | In your base currency. |
| maxAmount | number | nullable | null for no upper limit. |
| requiredApprovals | integer | no | |
| isActive | boolean | no |
GLAccountin a reply5 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| code | string | no | |
| name | string | no | |
| type | string | nullable | |
| isActive | boolean | no |
Budgetin a reply62 fields
All the money fields are in your base currency except budgetEntered.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| scopeType | string | no | global when the budget covers all spend. |
| scopeValue | string | nullable | |
| label | string | no | scopeValue, or "All spend". |
| name | string | nullable | |
| displayName | string | no | |
| description | string | nullable | |
| budgetType | string | no | |
| startDate | string (date-time) | nullable | |
| endDate | string (date-time) | nullable | |
| periodKind | string | nullable | |
| authoritativeSource | string: invoice | cloud | both | no | Which actuals drive consumedPct. |
| currency | string | nullable | The currency the budget was entered in. |
| tags | array | no | |
| glAccountId | integer | nullable | |
| recurrence | string | nullable | |
| recurrenceInterval | string | nullable | |
| warnPct | number | nullable | This budget's own warning threshold, as a fraction. |
| overPct | number | nullable | |
| warnThreshold | number | no | The threshold in force, as a fraction. |
| overThreshold | number | no | |
| ownerEmail | string | nullable | |
| ownerUserId | integer | nullable | |
| ownerDepartment | string | nullable | |
| period | string | no | |
| budget | number | no | The budget amount in base currency. |
| budgetEntered | number | no | As entered, in currency. |
| baseCurrency | string | no | |
| actual | number | no | Per authoritativeSource. |
| invoiceActual | number | no | |
| cloudActual | number | no | |
| cloudSynced | boolean | no | |
| cloudSystemCount | integer | no | |
| committed | number | no | Open purchase-order value that has not been invoiced yet. |
| softCommitted | number | no | Pending and approved spend requests. |
| available | number | no | budget minus actual, committed and softCommitted. |
| remaining | number | no | budget minus actual. |
| variance | number | no | |
| baselineBudget | number | no | The first version of this budget. |
| varianceVsBaseline | number | no | |
| varianceType | string: favorable | unfavorable | no | |
| consumedPct | number | no | A percentage to one place. |
| projected | number | no | |
| projectedPct | number | no | |
| onPaceToExceed | boolean | no | |
| status | string: OK | Warning | Over | no | The consumption status. |
| lifecycleStatus | string | no | Draft, Submitted, Approved or Rejected. |
| pendingReapproval | boolean | no | |
| approvedAmountBase | number | nullable | |
| reapprovalEditor | string | nullable | |
| submittedBy | integer | nullable | |
| approvedBy | integer | nullable | |
| rejectionReason | string | nullable | |
| versionNo | integer | no | |
| parentBudgetId | integer | nullable | |
| isCurrent | boolean | no | |
| locked | boolean | no | |
| versionLabel | string | nullable | |
| method | string | no | |
| hasLines | boolean | no | |
| frozen | boolean | no | |
| frozenReason | string | nullable |
BudgetLinein a reply12 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| label | string | no | |
| glAccountId | integer | nullable | |
| category | string | nullable | |
| costCenter | string | nullable | |
| amount | number | no | |
| justification | string | nullable | |
| driverKey | string | nullable | |
| driverQty | number | nullable | |
| unitCost | number | nullable | |
| sortOrder | integer | no |
BudgetCommentin a reply6 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| authorId | integer | nullable | |
| author | string | nullable | |
| comment | string | no | |
| createdAt | string (date-time) | nullable |
BudgetTransferin a reply22 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| amount | number | no | |
| reason | string | nullable | |
| status | string | no | |
| currency | string | nullable | |
| amountBase | number | no | |
| fromAmount | number | nullable | What leaves the source budget, in its own currency. |
| fromCurrency | string | nullable | |
| toAmount | number | nullable | |
| toCurrency | string | nullable | |
| fromBudgetId | integer | no | |
| toBudgetId | integer | no | |
| fromLabel | string | no | |
| toLabel | string | no | |
| requestedBy | integer | nullable | |
| requestedByEmail | string | nullable | |
| requestedAt | string (date-time) | nullable | |
| approvedBy | integer | nullable | |
| approvedAt | string (date-time) | nullable | |
| rejectionReason | string | nullable | |
| approvalsHave | integer | no | Distinct approvers recorded so far. |
| approvalsNeed | integer | no | What a transfer of this amount requires. |
SpendRequestin a reply14 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| requesterEmail | string | nullable | |
| department | string | nullable | |
| budgetId | integer | nullable | |
| budgetName | string | nullable | |
| amount | number | no | |
| amountBase | number | nullable | |
| currency | string | nullable | |
| description | string | nullable | |
| vendor | string | nullable | |
| status | string | no | |
| createdAt | string (date-time) | nullable | |
| approvedAt | string (date-time) | nullable | |
| rejectionReason | string | nullable |
BudgetAlertin a reply9 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| budgetId | integer | no | |
| tier | string | no | |
| message | string | nullable | |
| consumedPct | number | nullable | |
| ownerEmail | string | nullable | |
| ownerDepartment | string | nullable | |
| createdAt | string (date-time) | nullable | |
| acknowledgedAt | string (date-time) | nullable |
InvoiceDetailin a reply26 fields
One invoice as the review screen reads it. Not the Invoice object of the list: the field names differ.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| displayId | string | no | INV- and the id, padded to three digits. |
| filename | string | nullable | |
| hasFile | boolean | no | Whether the uploaded file is stored. |
| fileContentType | string | nullable | |
| vendor | string | nullable | |
| amount | number | no | In currency. |
| currency | string | no | USD when none was read. |
| currencyDefaulted | boolean | no | true when the currency was filled from the instance default. |
| amountAmbiguous | boolean | no | true when amountReviewNote is set. |
| amountReviewNote | string | nullable | |
| dateReviewNote | string | nullable | |
| submitterNote | string | nullable | |
| baseCurrency | string | no | |
| amountBase | number | nullable | |
| dateIso | string (date-time) | nullable | |
| category | string | nullable | |
| status | string | nullable | |
| approvalStatus | string | no | Draft when none is stored. |
| extractionConfidence | number | nullable | |
| invoiceNumber | string | nullable | |
| taxAmount | number | nullable | |
| poNumber | string | nullable | |
| matchStatus | string | no | unmatched when none is stored. |
| lineItems | object[] | no | In line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }. |
| rawText | string | no | The text read from the file; empty when there is none. |
PurchaseOrderDetailin a reply15 fields
One purchase order with its lines and goods receipts.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| poNumber | string | no | |
| vendor | string | nullable | |
| currency | string | no | |
| totalAmount | number | no | In currency. |
| totalAmountBase | number | no | In base currency. |
| baseConverted | boolean | no | false when no base amount was stored for the order. |
| status | string | no | open, partially_received, received, closed or cancelled. |
| department | string | nullable | |
| costCenter | string | nullable | |
| project | string | nullable | |
| notes | string | nullable | |
| orderedDate | string | nullable | Display text such as "Oct 10, 2026". |
| lines | object[] | no | In line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }. |
| goodsReceipts | object[] | no | Newest first, each { id:integer, grnNumber:string|null, receivedDate:string|null (display text) }. |
OwnedSystemin a reply13 fields
A system linked to an owner and, optionally, a budget, with the cost its provider reports.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| label | string | no | |
| providerType | string | no | |
| kind | string | nullable | |
| systemRef | string | nullable | |
| budgetId | integer | nullable | |
| metering | string: live | derived | discovery | no | |
| ownerEmail | string | nullable | |
| ownerDepartment | string | nullable | |
| cost | number | no | 0 when the provider could not be read. |
| forecast | number | no | |
| isSynced | boolean | no | |
| lastSync | string | nullable |
BulkOutcomeItemin a reply5 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | any | no | The id as an integer, or the value you sent when it could not be read as one. |
| ok | boolean | no | |
| error | string | no | Present only when ok is false: the reason this item was not done. |
| approvalStatus | string | no | Present only in the invoice bulk reply, on an item that was done by submit, approve, reject, pay or clear_review: the invoice's approval status after the action. |
| kind | string: budget | transfer | no | Present only in the budget-approvals bulk reply. |
BulkOutcomein a reply4 fields
What a bulk call did. Each item is handled and saved on its own, so a failed item never undoes the others. A valid id sent twice appears once; a value that cannot be read as an id is reported each time it is sent.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| action | string | no | The action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply. |
| ok | integer | no | How many items were done. |
| failed | integer | no | How many were not. |
| results | BulkOutcomeItem[] | no | One entry per distinct valid id, plus one for each value that could not be read as an id. |
InvoiceImportSummaryin a reply8 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| imported | integer | no | Rows stored as invoices. |
| skipped | integer | no | Rows not stored. |
| errors | string[] | no | One message per skipped row; the first 50 are returned. |
| currencyDefaulted | integer | no | Rows that named no currency and were given the base currency. |
| amountAmbiguous | integer | no | Rows held for review because the amount could be read two ways. |
| dateHeld | integer | no | Rows held for review because the date could not be read with certainty. |
| duplicatesSkipped | integer | no | Rows skipped because the same vendor and invoice number are already on file or repeated in the file. |
| duplicatesHeld | integer | no | Rows stored but held for review because another invoice has the same vendor, amount and date. |
CurrencyRatein a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| code | string | no | Three letters. |
| rateToBase | number | no | How many units of the base currency one unit of code is worth. |
| updatedAt | string (date-time) | no | UTC. |
CurrencyTablein a reply3 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| baseCurrency | string | no | |
| missingRates | array | no | Currencies in use on invoices, purchase orders, budgets or spend requests that have no rate, ordered by code; each entry carries code, base and a count for documents, purchaseOrders, budgets and spendRequests. |
| rates | CurrencyRate[] | no | Every stored rate, ordered by code. |
VendorMergeResultin a reply5 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| success | boolean | no | Always true. |
| keptVendorId | integer | no | |
| repointed | integer | no | How many invoices moved to the kept vendor. |
| skippedLocked | array | no | The invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status. |
| loserRetained | boolean | no | true when the merged vendor was kept because invoices in a locked period still point at it. |
PeriodCloseResultin a reply5 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| period | string | no | |
| status | string | no | Always locked. |
| journalsPosted | integer | no | Invoices that were journalled by this close; one already journalled is not posted again. |
| batch | string | no | The period, a hyphen and the UTC time of the close to the second. |
| forced | boolean | no | true when the period was locked over open items. |
GLSyncRunin a reply4 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| connector | string | no | |
| pushed | integer | no | Journal lines marked as synced by this call. |
| live | boolean | no | false for the manual connector. |
| note | string | no |
AlertSweepResultin a reply2 fields
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| evaluated | integer | no | Current budgets looked at. |
| fired | integer | no | Alerts raised by this call. |
InvoiceReviewrequest body8 fields
Corrections to what was read from an uploaded invoice. Only the keys you send are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| vendor | string | optional | Trimmed; an empty value changes nothing. |
| category | string | optional | Trimmed; an empty value changes nothing. |
| currency | string | optional | Upper-cased and cut to 3 letters. |
| date | string (date-time) | optional | ISO 8601; the first 19 characters are read and text that is not a date changes nothing. A date in a locked accounting period is refused. |
| amount | number | optional | A number, or text in a recognised number format. An entry that could be read two ways is refused with 400. |
| po_number | string | optional, null clears it | Trimmed, upper-cased and cut to 64 characters. |
| note | string | optional, null clears it | Trimmed and cut to 1000 characters. |
| commit | boolean | optional | true releases an invoice held for review to Draft. Ignored unless the caller holds manage_finance. |
InvoiceAllocationrequest body6 fields
Where an invoice is charged. Only the keys you send are changed. Refused with 409 once the invoice is Submitted, Approved or Paid.
| Field | Type | Required | Note |
|---|---|---|---|
| costCenter | string | optional, null clears it | |
| department | string | optional, null clears it | |
| project | string | optional, null clears it | |
| glCode | string | optional, null clears it | Once a chart of accounts exists the code must be in it, or the answer is 400. |
| currency | string | optional | Upper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it. |
| customFields | object | optional | Replaces the stored set. Keys that are not in the allocation custom-field schema, and empty values, are dropped. |
InvoiceActionrequest body4 fields
The body of an approval step. Which keys are read depends on the action in the path; submit and clear_review read none.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required for reject | Blank is refused with 400. |
| budget_override | boolean | optional | approve only: approve although the invoice takes a budget over its limit. Needs approve_invoice. |
| override_reason | string | required with budget_override | Blank is refused with 400. |
| comment | string | optional | approve only: stored with the approval record. |
InvoiceBulkActionrequest body7 fields
One action for many invoices. Each invoice is handled on its own and reported in results; an id sent twice is handled once.
| Field | Type | Required | Note |
|---|---|---|---|
| action | string: submit | approve | reject | pay | clear_review | allocate | delete | required | |
| ids | integer[] | required | 1 to 500 invoice ids. |
| allocation | InvoiceAllocation | optional | Read for allocate only; without it nothing is changed. |
| reason | string | required for reject | One reason shared by every invoice. |
| budget_override | boolean | optional | approve only, as in InvoiceAction. |
| override_reason | string | required with budget_override | |
| comment | string | optional | approve only, as in InvoiceAction; one comment shared by every invoice. |
VendorUpdaterequest body9 fields
Only the keys you send are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| canonicalName | string | optional | A name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one. |
| category | string | optional, null clears it | |
| owner | string | optional, null clears it | |
| ownerUserId | integer | optional, null clears it | |
| contractRef | string | optional, null clears it | |
| contractRenewal | string (date-time) | optional, null clears it | ISO 8601; text that is not a date is stored as empty. |
| notes | string | optional, null clears it | |
| aliases | string[] | optional | Replaces the alias list; each alias is normalised the way vendor names are matched. |
| customFields | object | optional | Replaces the stored set. Keys that are not in the vendor custom-field schema, and empty values, are dropped. |
CustomFieldDefinitionrequest body4 fields
One entry of a custom-field schema (vendor or allocation).
| Field | Type | Required | Note |
|---|---|---|---|
| key | string | required | Characters other than letters, digits and _ become _. An entry with an empty key, or a key already used in the same call (ignoring case), is skipped. |
| label | string | optional | Cut to 60 characters. Defaults to the key. |
| type | string: text | select | number | date | optional | Any other value is stored as text. |
| options | string[] | optional | Read for select only: each cut to 60 characters, duplicates dropped, the first 50 kept. |
CurrencyPolicyUpdaterequest body3 fields
Sets the base currency, one exchange rate, or both. The base totals of invoices that are not yet in approval are then worked out again.
| Field | Type | Required | Note |
|---|---|---|---|
| baseCurrency | string | optional | Three letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate. |
| code | string | required with rateToBase | Upper-cased and cut to 3 letters. |
| rateToBase | number | required with code | How many units of the base currency one unit of code is worth. Greater than 0, between 0.000001 and 1000000. |
FinanceSettingWriterequest body2 fields
One setting per call. The keys, their kinds and defaults are what GET /parser/finance/settings lists.
| Field | Type | Required | Note |
|---|---|---|---|
| key | string: enforce_sod | finance.high_value_floor | finance.review_confidence_floor | match.tolerance_pct | match.require_grn | finance.llm_extraction | finance.digest_enabled | finance.digest_recipients | finance.digest_interval_hours | finance.fiscal_year_start_month | finance.budget_warn_pct | finance.budget_over_pct | finance.cloud_anomaly_pct | required | Any other key is refused with 400. |
| value | any | required | A yes/no setting stores true for true, 1, yes or on and false for anything else. A number setting refuses text with 400 and is brought inside its range: finance.review_confidence_floor 0 to 1, match.tolerance_pct 0 to 0.5, finance.fiscal_year_start_month a whole number 1 to 12. |
PurchaseOrderLineInputrequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| description | string | optional | |
| quantity | number | optional | Default 0. |
| unit_price | number | optional | Default 0. The line amount is quantity times unit_price; an amount you send is not read. |
PurchaseOrderCreaterequest body9 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_number | string | required | Trimmed and upper-cased. A number that already exists is refused with 409. |
| vendor | string | optional | Trimmed and matched to a known vendor where one exists. |
| currency | string | optional | Default USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it. |
| lines | PurchaseOrderLineInput[] | optional | When present, the order total is the sum of the lines. |
| total_amount | number | optional | Read only when lines is empty. Default 0. |
| cost_center | string | optional | |
| department | string | optional | |
| project | string | optional | |
| budget_override | boolean | optional | true creates the order although it takes a budget over its limit; without it that case answers 409. |
GoodsReceiptLineInputrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_line_id | integer | optional | The purchase-order line received against. |
| quantity_received | number | optional | Default 0. |
GoodsReceiptCreaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| po_id | integer | required | 404 when the purchase order does not exist. |
| grn_number | string | optional | |
| lines | GoodsReceiptLineInput[] | optional |
ApprovalRuleWriterequest body5 fields
Create and update take the same keys; an update changes only the keys you send. Amounts are in your base currency.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | At most 80 characters. Default "Rule". |
| minAmount | number | optional | Default 0. Not negative; at most 999999999999.99. |
| maxAmount | number | optional, null clears it | null means no ceiling. Otherwise greater than minAmount and at most 999999999999.99. |
| requiredApprovals | integer | optional | Default 1. A whole number from 1 to 10. |
| isActive | boolean | optional | Default true. |
AdjustmentCreaterequest body4 fields
An adjusting journal entry in the current open period: one debit and one credit of the same amount, in base currency.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Blank is refused with 400. |
| debitGl | string | required | A general-ledger code. Once a chart of accounts exists it must be in it. |
| creditGl | string | required | Must differ from debitGl. |
| amount | number | required | Greater than 0; rounded to 2 places. |
BudgetWriterequest body19 fields
Create and update take the same keys. An update changes only the keys you send. The owner is set by the server and is not read from the body.
| Field | Type | Required | Note |
|---|---|---|---|
| period | string | required on create | YYYY-MM or YYYY. |
| scopeType | string | optional | Default global. A caller without a finance-wide budget permission may send only department, and it is set to their own department. |
| scopeValue | string | optional, null clears it | The department, cost centre or vendor the budget covers. |
| amount | number | optional | Default 0. A negative number or text that is not a number is stored as 0. |
| currency | string | optional, null clears it | The currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it. |
| name | string | optional, null clears it | |
| description | string | optional, null clears it | |
| budgetType | string | optional | Default opex. |
| method | string | optional | Default top_down. |
| startDate | string (date-time) | optional, null clears it | ISO 8601; text that is not a date is stored as empty. |
| endDate | string (date-time) | optional, null clears it | |
| periodKind | string | optional, null clears it | |
| authoritativeSource | string: invoice | cloud | both | optional | Default invoice. An update ignores any other value. |
| tags | array | optional | Read only when it is a list. |
| glAccountId | integer | optional, null clears it | |
| recurrence | string | optional, null clears it | |
| recurrenceInterval | string | optional, null clears it | |
| warnPct | number | optional, null clears it | A fraction; brought inside 0 to 1. |
| overPct | number | optional, null clears it | A fraction; brought inside 0 to 1. |
BudgetLineWriterequest body10 fields
Create and update take the same keys; an update changes only the keys you send. The line amount is driverQty times unitCost when both are known, otherwise amount.
| Field | Type | Required | Note |
|---|---|---|---|
| label | string | optional | Default "Line". |
| amount | number | optional | Rounded to 2 places. Not read when driverQty and unitCost are both set. |
| driverKey | string | optional | With headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one. |
| driverQty | number | optional | |
| unitCost | number | optional | |
| glAccountId | integer | optional | |
| category | string | optional | |
| costCenter | string | optional | |
| justification | string | optional | |
| sortOrder | integer | optional | Default 0. |
BudgetTransferCreaterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| fromBudgetId | integer | required | |
| toBudgetId | integer | required | Must differ from fromBudgetId. |
| amount | number | required | Greater than 0. |
| currency | string | optional | The currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side. |
| reason | string | optional |
BudgetApprovalsBulkrequest body4 fields
Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.
| Field | Type | Required | Note |
|---|---|---|---|
| action | string: approve | reject | required | |
| budgetIds | integer[] | optional | Together with transferIds: at least 1 and at most 500 items. |
| transferIds | integer[] | optional | |
| reason | string | required to reject a budget | Stored as the rejection reason. Without it each budget in a reject is reported in results as failed; a transfer can be rejected without one. |
SpendRequestCreaterequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| budgetId | integer | required | A budget in your scope: 404 when it does not exist, 403 when it is outside your scope. |
| amount | number | required | Greater than 0. |
| currency | string | optional | Empty means the base currency. 400 when no exchange rate is set for it. |
| description | string | optional | |
| vendor | string | optional |
OwnedSystemClaimrequest body7 fields
Attaches a discovered system to an owner and, optionally, a budget.
| Field | Type | Required | Note |
|---|---|---|---|
| providerType | string | optional | Upper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery. |
| externalProviderId | integer | optional | A provider id from GET /parser/finance/systems/providers. |
| systemRef | string | optional | |
| label | string | optional | Defaults to systemRef, then providerType, then "System". |
| kind | string | optional | |
| budgetId | integer | optional | A budget you can edit: 404 when it does not exist, 403 when it is not yours to edit. |
| ownerEmail | string | optional | Defaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner. |
DocumentStatusWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| status | string | required | Stored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps. |
FinanceQuestionrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| question | string | required | Trimmed; blank is refused with 400. |
AppBudgetWriterequest body1 field
The yearly budget of one app (vendor), for the current calendar year.
| Field | Type | Required | Note |
|---|---|---|---|
| amount | number | optional | Greater than 0 sets the budget. Zero, a negative number, a missing key or text that is not a number clears it. |
VendorBulkPatchrequest body2 fields
At least one of the two keys. Any other key is not read.
| Field | Type | Required | Note |
|---|---|---|---|
| category | string | optional | |
| owner | string | optional |
VendorBulkUpdaterequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| patch | VendorBulkPatch | required |
VendorMergerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| keepId | integer | required | The vendor that stays. |
| mergeId | integer | required | The vendor whose invoices and aliases move to keepId. |
DigestSendrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| recipients | string[] | optional | Email addresses. Without it the configured digest recipients are used. |
PurchaseOrderStatusWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| status | string: closed | cancelled | open | optional | Default closed. Any other value is refused with 400. |
PurchaseOrderBulkStatusrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| status | string: closed | cancelled | open | optional | Default closed. |
MatchExceptionWaiverequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Trimmed; blank is refused with 400. |
MatchExceptionBulkWaiverequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once. |
| reason | string | required | One reason shared by every exception. |
GLAccountCreaterequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| code | string | required | Trimmed. A code that already exists is refused with 409. |
| name | string | optional | Defaults to the code. |
| type | string | optional | The account type, stored as sent. |
PeriodCloserequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| force | boolean | optional | true locks the period although it still has open items. |
| reason | string | required with force | Trimmed; blank with force is refused with 400. |
GLConnectorWriterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| connector | string: manual | quickbooks | netsuite | xero | required | Trimmed and lower-cased. Any other value is refused with 400. |
BudgetRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Trimmed; blank is refused with 400. |
BudgetLockrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| locked | boolean | optional | Default true. false unlocks, which needs manage_finance_policy. |
BudgetFreezerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| frozen | boolean | optional | Default true. false lifts the freeze. |
| reason | string | optional | Stored with a freeze; not kept when the freeze is lifted. |
BudgetCopyrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| period | string | required | The period of the copy: YYYY-MM or YYYY. |
BudgetCommentCreaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| comment | string | required | Blank is refused with 400. |
TransferRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | Trimmed; stored as the rejection reason. |
SpendRequestDecisionrequest body1 field
Read for the reject and cancel actions only; approve and fulfill read no body.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional |
Devices: agent installer, enrolment tokens, commands, groups, rollouts, MDM status, software
AgentPackagein a reply5 fields
One hosted agent installer, as the agent-pkg read answers it and as each entry of versions[] in the agent-pkgs read. The file itself is not part of it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| version | string | nullable | Null for an installer uploaded without a version. |
| filename | string | no | |
| sha256 | string | no | SHA-256 of the hosted file, 64 hex characters. |
| sizeBytes | integer | no | |
| createdAt | string (date-time) | read-only | When it was uploaded. |
AgentPackageUploadin a reply8 fields
The reply to an installer upload.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| platform | string | no | macos | linux | windows |
| version | string | nullable | |
| filename | string | no | |
| sha256 | string | no | SHA-256 of the stored file, 64 hex characters. |
| sizeBytes | integer | no | |
| sig | string | nullable | The vendor release signature carried by a signed bundle. |
| kid | string | nullable | The id of the key that signature was verified with. |
| signed | boolean | no | True when the upload was a signed bundle. |
DeviceSetupStatein a reply2 fields
Which platforms are in use. Answered by the setup-state read.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| platforms | object | no | Keyed by macos, windows and linux. Each { active: boolean, agentPublished: boolean, deviceCount: integer, pendingCount: integer }: active is true when an installer is hosted or any device of that platform has enrolled; deviceCount counts approved devices that are not retired; pendingCount counts enrolments not yet approved. |
| anyActive | boolean | no | True when any platform is active. |
EnrollmentTokenin a reply9 fields
One enrolment token record, without its value. The token list is a bare array of the 200 newest, newest first; it takes no paging parameter.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| label | string | nullable | |
| expiresAt | string (date-time) | no | |
| maxUses | integer | no | 0 means unlimited. |
| useCount | integer | read-only | |
| usedAt | string (date-time) | nullable, read-only | |
| revokedAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| status | string | read-only | revoked | expired | used (every use spent) | active. Checked in that order. |
EnrollmentTokenCreaterequest body3 fields
Issues an enrolment token. The reply is { id, token, label, expiresAt, maxUses }; token is the value, shown only in that reply.
| Field | Type | Required | Note |
|---|---|---|---|
| label | string | optional | Cut to 120 characters. |
| ttlHours | integer | optional | Default 24. Held to 1 to 720. |
| maxUses | integer | optional | Default 1. Held to 1 to 500. |
DeviceCommandTypein a reply5 fields
One entry of the command catalogue: { types: DeviceCommandType[] }. A fixed list.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| type | string | no | lock | uninstall | restart | update_os | rename |
| label | string | no | |
| platforms | string[] | no | macos | linux | windows |
| destructive | boolean | no | True for uninstall only. |
| args | object[] | no | The arguments the command takes. Empty except for rename: one { name: "name", label, type: "string", required: true, maxLength: 63, maxLengthByPlatform: { windows: 15 }, pattern }. |
DeviceCommandCreaterequest body3 fields
Queues one command for one device. The batch call takes the same fields plus ids (1 to 500 device ids).
| Field | Type | Required | Note |
|---|---|---|---|
| type | string | required | lock | uninstall | restart | update_os | rename |
| args | object | optional | Required for rename: { name } of letters, digits and hyphens, with no hyphen first or last, at most 63 characters and at most 15 on Windows. |
| reason | string | optional | Required for uninstall. |
DeviceCommandin a reply13 fields
One command in a device’s history. The history is a bare array, newest first; ?limit takes 1 to 200 and defaults to 50, and there is no offset.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| deviceId | integer | read-only | |
| type | string | no | lock | uninstall | restart | update_os | rename |
| args | object | nullable | |
| status | string | read-only | queued, then delivered once the agent has collected it, then the result the agent reports: succeeded | failed | unsupported. A queued command can instead end as cancelled or expired. |
| requestedBy | string | nullable, read-only | |
| reason | string | nullable | |
| batchId | string | nullable, read-only | Shared by the commands of one batch call. |
| result | object | nullable, read-only | { status, detail } as the agent reported it, or { detail } for a cancelled or expired command. |
| deliveredAt | string (date-time) | nullable, read-only | |
| completedAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | nullable, read-only | |
| updatedAt | string (date-time) | nullable, read-only |
DeviceCommandResultin a reply3 fields
The reply to queueing commands. Each device is tried on its own, so read failed even on a 200. The single-device call answers ok and failed only, and on a refusal adds error and pendingCommand at the top level.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | object[] | no | Each { deviceId, commandId, noop }: noop is present, as true, only when nothing new was queued. |
| failed | object[] | no | Batch call: each { deviceId, error } and, where one applies, pendingCommand. Single-device call: each { deviceId, error, status } and, where one applies, code and pendingCommand. |
| batchId | string | nullable | Batch call only. Null when the call named a single device. |
DeviceGroupin a reply10 fields
One device group, as an entry of groups in the group list. That list answers { groups, fields, ops, fieldOps, complianceValues }: every group in one reply, ordered by name.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| kind | string | no | static | smart |
| platform | string | nullable | macos | linux | windows. Null for a group that is not bound to one platform. |
| criteria | object | nullable | The rules of a smart group. |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| memberCount | integer | read-only | Every member. |
| enforceableCount | integer | read-only | The members that profiles and rollouts actually target. |
DeviceLitein a reply7 fields
One entry of the device picker, which answers { items: DeviceLite[], total }. Retired devices and devices still waiting for enrolment approval are never included. Ordered by hostname.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| hostname | string | nullable | |
| name | string | nullable | |
| serialNumber | string | nullable | |
| platform | string | nullable | |
| ownerName | string | nullable | |
| loggedInUser | string | nullable |
DeviceActivityEntryin a reply6 fields
One audit-log entry about a device. The activity read is a bare array, newest first; ?limit defaults to 50 with a maximum of 200, and there is no offset.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | |
| action | string | no | The audit action name. |
| level | string | no | |
| userEmail | string | nullable | |
| createdAt | string (date-time) | no | |
| details | string | nullable |
ProfileRolloutin a reply14 fields
A staged rollout of one configuration profile, as stored. The rollout calls answer it as rollout, and a reply can carry computed fields beyond these.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| profileId | integer | read-only | |
| fromVersion | integer | nullable, read-only | The profile version in force before the rollout. |
| toVersion | integer | read-only | The version being rolled out. |
| rings | object[] | no | 2 to 5 rings, each { type: "groups", groupIds, label }, { type: "percent", percent, label } or, for the last ring, { type: "all", label }. |
| currentRing | integer | read-only | The index of the ring now receiving the version, from 0. |
| state | string | read-only | running | paused | complete | rolled_back | canceled |
| autoPromote | boolean | no | Default true. |
| promoteAfterHours | integer | no | Default 24. 1 to 720. |
| ringStartedAt | string (date-time) | nullable, read-only | |
| startedBy | string | nullable, read-only | |
| endedAt | string (date-time) | nullable, read-only | |
| endedBy | string | nullable, read-only | |
| holdReason | string | nullable, read-only |
AgentRolloutin a reply11 fields
One agent version rollout. The rollout read answers { platform, rollouts: AgentRollout[] }: every rollout of the platform in one reply, newest first.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| platform | string | no | macos | linux | windows |
| version | string | no | The agent version targeted. |
| enabled | boolean | no | False while the rollout is on hold. |
| label | string | nullable | |
| scope | object | no | { type: "all" } or a scope naming groups or devices. |
| scopeLabel | string | read-only | |
| deviceCount | integer | read-only | Devices in scope. |
| onTarget | integer | read-only | Devices in scope already on that version or a newer one. |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only |
MdmStatusin a reply8 fields
Whether Apple MDM is set up and reachable. { configured: false } alone when it is not set up.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| configured | boolean | no | |
| connected | boolean | no | Whether the MDM control plane answered. |
| tenant | string | nullable | |
| deviceCount | integer | nullable | Null when the control plane did not answer. |
| enrollmentUrl | string | nullable | |
| enrollmentReady | boolean | no | |
| hasCert | boolean | no | Whether a push certificate is installed. |
| certNotAfter | string | nullable | When the push certificate expires. |
SoftwarePagein a reply4 fields
One page of the software inventory. Paged by ?page and ?pageSize (default 50, 1 to 500).
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| items | object[] | no | Each { name, publisher, deviceCount, platforms, versions: [{ version, count }] }. |
| total | integer | no | Every title matching the filters. |
| page | integer | no | |
| pageSize | integer | no |
DeviceCreaterequest body10 fields
Adds a device record by hand. Only these keys are read; any other key is ignored.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| type | string | optional | |
| serialNumber | string | optional | Unique across devices. |
| status | string | optional | Default available. |
| manufacturer | string | optional | |
| userId | integer | optional | The owner, a user id. |
| assetTag | string | optional | |
| department | string | optional | |
| location | string | optional | |
| costCenter | string | optional |
DeviceAlertSettingsUpdaterequest body7 fields
Only the keys you send are changed. A value of the wrong type or out of range answers 400 and nothing is saved.
| Field | Type | Required | Note |
|---|---|---|---|
| pendingEnrollment | boolean | optional | |
| noncompliant | boolean | optional | |
| encryptionOff | boolean | optional | |
| silentDays | integer | optional, nullable | 1 to 365. Null turns the alert off. |
| enrollTokenDays | integer | optional, nullable | 1 to 90. Null turns the alert off. |
| pushCertDays | integer[] | optional | At most 6 whole numbers, each 1 to 365. Stored without repeats, largest first. |
| recipients | string | optional | Only the value manage_devices is accepted; anything else answers 400. |
DeviceStaleSettingUpdaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| staleDays | integer | required | A whole number from 1 to 365. deviceStaleDays is read when staleDays is not sent. |
DeviceCustomFieldsWriterequest body1 field
Replaces the whole list of custom device fields.
| Field | Type | Required | Note |
|---|---|---|---|
| fields | object[] | optional | Left out, or not an array, the list is emptied (200, not 400). At most 20 are kept. Each { key, label (cut to 60 characters), type: text | select | number | date (anything else is stored as text), options (select only: up to 50, each cut to 60 characters) }. An entry that is not an object, has no key or label, or repeats a key is skipped, not refused. |
InstallInvitesSendrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| userIds | integer[] | required | The users to email. At least one whole number. |
ReservationLinkrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reservationId | integer | required | The id of the reservation record to link the device to. |
EnrollmentRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | At most 234 characters after trimming, or the answer is 400. |
DeviceIdsRequestrequest body1 field
The body of the batch approve and unretire calls.
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped. |
DevicePolicyUpdaterequest body5 fields
Only the keys you send are changed. The hardening and software-update switches named on the 409 are read by this route too.
| Field | Type | Required | Note |
|---|---|---|---|
| platform | string: macos | linux | windows | optional | Default macos. |
| checkInIntervalMinutes | integer | optional | Held to 5 to 1440. |
| allowUserQuit | boolean | optional | |
| minOsVersion | string | optional, null clears it | A dotted number such as 14.5, 24.04 or 10.0.22631. An empty string also clears it. |
| minAgentVersion | string | optional, null clears it | A version such as 1.1.0, not above the adopted agent version. An empty string also clears it. |
DeviceAssignrequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| userId | integer | optional, null clears it | The owner, a user id. Null, or leaving the key out, unassigns the owner. |
| assetTag | string | optional | Cut to 255 characters. An empty string clears it; a key left out keeps its value. The same for department, location and costCenter. |
| department | string | optional | |
| location | string | optional | |
| costCenter | string | optional | |
| customFields | object | optional | Values keyed by custom field key. Keys that are not in the custom field list are dropped. |
DeviceBatchAssignrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped. |
| userId | integer | required, nullable | A user id, or null to unassign. Leaving the key out answers 400. |
DeviceBatchCommandCreaterequest body1 field
Queues one command for many devices.
Every field of DeviceCommandCreate, plus:
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped. |
DeviceRetirerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped. |
| reason | string | required | 3 to 255 characters after trimming. |
DeviceGroupCreaterequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| description | string | optional | |
| kind | string: static | smart | optional | Default static: any value other than smart is read as static. |
| platform | string: macos | linux | windows | optional | Binds the group to one platform. Any other value leaves it unbound. |
| criteria | object | optional | Smart groups only, where it is required and checked. |
| deviceIds | integer[] | optional | Static groups only: the first members. In a platform-bound group, devices of another platform are left out. |
DeviceGroupUpdaterequest body3 fields
Only the keys you send are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | An empty name is ignored. |
| description | string | optional | An empty string clears it. |
| criteria | object | optional | Smart groups only; ignored on a static group. |
DeviceGroupPreviewrequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| criteria | object | required | The rules to try, checked as for a smart group. |
| platform | string: macos | linux | windows | optional | |
| kind | string | optional | When sent it must be smart. |
DeviceGroupMembersrequest body1 field
The body of the add-members and bulk-remove calls.
| Field | Type | Required | Note |
|---|---|---|---|
| deviceIds | integer[] | required | At least one device id. |
DeviceProfileCreaterequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| platform | string: macos | linux | windows | required | Cannot be changed later. |
| payloads | object[] | optional | Each { key, settings }. A key the platform does not support, or one that is not available, answers 400. |
| scope | object | optional | { type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }. Default type all. |
| description | string | optional | |
| enabled | boolean | optional | Default false. |
DeviceProfileUpdaterequest body7 fields
Only the keys you send are changed. The profile version goes up by one when something changed.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | |
| description | string | optional | |
| payloads | object[] | optional | As on create. |
| scope | object | optional | As on create. |
| enabled | boolean | optional | |
| platform | string: macos | linux | windows | optional | A profile that has a platform keeps it: a different value answers 400. |
| rollout | ProfileRolloutStart | optional | Saves the change and starts a staged rollout of it in one call. The reply then also carries rollout and mdm. 400 when nothing changed on a profile that is already on. |
ProfileIdsRequestrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 100 profile ids. A repeated id is deployed once. |
ProfileRolloutStartrequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| rings | object[] | required | 2 to 5 rings. Each is { type: "groups", groupIds } or { type: "percent", percent } with 1 to 99 and each share larger than the one before, plus an optional label of up to 40 characters. The last ring must be { type: "all" }. |
| autoPromote | boolean | optional | Default true. |
| promoteAfterHours | integer | optional | 1 to 720. Default 24. |
ProfileRolloutSettingsrequest body2 fields
Either key or both.
| Field | Type | Required | Note |
|---|---|---|---|
| autoPromote | boolean | optional | |
| promoteAfterHours | integer | optional | 1 to 720. |
MdmEnrollCodeCreaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reusable | boolean | optional | True for a reusable code. Anything else gives a single-use code. |
MdmPushCertUploadrequest body4 fields
Send certPem (with keyPem), or p12 (with passphrase). 413 when the certificate and key together are over 200,000 characters.
| Field | Type | Required | Note |
|---|---|---|---|
| certPem | string | optional | The Apple push certificate in PEM form. Required unless p12 is sent. |
| keyPem | string | optional | Its private key in PEM form. |
| p12 | string | optional | A .p12 file holding both. When it is sent, certPem and keyPem are not read. 400 when it cannot be opened. |
| passphrase | string | optional | The export password of the .p12. |
MdmLockRequestrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| message | string | optional | Passed to the MDM control plane with the lock. |
DestructiveReasonrequest body1 field
The body of the remote wipe, recovery-key reveal and recovery-key rotation calls.
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | At least 3 characters after trimming. The wipe and rotation calls cut it to 500 characters. |
AgentRolloutHoldrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| enabled | boolean | optional | True resumes the rollout. False, or leaving the key out, holds it. |
EnrollmentTokenIssuedin a reply5 fields
The reply to issuing an enrolment token. The only reply that carries the token value.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| token | string | read-only | The token value. Only its hash is stored. |
| label | string | nullable | |
| expiresAt | string (date-time) | no | |
| maxUses | integer | no |
DeviceBatchOutcomein a reply2 fields
The reply to the batch approve and batch assign calls. Each device is tried on its own, so read failed even on a 200.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | object[] | no | Batch approve: each { deviceId, enrollmentApproved: true }. Batch assign: each { deviceId, userId }, userId null when unassigned. |
| failed | object[] | no | Each { deviceId, error }. |
DeviceLifecycleOutcomein a reply2 fields
The reply to the retire and unretire calls. Each device is tried on its own, so read failed even on a 200.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | integer[] | no | The ids done. A device already in the state asked for is listed here and nothing about it is changed. |
| failed | object[] | no | Each { id, error }; "Device not found" for an unknown id. |
AgentEnrollRequestrequest body18 fields
The first call an agent makes. A device is one record per serial number.
| Field | Type | Required | Note |
|---|---|---|---|
| enrollmentToken | string | required | |
| serialNumber | string | required | At most 255 characters. Spaces at either end are removed. |
| publicKey | string | required | The device's P-256 public key. A key on another curve is refused. |
| hostname | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| osVersion | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| osBuild | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| architecture | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| chip | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| modelName | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| agentVersion | string | optional | The version of the agent that is running. The check-in offers an update only when a newer version is the target. |
| loggedInUser | string | optional | Stored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as. |
| platform | string | optional | macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy. |
| cpuCores | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| memoryBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| diskTotalBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| diskFreeBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| osUpdateAvailable | boolean | optional | Also read: the text true or false, 1 or 0. |
| inventory | object | optional | Free-form: any keys, stored as sent. An installedSoftware list of { name, version, publisher, bundleId } entries is kept as the software inventory, at most 2,000 entries. |
AgentEnrollReplyin a reply1 field
The reply to an enrolment.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| deviceId | integer | no | The id to send in the x-zx-device-id header of every signed call. |
AgentHeartbeatRequestrequest body18 fields
The signed check-in. No key is required: the device is named by the signed headers, and the signature is over the exact bytes sent.
| Field | Type | Required | Note |
|---|---|---|---|
| hostname | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| osVersion | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| osBuild | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| architecture | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| chip | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| modelName | string | optional | A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored. |
| agentVersion | string | optional | The version of the agent that is running. The check-in offers an update only when a newer version is the target. |
| loggedInUser | string | optional | Stored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as. |
| platform | string | optional | macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy. |
| cpuCores | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| memoryBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| diskTotalBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| diskFreeBytes | integer | optional | A whole number, zero or more, or the same written as digits. Any other value is ignored. |
| osUpdateAvailable | boolean | optional | Also read: the text true or false, 1 or 0. |
| inventory | object | optional | Free-form: any keys, stored as sent. An installedSoftware list of { name, version, publisher, bundleId } entries is kept as the software inventory, at most 2,000 entries. |
| capabilities | string[] | optional | What the agent supports, for example commands_v2 or enforcement_v1. At most 32 are read; each is lower-cased and cut to 64 characters. |
| commandResults | AgentCommandResult[] | optional | The outcome of commands delivered on an earlier check-in. The first 50 entries are read. |
| enforcement | AgentEnforcementReport | optional | Which policy the agent applied. |
AgentCommandResultrequest body3 fields
One entry of commandResults. An entry with an unknown status, or an id that is not a positive whole number, is skipped; a repeated id is read once.
| Field | Type | Required | Note |
|---|---|---|---|
| id | integer | required | The id of the command, as the check-in delivered it. |
| status | string: succeeded | failed | unsupported | required | |
| detail | string | optional | Cut to 500 characters. |
AgentEnforcementReportrequest body3 fields
The enforcement key of a check-in.
| Field | Type | Required | Note |
|---|---|---|---|
| policyHash | string | optional | The policyHash of the policy the agent applied, from an earlier reply. |
| applied | string[] | optional | The policy fields applied. At most 200 are read, each cut to 64 characters. |
| failed | object[] | optional | Each { field, error }: a policy field that could not be applied and why. At most 200 are read; field is cut to 64 characters and error to 200. |
AgentHeartbeatReplyin a reply9 fields
The reply to a check-in: the policy to apply, the commands to run and the update to install. It can carry further keys.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | boolean | no | Always true. |
| deviceId | integer | no | |
| command | string | nullable | One command for an agent that does not declare commands_v2: lock or uninstall. Null when nothing is waiting, and null when the same command is in commands. |
| trustBundle | object | nullable | The current list of release-signing keys, or null when the instance holds none. |
| policy | AgentPolicy | no | |
| update | AgentUpdate | nullable | Null unless a version newer than the agentVersion sent is the target for this device. |
| policyHash | string | no | SHA-256 of the policy object, 64 hex characters. Send it back in enforcement.policyHash. |
| commands | AgentCommand[] | no | Only for an agent that declares commands_v2. At most 20 per check-in, oldest first. Each is delivered once. |
| commandResultsAccepted | boolean | no | Only for an agent that declares commands_v2, and only when the request carried commandResults. False means they were not stored: send them again. |
AgentCommandin a reply3 fields
One entry of commands in the check-in reply.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | no | Send it back as the id of the AgentCommandResult. |
| type | string | no | The command, for example lock, uninstall, restart, update_os or rename. |
| args | object | nullable | The arguments of the command, for example { name } for rename. |
AgentUpdatein a reply5 fields
The agent version to install, in the update key of the check-in reply.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| version | string | no | |
| url | string | no | Where to download exactly this version. |
| sha256 | string | no | SHA-256 of the file, 64 hex characters. |
| sig | string | nullable | The release signature of this version. |
| kid | string | nullable | The id of the key that signature was made with. |
AgentPolicyin a reply22 fields
The fleet policy of the agent's platform, in the policy key of the check-in reply. A null field means: leave that setting as it is. The object carries further hardening keys of the same kind that are not listed here.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| allowUserQuit | boolean | no | |
| checkInIntervalMinutes | integer | no | |
| manageUpdates | boolean | no | |
| updateAutoCheck | boolean | nullable | Null unless manageUpdates is true. |
| updateAutoDownload | boolean | nullable | Null unless manageUpdates is true. |
| updateInstallSecurity | boolean | nullable | Null unless manageUpdates is true. |
| updateInstallMacOS | boolean | nullable | Null unless manageUpdates is true. |
| updateInstallApps | boolean | nullable | Null unless manageUpdates is true. |
| manageCompliance | boolean | no | |
| complianceFirewall | boolean | nullable | Null unless manageCompliance is true. |
| complianceDisableSSH | boolean | nullable | Null unless manageCompliance is true. |
| complianceDisableScreenSharing | boolean | nullable | Null unless manageCompliance is true. |
| complianceDisableGuest | boolean | nullable | Null unless manageCompliance is true. |
| complianceDisableAutoLogin | boolean | nullable | Null unless manageCompliance is true. |
| complianceGatekeeper | boolean | nullable | Null unless manageCompliance is true. |
| complianceRequireScreenLock | boolean | nullable | Null unless manageCompliance is true. |
| complianceScreenLockMinutes | integer | nullable | Null unless manageCompliance is true. |
| complianceAuditd | boolean | nullable | Null unless manageCompliance is true. |
| complianceTimeSync | boolean | nullable | Null unless manageCompliance is true. |
| complianceFail2ban | boolean | nullable | Null unless manageCompliance is true. |
| complianceDefender | boolean | nullable | Null unless manageCompliance is true. |
| complianceBitlocker | boolean | nullable | Null unless manageCompliance is true. |
AgentRecoveryKeyEscrowrequest body3 fields
A signed call. One key is kept per device and key type; a later call replaces it.
| Field | Type | Required | Note |
|---|---|---|---|
| keyType | string: bitlocker | filevault | required | Read in any letter case. |
| recoveryKey | string | required | |
| volumeId | string | optional | Cut to 255 characters. |
AgentMdmEnrollRedeemrequest body1 field
Exchanges an enrolment code for the profile to install. code is the only key read.
| Field | Type | Required | Note |
|---|---|---|---|
| code | string | required | The enrolment code: at least 6 letters or digits. |
Sign-in, MFA, passkeys and trusted devices
LoginRequestrequest body3 fields
The body of the password sign-in.
| Field | Type | Required | Note |
|---|---|---|---|
| username | string | required | The account email. At most 255 characters. |
| password | string | required | At most 1024 characters. |
| consentAccepted | boolean | optional | true accepts the current terms in the same call, when the first answer was a LoginTermsChallenge. |
SessionGrantin a reply2 fields
A session was opened. Answered by the password sign-in, the session refresh, the step-up, the magic-link redemption, the terms acceptance, both passkey sign-ins and the passkey step-up. The gateway also sets the same token as the session cookie.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| auth | boolean | no | Always true. |
| token | string | no | The session token (a JWT). Send it as Authorization: Bearer, or rely on the cookie. |
LoginTermsChallengein a reply4 fields
Answered with status 200 instead of a SessionGrant when the current terms are not yet accepted. No session exists yet.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| termsAcceptanceRequired | boolean | no | Always true. This field tells the three challenge objects apart. |
| userId | integer | no | |
| termsVersion | string | no | The version of the terms to show. |
| termsToken | string | no | Present on the magic-link, passkey and single sign-on paths only: the single-use token the terms acceptance call takes, valid for 10 minutes. The password sign-in sends no token; repeat the sign-in with consentAccepted: true. |
LoginPasswordChangeChallengein a reply4 fields
Answered with status 200 instead of a SessionGrant when the account must choose a new password first.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| passwordChangeRequired | boolean | no | Always true. |
| userId | integer | no | |
| requirements | object | no | { minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account. |
| passwordChangeToken | string | no | The single-use token the password change call takes as its Bearer token. |
LoginMfaChallengein a reply5 fields
Answered with status 200 instead of a SessionGrant when a second factor is still owed.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| mfaRequired | boolean | no | Always true. |
| mfaStatus | string | no | setup_pending (no factor enrolled yet: enrol one with the token) | challenge_pending (answer with an enrolled factor). |
| userId | integer | no | |
| mfaToken | string | no | A short-lived token for the enrolment and verification calls. It is not a session. |
| mfaMethods | object | no | Present with challenge_pending only: { passkey: boolean, totp: boolean }, which factors the account has enrolled. |
CurrentUserin a reply11 fields
The user object inside the reply of the signed-in user read: { auth: true, user }.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| username | string | no | The account email. |
| firstName | string | nullable | |
| lastName | string | nullable | |
| roles | string[] | no | Role names, held directly or through a group. |
| groups | string[] | no | Group names. |
| permissions | string[] | no | Permission names, held directly, through a role or through a group. |
| mfaEnabled | boolean | no | The MFA flag stored on the account. |
| mfaVerified | boolean | no | Whether this session proved a second factor. |
| mfaRequired | boolean | no | Whether the account is required to use one. |
| supportSession | SupportSessionInfo | no | Present only in a support-access session. |
SupportSessionInfoin a reply4 fields
Marks a session as a time-boxed support-access session.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| grantId | string | nullable | |
| scope | string | no | read_only | role |
| roleName | string | nullable | The role granted. Null for read_only. |
| expiresAt | string (date-time) | no |
PasswordChangeResultin a reply3 fields
The reply to a forced password change. It opens no session: sign in again afterwards.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| success | boolean | no | Always true. |
| message | string | no | |
| devicesRevoked | integer | no | How many trusted devices of the account were revoked by the change. |
InviteInspectionin a reply5 fields
Who a set-password invitation is for. Status 410 with { error } when the token is unknown, used or expired.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| valid | boolean | no | Always true. |
| string | no | ||
| firstName | string | no | Empty string when the account has none. |
| expiresAt | string (date-time) | no | |
| policy | object | no | { minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account. |
InviteAcceptRequestrequest body2 fields
Sets the password of the invited account. The reply is { success: true, email } and opens no session.
| Field | Type | Required | Note |
|---|---|---|---|
| token | string | required | The invitation token. At most 128 characters. |
| password | string | required | At most 1024 characters, and it must meet the policy the inspection returned. |
TotpSetupin a reply2 fields
The reply to starting authenticator-app enrolment. The secret is pending until it is confirmed with a code.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| secret | string | no | The shared secret, base32. |
| otpauth_url | string | no | The same secret as an otpauth:// address, for a QR code. |
MfaEnrolmentResultin a reply3 fields
The reply to confirming an authenticator app. Registering a passkey answers verified: true in place of auth, with recoveryCodes under the same rule.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| auth | boolean | no | Always true. |
| token | string | no | A session token that carries the proven second factor. |
| recoveryCodes | string[] | nullable | The recovery codes in clear, shown this once. Null when the account already had recovery codes. |
MfaVerifyRequestrequest body3 fields
Answers a LoginMfaChallenge with an authenticator-app code. The recovery-code call takes recoveryCode (string, required) in place of token.
| Field | Type | Required | Note |
|---|---|---|---|
| mfaToken | string | required | The mfaToken of the challenge. |
| token | string | required | The current code from the authenticator app. A code is accepted once. |
| rememberDevice | boolean | optional | true also registers this browser as a trusted device. |
MfaVerifyResultin a reply3 fields
The reply to a second factor answered with a code or a recovery code.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| auth | boolean | no | Always true. |
| token | string | no | The session token, also set as the session cookie. |
| trustedDeviceToken | string | nullable | The trusted-device token when rememberDevice was true, otherwise null. When it is set, the gateway also sets it as an httpOnly cookie. |
PasskeyRegistrationOptionsin a reply8 fields
The WebAuthn creation options to pass to the browser. The server passes no algorithm list and no timeout, so the WebAuthn library the instance uses decides pubKeyCredParams and timeout.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| challenge | string | no | base64url. Kept by the server until the registration is verified. |
| rp | object | no | { name, id }: the instance. |
| user | object | no | { id, name, displayName }: name and displayName are the account email. |
| pubKeyCredParams | object[] | no | Set by the library. |
| timeout | integer | no | Milliseconds. Set by the library. |
| attestation | string | no | none |
| excludeCredentials | object[] | no | The passkeys the account already has. |
| authenticatorSelection | object | no | residentKey: preferred, userVerification: required. |
PasskeyListin a reply3 fields
The signed-in user’s own second factors.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| credentials | object[] | no | Oldest first. Each { id: integer, transports: string[], createdAt: string (date-time) }. The public key is not included. |
| totpEnrolled | boolean | no | Whether an authenticator app is enrolled. |
| recoveryCodesRemaining | integer | no | Unused recovery codes. |
TrustedDevicein a reply7 fields
One remembered browser. The trusted-device read answers { devices: TrustedDevice[] }, most recently used first, every device in one reply.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| userAgent | string | nullable | |
| ipAddress | string | nullable | |
| lastUsedAt | string (date-time) | nullable | |
| expiresAt | string (date-time) | no | After this the browser is asked for a second factor again. |
| createdAt | string (date-time) | read-only | |
| current | boolean | no | True for the browser the call came from. |
FactorResetResultin a reply4 fields
The reply to an administrator resetting another user’s second factors. Revoking only their trusted devices answers { ok, email, revoked: integer }.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| ok | boolean | no | Always true. |
| string | no | The account that was reset. | |
| passkeysRemoved | integer | no | |
| devicesRevoked | integer | no |
PasswordChangeRequestrequest body1 field
The account is the one the password-change token names, never one named in the body. The token goes in the Authorization: Bearer header; a call without that header is answered 401.
| Field | Type | Required | Note |
|---|---|---|---|
| newPassword | string | required | Must not be empty, and must meet the password rules that apply to the account. |
StepUpRequestrequest body1 field
The session is the one in the Authorization: Bearer header.
| Field | Type | Required | Note |
|---|---|---|---|
| password | string | required | The password of the signed-in account. |
SignInLinkRequestrequest body1 field
Asks for a sign-in link by email.
| Field | Type | Required | Note |
|---|---|---|---|
| string | required | At most 255 characters. |
MfaSetupStartrequest body1 field
Every key is optional.
| Field | Type | Required | Note |
|---|---|---|---|
| mfaToken | string | optional | The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read. |
MfaSetupConfirmrequest body2 fields
Confirms an authenticator app with its first code.
| Field | Type | Required | Note |
|---|---|---|---|
| token | string | required | The code the authenticator app shows. |
| mfaToken | string | optional | The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read. |
MfaRecoveryVerifyrequest body3 fields
Completes a sign-in with a recovery code.
| Field | Type | Required | Note |
|---|---|---|---|
| mfaToken | string | required | The token the sign-in answered when it asked for a second factor. |
| recoveryCode | string | required | |
| rememberDevice | boolean | optional |
Single sign-on, SCIM, access reviews and requests, log forwarding, support access, and user, role and org-chart request bodies
SsoStatusin a reply4 fields
What the sign-in page reads before anyone is signed in. { enabled: false } alone when the identity service cannot be reached.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| enabled | boolean | no | |
| protocol | string | no | oidc | saml |
| preset | string | no | okta | entra | google | other. Present only while single sign-on is on. |
| smtpConfigured | boolean | no | Whether the instance can send email, and so offer the emailed sign-in link. |
SsoConfigin a reply9 fields
The single sign-on configuration as an administrator reads it and as a save answers it. The client secret is never returned.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| enabled | boolean | no | |
| protocol | string | no | oidc | saml. oidc when none is stored. |
| preset | string | no | okta | entra | google | other. other when none is stored. |
| issuer | string | no | Empty string when unset. |
| clientId | string | no | Empty string when unset. |
| clientSecretSet | boolean | read-only | Whether a client secret is stored. |
| saml | object | no | { idpEntityId: string, idpSsoUrl: string, certs: SsoCertificate[] } |
| jit | object | no | { enabled, allowedDomains, defaultRoleId, defaultGroupId }: creating an account on first sign-in. |
| mfaAcrValues | string[] | no | Empty array when unset. |
SsoCertificatein a reply4 fields
A summary of one identity-provider signing certificate. The certificate itself is not returned by the configuration read.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| subject | string | no | unparseable when the stored certificate cannot be read. |
| notAfter | string | nullable | The expiry as the certificate states it. |
| fingerprint256 | string | nullable | SHA-256 fingerprint. |
| expired | boolean | no |
SsoPendingStepin a reply8 fields
The step a single sign-on still owes. Only the fields of that step are present: the fields of a LoginMfaChallenge, or of a LoginTermsChallenge with its termsToken.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| mfaRequired | boolean | no | |
| mfaStatus | string | no | setup_pending | challenge_pending |
| userId | integer | no | |
| mfaToken | string | no | |
| mfaMethods | object | no | { passkey: boolean, totp: boolean } |
| termsAcceptanceRequired | boolean | no | |
| termsToken | string | no | |
| termsVersion | string | no |
ScimUserin a reply12 fields
A SCIM 2.0 User as the instance returns it. A field marked "when set" is left out, not sent as null.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| schemas | string[] | no | The core User schema and the enterprise User extension. |
| id | string | read-only | The Zonitix user id, as a string. |
| userName | string | no | The userName the identity provider sent, otherwise the account email. |
| externalId | string | no | When set. |
| name | object | no | { givenName, familyName, formatted }: empty strings when the account has no name. |
| displayName | string | no | First and last name. Left out when both are empty. |
| emails | object[] | no | One entry: { value, type: "work", primary: true }. |
| active | boolean | no | False for an account that is offboarded, disabled, inactive, terminated, archived, deleted or suspended. True for every other status. |
| title | string | no | When set. |
| phoneNumbers | object[] | no | When set: one entry { value, type: "work" }. |
| urn:ietf:params:scim:schemas:extension:enterprise:2.0:User | object | no | Any of department, employeeNumber and manager: { value }, each when set. An empty object otherwise. |
| meta | object | read-only | { resourceType: "User", created, lastModified, location } |
ScimUserWriterequest body9 fields
What the instance reads from a SCIM User on create, replace and patch. Other attributes are accepted and ignored. Only the attributes present are changed.
| Field | Type | Required | Note |
|---|---|---|---|
| userName | string | optional | At most 320 characters. On create the sign-in email is the value of the chosen emails entry or, when there is none, a userName that is an email address; with neither the call answers 400. It is stored in lower case. |
| externalId | string | optional | At most 255 characters. null clears it. |
| emails | object[] | optional | The entry marked primary is used; otherwise the entry of type work; otherwise the first entry with a value. |
| name | object | optional | { givenName, familyName } |
| displayName | string | optional | Split into first and last name only when no name object is sent. |
| title | string | optional | Stored as the position. |
| active | boolean | optional | |
| phoneNumbers | object[] | optional | The entry marked primary is used; otherwise the entry of type work, then mobile; otherwise the first entry with a value. At most 64 characters. |
| urn:ietf:params:scim:schemas:extension:enterprise:2.0:User | object | optional | department, employeeNumber (at most 64 characters) and manager (a user id, or { value }). |
ScimGroupin a reply6 fields
A SCIM 2.0 Group as the instance returns it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| schemas | string[] | no | The core Group schema. |
| id | string | read-only | The Zonitix group id, as a string. |
| displayName | string | no | The group name. |
| externalId | string | no | Only for a group that SCIM created and that has one. |
| members | object[] | no | Each { value: the user id as a string, display, $ref }. Left out when the request excludes members. |
| meta | object | read-only | { resourceType: "Group", created, lastModified, location } |
ScimListResponsein a reply5 fields
The envelope of the SCIM user and group lists. Paging is by startIndex (1-based, default 1) and count (default 100, at most 200).
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| schemas | string[] | no | The SCIM ListResponse schema. |
| totalResults | integer | no | Every match, not only this page. |
| startIndex | integer | no | |
| itemsPerPage | integer | no | The number of resources in this reply. |
| Resources | object[] | no | ScimUser or ScimGroup objects. |
ScimErrorin a reply4 fields
The body of every refused SCIM call.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| schemas | string[] | no | The SCIM Error schema. |
| status | string | no | The HTTP status, as a string. |
| detail | string | no | The reason. |
| scimType | string | no | Present on some refusals, for example invalidFilter, invalidValue, uniqueness or mutability. |
ScimTokenin a reply11 fields
One SCIM bearer token record. The token value is returned once, as token, by the call that creates it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| tokenPrefix | string | read-only | The first characters of the value, to recognise it by. |
| createdBy | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| lastUsedAt | string (date-time) | nullable, read-only | |
| revokedAt | string (date-time) | nullable, read-only | |
| revokedBy | string | nullable, read-only | |
| familyId | string | read-only | The connection the token belongs to. A token made with rotateFromId keeps the family of the one it replaces. |
| active | boolean | read-only | False once revoked. |
| owns | object | nullable, read-only | In the token list only: { users: integer, groups: integer } owned by the token’s connection. Null when the counts could not be read. |
ScimSettingsin a reply2 fields
The provisioning policy and the groups it can cover. A save answers the policy object alone.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| policy | object | no | { onboardOnCreate: boolean, allowGroupCreate: boolean, managedGroupIds: integer[], privilegedAllowedGroupIds: integer[] } |
| groups | object[] | no | Each { id, name, source, scimCreated: boolean, eligible: boolean, managed: boolean, privileged: boolean, confers: string[], privilegedAllowed: boolean }. |
AccessReviewCampaignin a reply16 fields
One access review. The campaign list is a bare array, newest first, every campaign that is not archived in one reply (?archived=1 lists the archived ones instead).
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| status | string | read-only | active | completed | archived |
| scopeType | string | no | all | department |
| scopeValue | string | nullable | The department, for a department scope. |
| createdBy | string | read-only | |
| dueDate | string (date) | nullable | |
| itemCount | integer | read-only | |
| decidedCount | integer | read-only | |
| revokeCount | integer | read-only | |
| completedAt | string (date-time) | nullable, read-only | |
| scheduleId | integer | nullable, read-only | The recurring schedule that launched it. |
| lastReminderAt | string (date-time) | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
AccessReviewCampaignCreaterequest body6 fields
Starts a review and creates its items.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| description | string | optional | |
| scopeType | string | optional | all (default) | department |
| scopeValue | string | optional | The department. Required for a department scope. |
| dueDate | string (date) | optional | |
| externalSource | string | optional | none | persona | actual | both. Default persona. |
AccessReviewItemin a reply26 fields
One piece of access to decide, as the campaign read and the reviewer queue return it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| campaignId | integer | read-only | |
| userId | integer | read-only | The person whose access it is. |
| userEmail | string | nullable, read-only | |
| reviewerEmail | string | no | Who is asked to decide. |
| accessType | string | read-only | role | group | app | license |
| accessId | integer | read-only | The role or group id. 0 for an application or licence. |
| accessLabel | string | nullable, read-only | |
| providerType | string | nullable, read-only | |
| runId | integer | nullable, read-only | The workflow run that carries out a revoke. |
| drift | boolean | read-only | |
| decision | string | no | pending | keep | revoke |
| decidedBy | string | nullable, read-only | |
| decidedAt | string (date-time) | nullable, read-only | |
| note | string | nullable | |
| revoked | boolean | read-only | |
| reroutedFrom | string | nullable, read-only | The reviewer the item was moved away from. |
| reroutedAt | string (date-time) | nullable, read-only | |
| rerouteReason | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only | |
| campaignName | string | nullable, read-only | |
| campaignDueDate | string (date) | nullable, read-only | |
| classification | string | read-only | directory (a role or a group) | blueprint (an application the person’s persona expects) | drift (an application outside it). |
| permissionCount | integer | nullable, read-only | How many permissions the role or group carries. Null for an application or licence. |
| privileged | boolean | read-only | True for the administrator role, for a role or group carrying any manage_ permission, and for one carrying 15 or more permissions. |
AccessReviewCampaignDetailin a reply5 fields
One campaign with a page of its items, ordered by person then access type. Paged by ?limit (default 200, 1 to 500) and ?offset.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| campaign | AccessReviewCampaign | no | |
| items | AccessReviewItem[] | no | |
| total | integer | no | Every item of the campaign. |
| limit | integer | no | The limit applied. |
| offset | integer | no |
AccessReviewQueuein a reply6 fields
The pending items assigned to the caller in active campaigns. Paged by ?limit (default 50, 1 to 200) and ?offset.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| items | AccessReviewItem[] | no | |
| total | integer | no | Every item matching the filter. |
| limit | integer | no | |
| offset | integer | no | |
| filter | string | no | The filter applied. all when the one sent is not recognised. |
| stats | object | no | { all, attention, expected, privileged, drift, people }, each an integer. |
AccessGrantDetailin a reply7 fields
The permissions behind one role or group. For any other access type only accessType, accessId, external: true, permissions: null, permissionCount: null and privileged: false are sent.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| accessType | string | nullable | role | group, or the value sent for any other type. Null when none was sent. |
| accessId | integer | nullable | Null when none was sent. |
| accessLabel | string | nullable | The role or group name. |
| external | boolean | no | False for a role or group. |
| permissions | string[] | nullable | Permission names, sorted. |
| permissionCount | integer | nullable | |
| privileged | boolean | no |
AccessReviewSchedulein a reply17 fields
A recurring review. The schedule list is a bare array, newest first, every schedule in one reply.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| scopeType | string | no | all | department |
| scopeValue | string | nullable | |
| externalSource | string | no | none | persona | actual | both. Default both. |
| cadence | string | no | monthly | quarterly (default) | semiannual | annual | custom |
| intervalMonths | integer | nullable | For a custom cadence. |
| dueDays | integer | no | Default 14. |
| status | string | no | active | paused |
| remind | boolean | no | Default true. |
| autoClose | boolean | no | Default true. |
| skipIfOpen | boolean | no | Default false. |
| nextRunAt | string (date-time) | nullable | |
| lastRunAt | string (date-time) | nullable, read-only | |
| createdBy | string | read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
LiveAccessSnapshotin a reply12 fields
Current access across the scope, computed on each call. Not paged: at most 2000 items are returned and capped says when the list was cut.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| generatedAt | string (date-time) | no | |
| scopeType | string | no | all | department |
| scopeValue | string | nullable | |
| externalSource | string | no | none | persona | actual | both |
| userCount | integer | no | |
| total | integer | no | Items before the cut. |
| driftCount | integer | no | |
| privilegedCount | integer | no | |
| providersActive | integer | no | |
| externalScanned | boolean | no | True when actual usage was read (externalSource actual or both). |
| capped | boolean | no | |
| items | object[] | no | Each { userId, userEmail, department, accessType, accessId, accessLabel, providerType, drift, classification, permissionCount, privileged, liveKey }. |
AccessRequestin a reply15 fields
One request for an application or licence. Both request lists are bare arrays, newest first, every matching request in one reply; a list entry can carry further fields describing the person.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| userId | integer | no | The person the access is for. |
| requestedBy | string | read-only | |
| itemKind | string | no | Default app. |
| itemLabel | string | no | |
| providerType | string | nullable | |
| justification | string | nullable | |
| customFields | object | nullable | Values keyed by custom-field key. |
| status | string | read-only | pending | approved | fulfilled | declined | rejected | failed |
| approverEmail | string | nullable, read-only | |
| decisionComment | string | nullable | |
| decidedAt | string (date-time) | nullable, read-only | |
| runId | integer | nullable, read-only | The workflow run that provisions an approved request. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
LogForwardDestinationin a reply26 fields
One log-forwarding destination. The secret is never returned. The destination read answers { destinations, types, datadogSites, health }, every destination in one reply, id ascending.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | At most 80 characters. |
| type | string | no | webhook | splunk_hec | datadog | syslog_tls. Cannot be changed after creation. |
| typeLabel | string | read-only | |
| enabled | boolean | no | |
| url | string | nullable | |
| port | integer | nullable | |
| config | object | no | Type-specific settings. Empty object when none. |
| secretSet | boolean | read-only | Whether a secret is stored. |
| cursorSeq | integer | nullable, read-only | The sequence number of the last audit event the destination acknowledged. |
| lastAckAt | string (date-time) | nullable, read-only | |
| lastAttemptAt | string (date-time) | nullable, read-only | |
| lastError | string | nullable, read-only | |
| lastErrorAt | string (date-time) | nullable, read-only | |
| consecutiveFailures | integer | read-only | |
| nextAttemptAt | string (date-time) | nullable, read-only | |
| totalSent | integer | read-only | |
| skippedCount | integer | read-only | |
| lastSkip | object | nullable, read-only | |
| gapCount | integer | read-only | |
| lagEvents | integer | nullable, read-only | In the list only: audit events not yet acknowledged. |
| lagSeconds | integer | nullable, read-only | In the list only. |
| createdBy | string | nullable, read-only | |
| updatedBy | string | nullable, read-only | |
| createdAt | string (date-time) | nullable, read-only | |
| updatedAt | string (date-time) | nullable, read-only |
LogForwardingHealthin a reply9 fields
The health block of the destination read. { error } alone when the state cannot be read.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| configured | integer | no | Destinations that exist. |
| enabled | integer | no | Destinations switched on. |
| failing | integer | no | Enabled destinations whose last attempt failed. |
| stale | integer | no | Enabled destinations with a backlog and no acknowledgement for 15 minutes. |
| maxLagEvents | integer | no | |
| maxLagSeconds | integer | no | |
| degraded | boolean | no | True when any destination is failing or stale. |
| lastTick | object | nullable | { at: string (date-time), status: worked | idle | failed, summary }: the last delivery sweep. Null before the first one. |
| destinations | object[] | no | Each { id, type, enabled, lagEvents, lagSeconds, consecutiveFailures, lastAckAt, skipped, gaps, stale }. |
SupportAccessStatusin a reply7 fields
The state of vendor support access. No credential is ever part of it.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| active | boolean | no | |
| grant | SupportAccessGrant | nullable | The grant in force. Null when none is active. |
| account | object | nullable | { email, status, mfaEnrolled: boolean, invitePending: boolean, lastActiveAt }. Null when no support account exists. |
| history | object[] | no | Up to 20 ended grants, newest first: each a SupportAccessGrant plus endedAt, endReason (revoked | expired) and endedBy. |
| limits | object | no | { minHours: 1, maxHours: 72 } |
| readOnlyPermissions | string[] | no | The permissions a read_only grant carries. |
| supportMailbox | string | no | The address invitations are suggested for. Empty string when none is saved. |
SupportAccessGrantin a reply10 fields
One grant of support access. Granting answers { grant, invite }, where invite is { sent: boolean, expiresAt?, reason?, link? }.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| grantId | string | read-only | A UUID. |
| scope | string | no | read_only | role |
| roleName | string | nullable | The role granted. Null for read_only. |
| grantedBy | string | read-only | |
| grantedAt | string (date-time) | read-only | |
| expiresAt | string (date-time) | read-only | |
| hours | integer | no | 1 to 72. |
| reason | string | no | 5 to 500 characters. |
| string | no | The support address the invitation went to. | |
| administratorAcknowledged | boolean | read-only | True when the role granted is administrator. |
ApiTokenAdminRevokerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | optional | Cut to 200 characters and stored as revokeReason. Without it the record says "Revoked by an administrator". |
ScimTokenIssuerequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| rotateFromId | integer | optional | The id of the token this one replaces, so it keeps that connection’s users and groups. 404 when no token has that id. |
ScimTokenRebindrequest body2 fields
Send one of the two keys.
| Field | Type | Required | Note |
|---|---|---|---|
| fromTokenId | integer | optional | Move the users and groups of this token’s connection to the token in the path. |
| unowned | boolean | optional | True moves the users and groups that belong to no connection. |
AccessReviewDecisionrequest body2 fields
The bulk call takes the same keys plus ids (1 to 500 item ids).
| Field | Type | Required | Note |
|---|---|---|---|
| decision | string: keep | revoke | required | |
| note | string | optional |
AccessReviewReassignrequest body1 field
The bulk call takes the same key plus ids (1 to 500 item ids).
| Field | Type | Required | Note |
|---|---|---|---|
| reviewerEmail | string | required | An active user, and not the person whose access the item is. |
AccessReviewScheduleWriterequest body11 fields
The body of the schedule create call. The update call reads the same keys, all optional.
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | |
| cadence | string: monthly | quarterly | semiannual | annual | custom | optional | Default quarterly; an unknown value is read as quarterly. |
| intervalMonths | integer | optional | Custom cadence only. At least 1. |
| scopeType | string: all | department | optional | Default all. |
| scopeValue | string | optional | The department, for a department scope. |
| externalSource | string: none | persona | actual | both | optional | Default both. |
| dueDays | integer | optional | Held to 1 to 365. Default 14. |
| remind | boolean | optional | Default true. |
| autoClose | boolean | optional | Default true. |
| skipIfOpen | boolean | optional | Default false. |
| firstRunAt | string (date-time) | optional | Default now. |
LiveAccessRevokerequest body5 fields
| Field | Type | Required | Note |
|---|---|---|---|
| userId | integer | required | |
| accessType | string | required | |
| accessLabel | string | required | |
| accessId | integer | optional | Required to revoke a role or a group. |
| providerType | string | optional |
AccessRequestCreaterequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| itemLabel | string | required | What is being asked for. |
| itemKind | string | optional | Default app. |
| providerType | string | optional | |
| justification | string | optional | |
| customFields | object | optional | Values keyed by custom field key. Only keys in the custom field list are kept, 500 characters each. |
| userId | integer | optional | Who the request is for. Honoured only for an approver or a holder of manage_users; otherwise the request is for the caller. |
AccessRequestDecisionrequest body2 fields
The bulk call takes the same keys plus ids (1 to 500 request ids).
| Field | Type | Required | Note |
|---|---|---|---|
| approve | boolean | optional | True approves. Anything else, or leaving it out, rejects. |
| comment | string | optional |
AccessRequestFieldsWriterequest body1 field
Replaces the whole list of custom fields on the request form.
| Field | Type | Required | Note |
|---|---|---|---|
| fields | object[] | optional | Left out, or not an array, the list is emptied (200, not 400). At most 20 are kept. Each { key, label (cut to 60 characters), type: text | select | number | date, options (select only: up to 50) }. A malformed entry is skipped, not refused. |
DepartmentCreaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required | At most 100 characters. |
UserOffboardrequest body3 fields
All keys are optional.
| Field | Type | Required | Note |
|---|---|---|---|
| lastDay | string | optional | The person’s last day. |
| immediate | boolean | optional | True runs the revoke steps at once, and then reason is required. |
| reason | string | optional | At least 5 characters when immediate is true. |
UserOnboardrequest body3 fields
All keys are optional.
| Field | Type | Required | Note |
|---|---|---|---|
| personaId | integer | optional | |
| startDate | string | optional | |
| managerId | integer | optional |
DottedReportsUpdaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| managerIds | integer[] | optional | Replaces the whole set. Repeats and the person’s own id are dropped; at most 10; each must be an active user. Left out, or not an array, the set is emptied. |
RolePermissionsReplacerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| permissionIds | integer[] | required | Replaces the role’s permission set. |
OrgSnapshotCreaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | required |
PasswordExpiryBatchrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| userIds | integer[] | required | 1 to 500 user ids. Repeated ids are counted once. |
SupportAccessGrantRequestrequest body6 fields
| Field | Type | Required | Note |
|---|---|---|---|
| hours | integer | required | A whole number from 1 to 72. |
| string (email) | required | The support address the invitation goes to. At most 254 characters. | |
| reason | string | required | 5 to 500 characters after trimming. Recorded in the audit log. |
| scope | string: read_only | role | optional | Default read_only. |
| roleName | string | optional | Required for scope role. |
| acknowledgeAdministrator | boolean | optional | Must be true when roleName is administrator. |
SupportMailboxUpdaterequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| string | optional | A valid address of at most 254 characters. Empty, null or left out clears the stored address. |
Security settings, identity hooks, cloud maps, local AI models, notifications, and pipeline and run request bodies
PasswordPolicyin a reply7 fields
One password policy, as an entry of passwordPolicies in the security settings. The same shape is sent back on a save, where the array replaces the stored one.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | no | default is the organisation-wide policy. Any other id is a policy scoped to groups. |
| name | string | no | |
| minLength | integer | no | 8 to 128. |
| requireUppercase | boolean | no | |
| requireNumbers | boolean | no | |
| requireSymbols | boolean | no | |
| groupIds | integer[] | no | The groups it applies to. Empty on default. |
SecuritySettingsin a reply34 fields
The security settings read. The eight fields marked "restricted" are sent only to a caller with manage_security or the administrator role. The SMTP password is never returned. A reply carries further fields beyond these.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| mfaEnabled | boolean | no | The organisation-wide default that groups set to inherit follow. |
| passwordPolicies | PasswordPolicy[] | no | |
| passwordHistoryCount | integer | no | 0 to 24. |
| namingConvention | string | no | [first_name].[last_name] when none is stored. |
| emailDomain | string | no | Empty string when unset. |
| conflictStrategy | string | no | numeric when none is stored. |
| sessionTimeout | string | no | A duration such as 1h, which is the value when none is stored. 5m to 24h. |
| auditLogRetention | object | no | { days, source, minDays, maxDays }: the retention in force and its limits. |
| auditLogRetentionDays | integer | no | 90 to 3650. |
| workflowRequireSeparateApprover | boolean | no | True unless switched off. |
| workflowRequireSeparateRunApprover | boolean | no | True unless switched off. |
| reportRecipientPolicy | string | no | open when none is stored. |
| reportRecipientDomains | string | no | Empty string when unset. |
| awsMappingEnabled | boolean | no | A module switch. False when never set. |
| azureMappingEnabled | boolean | no | A module switch. |
| gcpMappingEnabled | boolean | no | A module switch. |
| securityLabEnabled | boolean | no | A module switch. |
| complianceEnabled | boolean | no | A module switch. |
| reportingEnabled | boolean | no | A module switch. |
| financeEnabled | boolean | no | A module switch. |
| activeAiModel | string | no | The model the local AI uses. Always one on the allow-list. |
| aiModelRouting | object | no | Empty object when unset. |
| aiEnabled | boolean | no | True unless switched off. |
| aiRequireUsePermission | boolean | no | False unless switched on. |
| smtpConfigured | boolean | no | True when a mail host, user and password are all stored. |
| branding | object | no | { companyName: string, logoUrl: string or null, primaryBrandColor: string } |
| itNotificationRecipients | string | no | Restricted. |
| hrNotificationRecipients | string | no | Restricted. |
| smtpHost | string | no | Restricted. |
| smtpPort | integer | no | Restricted. |
| smtpUser | string | no | Restricted. |
| smtpFrom | string | no | Restricted. |
| smtpSecure | boolean | no | Restricted. |
| smtpPassSet | boolean | no | Restricted. Whether a password is stored. |
AccessControlRulesin a reply9 fields
The network access rules: what is stored, laid over the defaults. A reply carries further fields beyond these.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| enabled | boolean | no | Default false. |
| mode | string | no | allowlist | blocklist. Default blocklist. |
| countries | string[] | no | Default empty. |
| blockedIps | string[] | no | Default empty. |
| allowedIps | string[] | no | Default empty. |
| blockUnknownGeo | boolean | no | Default false. |
| dryRun | boolean | no | Default false. |
| autoBlock | object | no | { enabled (default false), dryRun (default true), threshold (default 6), windowSec (default 600), banSec (default 21600), patterns, tuning } |
| geoDbLoaded | boolean | read-only | Added by the gateway: whether its offline country database loaded. Country rules do nothing when false. |
HookProviderin a reply21 fields
One identity hook. Secret values are removed from config and their names listed in configuredCredentials. The hook list is a bare array, every hook in one reply. A reply carries further fields beyond these.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| name | string | no | |
| description | string | nullable | |
| type | string | no | The kind of system, for example OKTA or AWS. |
| config | object | no | The connection settings, without secret values. |
| configuredCredentials | string[] | read-only | The names of the secrets that are set. |
| isActive | boolean | no | Default true. |
| canReadUsers | boolean | no | Default false. |
| canReadGroups | boolean | no | Default false. |
| canWriteUsers | boolean | no | Default false. |
| canWriteGroups | boolean | no | Default false. |
| canReadAllResources | boolean | no | Default false. |
| capabilitiesSetAt | string (date-time) | nullable, read-only | |
| isVerified | boolean | read-only | Set by the validation call. |
| lastSync | string (date-time) | nullable, read-only | |
| lastValidation | object | nullable, read-only | The result of the last validation. |
| lastValidatedAt | string (date-time) | nullable, read-only | |
| syncCount | integer | read-only | |
| syncInterval | integer | no | Default 24. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
ProviderSyncLogin a reply8 fields
One sync of one hook. The log read is a bare array of the 10 most recent, newest first.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| providerId | integer | read-only | |
| syncType | string | nullable | |
| status | string | nullable | |
| summary | string | nullable | |
| details | object[] | no | Empty array when none. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
IdentityAlignmentLogin a reply10 fields
One alignment action, as an entry of the alignment history. The route adds readable source labels to each entry.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | A UUID. This is the id the rollback call takes. |
| actionType | string | read-only | |
| targetEmails | string[] | read-only | |
| sourceOfTruth | string | read-only | |
| priorState | object | read-only | The values before the change. |
| newState | object | read-only | The values after it. |
| status | string | read-only | SUCCESS when written without another value. |
| adminUser | string | read-only | Who ran it. |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
AlignmentChangeRequestin a reply16 fields
A bulk alignment waiting for, or past, approval. The route adds readable source labels to each entry.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| status | string | read-only | pending when created. |
| requestedBy | string | read-only | |
| reason | string | nullable | |
| sourceOfTruth | string | no | |
| targets | string[] | no | |
| emails | string[] | no | |
| userCount | integer | read-only | |
| approverEmail | string | nullable, read-only | |
| decisionComment | string | nullable | |
| decidedAt | string (date-time) | nullable, read-only | |
| appliedAt | string (date-time) | nullable, read-only | |
| result | object | nullable, read-only | |
| error | string | nullable, read-only | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
CloudResourceMapin a reply5 fields
The stored resource map of one cloud hook. The same shape serves the three clouds.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| providerId | integer | no | The hook the map belongs to. |
| nodes | object[] | no | The resources found. |
| edges | object[] | no | The relations between them. |
| billingData | object | nullable | |
| lastUpdated | string (date-time) | no | When the map was last discovered. |
CloudSnapshotin a reply7 fields
One dated copy of a resource map. The snapshot list sends id, snapshotDate and metadata only; the detail read sends every field.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | string | read-only | A UUID. |
| providerId | integer | read-only | |
| snapshotDate | string (date-time) | read-only | |
| metadata | object | no | Empty object when none. |
| nodes | object[] | no | Detail read only. |
| edges | object[] | no | Detail read only. |
| billingData | object | nullable | Detail read only. |
AiModelStatusin a reply7 fields
Whether one local AI model is downloaded and loaded.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| model | string | no | The model asked about. |
| pulled | boolean | no | Whether it is downloaded. |
| ready | boolean | no | Whether it is loaded and can answer. |
| state | string | no | ready | loading | pulling | engine_unreachable |
| reachable | boolean | no | Whether the model engine answered. |
| previousResident | boolean | nullable | Whether the model named by ?previous is still in memory. Null when the engine is unreachable. |
| consumers | object[] | no | The features that use the local AI, each { key, label, detail, permission }. |
AiModelCapabilityin a reply3 fields
Which models this server can run.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| box | object | no | { cores, ramGB, gpu, cpuParamCap }: the server as the AI service sees it. |
| models | object[] | no | Each { id, label, sizeB, ramGB, fits: boolean, reason }. |
| defaultModel | string | no |
Notificationin a reply9 fields
One entry of the signed-in user’s own notification feed. The feed is a bare array of at most 50: unread entries first, newest first; then read entries in the order they were read, earliest first. It takes no paging parameter.
| Field | Type | Null / read-only | Note |
|---|---|---|---|
| id | integer | read-only | |
| userId | integer | read-only | Always the caller. |
| type | string | no | info when none was set. |
| message | string | no | |
| link | string | nullable | Where in the app the notification points. |
| readAt | string (date-time) | nullable | Null while unread. |
| candidateId | integer | nullable | |
| createdAt | string (date-time) | read-only | |
| updatedAt | string (date-time) | read-only |
WorkflowRejectrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| reason | string | required | Not empty. |
WorkflowBulkDecisionrequest body3 fields
| Field | Type | Required | Note |
|---|---|---|---|
| ids | integer[] | required | 1 to 200 workflow ids. A repeated id is processed once. |
| action | string: promote | reject | required | |
| reason | string | optional | Required, and not empty, when action is reject. |
WorkflowClonerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| name | string | optional | The name of the copy, trimmed to 120 characters. Left out, the copy is named after its source with " (copy)" added. |
WorkflowRetirerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| retired | boolean | optional | True retires the pipeline. Anything else, or leaving it out, reinstates it. |
WorkflowRunRecallrequest body2 fields
| Field | Type | Required | Note |
|---|---|---|---|
| targetNodeId | string | required | The id of a human step the run has already passed. |
| reason | string | optional |
WorkflowNodeExecuterequest body2 fields
Only these two keys are read. The action, provider and target come from the run’s stored steps.
| Field | Type | Required | Note |
|---|---|---|---|
| nodeId | string | required | The id of the step to run. |
| taskData | object | optional |
WorkflowRunSchedulerequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| startDate | string (date) | optional, nullable | A real YYYY-MM-DD date. Null, or leaving the key out, clears it. |
CredentialRevealrequest body1 field
| Field | Type | Required | Note |
|---|---|---|---|
| nodeId | string | required | The id of the step that created the account. |
Request & Response Schemas (Common Write Paths)
Many write endpoints in the catalogue below carry an inline body/returns/guard line. This section goes one level deeper for the write paths an operator reaches for most: a worked curl, the full field list with its conditional requirements, and the exact refusal an inspector should assert. Every field, gate, status and invariant below was read out of the platform’s controllers — not inferred from the path. Fields are camelCase JSON; send Content-Type: application/json. The examples authenticate with a personal API token held in $ZX_TOKEN; a browser session cookie works on the same routes. The two exceptions are marked: MFA enrolment and anything that needs a sudo step-up are session-only, because a token cannot perform a step-up.
A Green 2xx Is Not The Whole Story
Most of these routes fail closed with a 403 and a distinct audit line when a guard trips — privilege-escalation, segregation-of-duties, whitelist and sanctions checks all live in the controller, not the client. When you verify a control, assert the refusal path returns the documented status, not only that the happy path returns 200/201.
Create A User — POST /user-management/users
Gate: manage_users. Attaching roleIds or groupIds at creation additionally requires manage_roles, and those roles are still held to the no-escalation subset invariant below. Every new user is unconditionally added to the Everyone group and given the standard_user role, regardless of what you send.
- Body fields (all optional to the parser except as noted):
firstName,lastName,email,sAMAccountName,password,position,department,managerId,status(defaultactive),source(defaultlocal),employeeId,phoneNumber,attributes,roleIds,groupIds,personaId. - The one hard-required field is conditional:
passwordis required only whensourceis explicitly"local"(else400“Password is required for local users (or send an invitation instead).”, unless the body carriessendInvite: true). A user created without a password gets an unusable random hash andmustChangePassword: true— the account exists but cannot be logged into until a credential is issued.
// Minimal local user (manage_users). 201 returns the created user.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/user-management/users \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ZX_TOKEN" \
-d '{ "firstName": "Dana", "lastName": "Okoro",
"email": "dana@company.cc", "source": "local",
"password": "S3cur3P@ss!", "department": "Finance" }'
// 201 Created → the user object (id, email, status, …)
Guard — No-Escalation Subset
roleId confers a permission the caller does not themselves hold, the whole create is refused 403 “Privilege escalation blocked” and a ROLE_ESCALATION_BLOCKED audit line is written. A delegated user-admin cannot mint an administrator.Guard — Unique Login
sAMAccountName returns 400 naming the current holder; a password that fails the configured policy returns 400 with the policy reason. Both roll the transaction back — no partial user is left behind.Grant Permissions — Role Side And Permission Side
The same role/permission join is writable from two directions, and both carry the identical no-escalation invariant: you cannot grant a permission you do not hold (resolved from the database, not the request header; administrator bypasses by design). The administrator role always ends up holding every permission regardless of the body you send.
- Role side —
POST /user-management/roles/:roleId/permissions(gatemanage_roles). Body{ permissionIds: number[] }— replaces the role’s permission set. Response{ ok: true }. - Permission side —
POST /user-management/permissions/:id/assignments(gatemanage_permissions). Body{ roleIds?: number[], userIds?: number[] }— sets which roles and users hold this one permission. DirectuserIdsgrants land in the JWT, so this side is guarded too.
// Replace a role's permission set (manage_roles).
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/user-management/roles/14/permissions \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ZX_TOKEN" \
-d '{ "permissionIds": [3, 7, 12] }'
// 200 { "ok": true } — or 403 "Privilege escalation blocked"
// "You cannot grant permissions you do not hold: manage_finance"
Enrol TOTP MFA — Init Then Confirm
Two calls, both on a Session (a full session or an in-flight MFA-challenge token) — session-only: a personal API token is refused on the MFA routes. init stages a pending secret; confirm promotes it to the live factor only once a real code proves it. An account that already has MFA cannot be re-enrolled from a session that has not itself passed MFA (the second call returns the denial status/code from the enrollment guard).
POST /auth/mfa/setup/init— body optional{ mfaToken }(else theAuthorization: Bearertoken is used). Returns{ secret, otpauth_url }for the QR / manual entry.POST /auth/mfa/setup/confirm— body{ token }is required (the 6-digit code; missing →400“MFA code is required”). A wrong code reaches the caller as400{ error: 'Invalid token', correlationId }.
// 1) init — stage a pending secret
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/mfa/setup/init \
-H 'Authorization: Bearer <session-jwt>' # your session token, not a zx_ API token
// 200 { "secret": "JBSWY3DP…", "otpauth_url": "otpauth://totp/Zonitix:you@company.cc?…" }
// 2) confirm — prove the code; promotes the factor and (first time only) mints recovery codes
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/mfa/setup/confirm \
-H 'Authorization: Bearer <session-jwt>' -H 'Content-Type: application/json' \
-d '{ "token": "482915" }'
// 200 { "auth": true, "token": "<new-mfa-verified-jwt>", "recoveryCodes": ["…", …] }
Recovery Codes Are Minted Once
recoveryCodes is populated only when the account had none. Adding TOTP alongside an existing factor returns recoveryCodes: null and does not void codes you already printed. To deliberately reissue, call POST /auth/mfa/recovery-codes/regenerate — which invalidates the old set.
License Provisioning Is Retired — POST /user-management/license/provision
Activation is automatic; there is no key to submit. The route still answers, behind manage_security, but every call returns 410 with { "error": "Manual provisioning is disabled. This instance is activated automatically by the store." }, ignores the body, and is audited as LICENSE_PROVISION_REJECTED. See License Administration.
Create A Provider Hook — POST /user-management/providers
Gate: manage_hooks. Only an explicit allowlist of fields is persisted — name, description, type, config, isActive, syncInterval, and the scope flags canReadUsers / canReadGroups / canWriteUsers / canWriteGroups / canReadAllResources. Anything else in the body is ignored.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/user-management/providers \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ZX_TOKEN" \
-d '{ "name": "Corp Azure AD", "type": "azure",
"config": { "tenantId": "…", "clientId": "…", "clientSecret": "…" },
"canReadUsers": true, "canReadGroups": true }'
// 201 → the provider, with config secrets stripped and replaced by a name-only list:
// { "id": 5, "name": "Corp Azure AD", "isVerified": false,
// "configuredCredentials": ["clientSecret"], … }
Invariant — Trust Is Earned, Not Asserted
isVerified is server-side only. It is forced false on create and on any connection-config change, and set true only after a passing connection test — sending isVerified: true in the body has no effect. Credentials in config are encrypted at rest and never returned; responses expose only the names of the credential keys that are set.
Set Fleet Device Policy — PUT /user-management/devices/policy
Gate: manage_devices. Every field is optional and only the keys present are patched; platform selects which OS policy you are editing (macos default, or linux / windows) — each platform’s UI sends only its own keys, so there is no cross-platform clobber. The response is the resolved policy view; the change is audited as DEVICE_POLICY_UPDATED.
- Agent behaviour:
allowUserQuit(bool),checkInIntervalMinutes(int, clamped to 5–1440). - Self-update:
latestAgentVersion,agentPkgUrl(must behttps://— else400“agentPkgUrl must be https”),agentPkgSha256,agentPkgSig,agentPkgSigKid(any string field cleared by sending""). - OS updates: booleans
manageUpdates,updateAutoCheck,updateAutoDownload,updateInstallSecurity,updateInstallMacOS,updateInstallApps. - Endpoint hardening (macOS/Linux/Windows as applicable): booleans
manageCompliance,complianceFirewall,complianceDisableSSH,complianceGatekeeper,complianceRequireScreenLock,complianceDefender,complianceBitlockerand peers;complianceScreenLockMinutes(int, clamped 1–60). - Minimum versions:
minOsVersion(a dotted number such as14.5; anything else is400) andminAgentVersion(such as1.1.0; refused with400when it is above the adopted agent version). An empty string clears either. - After the move to profiles: once an instance keeps hardening and update settings in profiles only, a body that would change one of the OS-update or endpoint-hardening flags is refused with
409{ error, profilesOnly: true }; the error names the flags. A flag sent with its current value is ignored, and the other fields are still saved. Edit the profile instead.
curl -X PUT https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/policy \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ZX_TOKEN" \
-d '{ "platform": "macos", "allowUserQuit": false,
"checkInIntervalMinutes": 15, "minOsVersion": "14.5" }'
// 200 → the resolved policy view for that platform
// 409 → { error, profilesOnly: true } when the body would change a hardening or update flag after the move to profiles
Request A Backup / Patch / OS Update — Request Then Approve
A two-role control channel to the host updater agent. The app can only write a request; the host agent reads and runs it. There is a single pending slot, and it auto-clears if left unactioned.
POST /compliance/reports/update-request(gaterequest_system_updates). Body{ mode }, required, one ofbackup,security,full,os-security,os-full(anything else →400“Invalid update mode…”). Always answers202.POST /compliance/reports/update-approve(gateapprove_system_updates). No body needed — it approves the one pending request. Response{ approved: true, request }.
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/update-request \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ZX_TOKEN" \
-d '{ "mode": "security" }'
// 202 { "status": "pending_approval", … } ← requested by a non-admin
// 202 { "status": "dispatched", … } ← an admin is the approver, so it runs at once
Admin Requests Run Immediately; Two Distinct Admins For Destructive Actions
When the requester holds admin, their request is auto-approved and dispatched in the same call (a personal API token never carries the administrator role, so a request made with a token is parked as pending_approval) — there is no second-person step for a routine backup/patch. The genuinely destructive operations (a database restore or a backup deletion, driven from the Backups console, not this mode list) do enforce two distinct admins: approving your own restore/delete request is refused.
Paging, Filtering And Sorting
In plain words: a long list comes back a page at a time, and you ask for the next page with query parameters. The services do not share one way of doing it. Five styles are in use, and the catalogue row for a list says which one it follows. Where a row says nothing about paging, do not assume one: some lists return everything in a single reply.
| Style | How it works | Example (default, maximum) | Reply |
|---|---|---|---|
| limit + offset | Page size and a zero-based number of rows to skip. | GET /user-management/users (50, at most 500), GET /recruitment/candidates (200, at most 500), GET /user-management/devices (200, at most 1000) | { count, rows }, { candidates, total } and a bare array, in that order |
| limit + page | Page size and a page number that starts at 0. | GET /user-management/logs (25, at most 500) | { total, logs, counts, limit, page, … }; the reply echoes the limit and page it used |
| page + pageSize | A page number that starts at 1 and a page size. | GET /user-management/devices/software (50, at most 500) | { items, total } |
| cursor + limit | Send back the nextCursor from the previous reply as cursor. There is no page number and no total. | GET /compliance/reports/runs?vault=1 (50, at most 100) | { runs, nextCursor }; nextCursor is null on the last page |
| startIndex + count | The SCIM standard’s own paging: a first row numbered from 1 and a count. | The SCIM list endpoints (100, at most 200) | A SCIM ListResponse |
- Out-of-range values are corrected, not refused. In every handler above, a page size over the maximum is lowered to the maximum, a missing or non-numeric one becomes the default, and a negative offset or page becomes the first page. You do not get a
400for asking for too much, so read how many rows came back instead of assuming you got what you asked for. - Totals count the whole match. Where a reply carries
countortotal, it is the number of records matching your filters, not the size of the page. - Sorting is fixed unless the row says otherwise. There is no general
sortororderparameter. Each list comes back in the order its handler sets: users by highestidfirst, audit entries and stored report runs newest first. Where the order can be chosen it is a named value on that one route, for example?sort=scoreor?sort=nameon the candidate list. - Filters are plain query parameters. Each is named after the thing it filters (
status,stage,level), and free text issearchorqdepending on the route. There is no shared filter expression syntax; the SCIM endpoints follow the SCIM standard’s ownfilterparameter instead (see SSO, Passwordless Login & SCIM). - Offset paging over changing data can repeat or skip a row if records are added or removed between two requests. The cursor style does not have that problem.
Worked Example: The User Directory
GET /user-management/users pages with ?limit= and ?offset=. limit defaults to 50 and is clamped to 1–500; offset defaults to 0 and is never negative. Rows come back newest first (highest id). The reply is { count, rows }. Filters are status, source, mfa (enabled or anything else for not enabled), role (matches people who hold the role directly or through a group), manager (none or assigned) and search (name, email or login); all switches a filter off.
What each row contains depends on the caller. With view_users, manage_users or manage_permissions, a row is the full user record with its groups, directly assigned roles and manager (never the password hash). Any other caller allowed on the route gets a minimal card: id, firstName, lastName, email, position, department, photoUrl. The role filter applies to the full view only.
# Read every user, 500 at a time. Needs curl and jq. $ZX_TOKEN is a personal API token.
BASE='https://<your-instance>-api.zonitix.com/api/v1'
offset=0
while :; do
status=$(curl -s -o page.json -D headers.txt -w '%{http_code}' \
-H "Authorization: Bearer $ZX_TOKEN" "$BASE/user-management/users?limit=500&offset=$offset")
if [ "$status" = 429 ]; then # over 300 requests a minute for this token
sleep "$(grep -i '^ratelimit-reset:' headers.txt | tr -dc 0-9)"; continue
fi
[ "$status" = 200 ] || { echo "stopped on HTTP $status" >&2; exit 1; }
n=$(jq '.rows | length' page.json)
[ "$n" -eq 0 ] && break
jq -c '.rows[]' page.json
offset=$((offset + n))
done
The loop advances by the number of rows it received, not by the number it asked for, so it stays correct if the maximum page size is ever lowered. A read like this is safe to repeat after any error; which writes are safe to repeat is under Retries on Error Handling.
API Versioning
In plain words: there is one version of the API, called v1, and it is part of every address. Your instance’s own web app uses the same v1 addresses your scripts do. The API changes only when the release installed on your instance changes, not on a date set elsewhere.
- One version, in the path. Every route group is served under
/api/v1. There is no/api/v2, and no request header selects a version: the gateway and the services read none. - Unversioned mounts. The gateway also mounts each route group without the prefix, so
/user-management/…reaches the same code as/api/v1/user-management/…: the same handlers, permission checks and rate-limit budgets. Two groups differ in their second address: the finance and document routes are at/api/v1/parserand/api/parser, and evidence files are at/api/v1/evidence,/api/v1/auth/evidenceand/auth/evidence. Use the/api/v1form in anything you write; it is the one this documentation and the generated OpenAPI reference use. - Retired routes say so. A route that has been withdrawn answers
410with a body that says what replaced it, rather than404. NoDeprecationorSunsetresponse header is sent ahead of that. - The version you call is the version you run. The OpenAPI file at
/api/docs/openapi.jsonis the one shipped with the release installed on your instance, so it changes when that release does. Itsinfo.versionis the fixed text1.0.0and does not identify a release. - How to tell which release you run. An administrator reads it in the app under System → Updates (the Application tile: “running” and a release name, or the first seven characters of a commit) or under System → System Health (App version, the Running and Deployed rows). If the tile reads Unknown, the instance could not check for releases; read the value under System → System Health instead. A script reads the same from
GET /compliance/reports/patch-status, which needsview_system:current.tag(the release name, ornull),current.commit,current.committedAtandcurrent.deployedAt. No response header carries the release.
No Deprecation Policy Is Published
Zonitix publishes no deprecation policy: there is no stated notice period before a route changes or is withdrawn, and no commitment that v1 routes stay compatible between releases. The Changelog is a monthly summary in plain words, not a route-by-route record, so a changed route may not be in it. The check you can run yourself: before an update is applied, save /api/docs/openapi.json and note the release shown under System → Updates; afterwards fetch the file again and compare the two. That shows added, removed and re-permissioned operations in the areas the file covers, and nothing outside them. Write integrations to treat an unexpected 410 or 404 as a signal that a route changed.
Route Catalogue
The documented routes, grouped by service, each with its method, path and permission gate. This is a subset of what the gateway serves; a route that is not listed here may still exist.
Session & Authentication
api-gateway → identity-service
Handled at the gateway and delegated to the identity service. This is the unauthenticated sign-in surface, and it is aggressively rate-limited. The other routes that carry no session — the agent endpoints, SCIM, SSO sign-in, shared report links, the careers site and candidate portal, and inbound webhooks — are listed in their own groups below.
Worked example: Who am I
Session cookie only: a personal API token cannot call /auth/me. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/auth/me
# Reply
200
{
"auth": true,
"user": {
"id": 12,
"username": "dana.levi@example.com",
"firstName": "Dana",
"lastName": "Levi",
"roles": ["administrator"],
"groups": ["IT"],
"permissions": ["view_users", "manage_users"],
"mfaEnabled": true,
"mfaVerified": true,
"mfaRequired": true
}
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /auth/login | Exchange credentials for a session; returns an MFA or terms-acceptance challenge when required. body A LoginRequest: { username, password } required (username<=255, password<=1024); optional consentAccepted:boolean to accept terms in-line returns 200 a SessionGrant { auth:true, token } | 200 a challenge instead of a session: a LoginTermsChallenge { termsAcceptanceRequired, userId, termsVersion }, a LoginPasswordChangeChallenge { passwordChangeRequired, userId, requirements, passwordChangeToken } or a LoginMfaChallenge { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods? } (each typed under Response Objects) | 401 bad creds (gateway maps 401/403 to 'Invalid username or password') guard 5 consecutive fails => 15-min lockout (403); blocked statuses (disabled/inactive/deleted/terminated/suspended/offboarded/archived) refused; directory-synced (non-local source) refused 403; admins always forced into MFA | Public |
| POST | /auth/logout | Invalidate the current session and clear the session cookie. body No body from client — gateway reads the session token from the __Host-token cookie and forwards { token } to identity returns 200 { ok:true } always guard no auth guard (works mid-MFA); token added to blacklist best-effort so a stolen JWT stops working; server-side notify failure never blocks cookie clear | Public |
| GET | /auth/me | Return the signed-in user with resolved roles, groups, and effective permissions. body No query parameters. returns 200 { auth:true, user: CurrentUser } where CurrentUser is { id:integer, username:string (the account email), firstName:string|null, lastName:string|null, roles:string[] (role names), groups:string[] (group names), permissions:string[] (permission names), mfaEnabled:boolean, mfaVerified:boolean, mfaRequired:boolean, supportSession?:SupportSessionInfo (present only in a support-access session) }, typed under Response Objects | 401 { auth:false } when the token is missing, invalid, blacklisted, or the account is blocked or its sessions were invalidated | 503 { degraded:true, message } when the profile cannot be resolved right now (the session is still valid; retry) guard read-only and safe to repeat; a 503 is not a sign-out — only a 401 means the session is gone | Session |
| POST | /auth/refresh | Refresh the session token before expiry. body No body from client — gateway extracts token from the Authorization bearer/cookie and forwards { token } returns 200 a SessionGrant { auth:true, token } (new session, also set as the session cookie) | 401 from the gateway's own session check (no token, or a session that is expired, revoked or no longer active) | 400 { error, correlationId } when the identity service then refuses the token: blacklisted, expired, or an MFA, password-change, magic-link or terms token (the identity service answers 401; the gateway returns it as 400) | 403 account not active | 404 user not found guard requires authenticateToken at gateway; refuses non-session token types (isMfaSession/isPasswordChangeSession/isMagicLink) so a single-use email credential can't mint a session | Session |
| POST | /auth/password/change | Change your own password (enforces the configured password policy). body A PasswordChangeRequest: { newPassword } required; the single-use password-change token goes in the Authorization: Bearer header, and a call without that header is answered 401 — target user derived from token, never from body returns 200 a PasswordChangeResult { success:true, message, devicesRevoked:integer } | 400 policy fail or same-as-current | 401 missing/invalid/expired token | 403 token is not a password-change token | 404 user not found guard anti-IDOR: account taken from verified token claims only; token single-use (blacklisted after); revokes ALL trusted devices as breach response; authLimiter rate-limited | Session |
| POST | /auth/sudo | Step-up re-authentication that unlocks sudo-gated actions for a short window. body A StepUpRequest: { password } required; current session token in Authorization: Bearer header returns 200 a SessionGrant { auth:true, token } (sudo-elevated token, also set as the session cookie) | 400 { error:'Invalid password', correlationId } for a wrong password (with code NO_LOCAL_PASSWORD and a longer message on a directory-synced account that has no password here), and 400 'Invalid user' or 'Session is no longer valid' when the account or session is refused (the identity service answers 401; the gateway returns it as 400) | 401 only from the gateway's own session check, when there is no valid session at all | 429 when the step-up attempt budget is used up guard re-verifies the user's password (bcrypt) to step up; sudoLimiter rate-limited; blocked-status users refused; failures audit-logged | Session |
| POST | /auth/magic-link/request | Email a one-time sign-in link. body A SignInLinkRequest: { email } required returns 200 { message:'If an account exists…' } whether or not the account exists (anti-enumeration) — the sign-in token is never in the response, it goes only to the mailbox | 400 { error, correlationId }: 'email is required' when email is missing or empty, 'Enter your email address.' when it is only spaces | 409 { error:'Email sign-in is not available on this system. Please sign in with your password instead.' } when email delivery (SMTP) is not configured on the instance guard only status 'active' users get a link; email send is best-effort (a send failure never reveals account existence); token never logged or persisted; magicLinkRequestLimiter — 30 requests per 15 minutes per IP, every request counted | Public |
| GET | /auth/magic-link/verify | Redeem a magic-link token. Opens a session, or hands back the next step when one is owed. body Query: ?token= required returns 200 a SessionGrant { auth:true, token } (session) — or, instead of a session, a LoginMfaChallenge when a second factor is required, a LoginPasswordChangeChallenge when a change is forced, or a LoginTermsChallenge { termsAcceptanceRequired:true, userId, termsVersion, termsToken } (each typed under Response Objects) when the current terms are not yet accepted | 400 { error, correlationId } in every refusal: 'Token is required' when ?token= is missing, and 'Magic link expired or invalid', 'This magic link has already been used.', 'Invalid token type' or 'Account is not active' otherwise (the identity service answers 401; the gateway returns it as 400) guard single use: the token is claimed atomically before anything is issued, so a replay is refused with 400 'This magic link has already been used.' | Public |
| POST | /auth/accept-terms | Accept the current terms when a sign-in handed back a terms token instead of a session (passkey, magic-link and SSO sign-ins can; a password sign-in instead re-sends POST /auth/login with consentAccepted:true). body { termsToken, consentAccepted:true } both required returns 200 a SessionGrant { auth:true, token } (session, also set as the session cookie) — or, instead of a session, the next step still owed: a password-change token when a change is forced, or an MFA challenge | 400 'Missing token.' | 400 'You must accept the Terms of Service and Privacy Policy to continue.' when consentAccepted is not true | 400 { error, correlationId } when the terms token is expired, already used, not a terms token, or the account is not active (the identity service answers 401; the gateway returns it as 400) guard single use: the terms token is claimed before anything is issued, so a replay is refused; counted by the sign-in rate limiter | Public |
| GET | /auth/setup/status | Report whether first-run setup is still pending, and whether a one-time setup key is required (setupKeyRequired). Used by the setup wizard. body No query parameters. returns 200 { initialSetupCompleted, setupKeyRequired, setupKeyNotConfiguredMessage (string or null), branding:{ companyName, logoUrl, primaryBrandColor } } | 503 { error, correlationId } when setup state cannot be read (never reported as 'not set up'); retry | Public |
| POST | /auth/setup/unlock | Check the one-time setup key before the wizard shows its steps. It completes nothing; the key is checked again on complete. body { setupKey } required returns 200 { ok:true } | 401 invalid setup key | 410 setup key expired | 429 too many attempts | 409 code SETUP_KEY_NOT_CONFIGURED (no setup key is configured on the installation) guard no session (no admin exists yet); 10 failed attempts per source IP per 15 minutes, then a 15-minute lockout; every rejection is audit-logged | Public |
| POST | /auth/setup/complete | Create the first owner admin and initial security settings. First run only. body { adminInfo:{ email, password } } required; setupKey required whenever the status call reports setupKeyRequired; optional acceptedTerms:boolean, updates ({ mode:'manual' } or { mode:'automatic', day:'sun'..'sat', time:'HH:MM', timezone: IANA zone } — the wizard's Updates step), securitySettings (object; the wizard sends none and the seeder applies its defaults) returns 200 { success:true } | 401 invalid setup key | 410 setup key expired | 429 too many setup-key attempts | 400 missing email/password, password below bootstrap policy, or an invalid updates value | 409 code SETUP_KEY_NOT_CONFIGURED | 403 setup already completed / a primary admin already exists | 409 setup already in progress | 503 cannot verify setup state guard first-run only; the setup key is re-checked here (this is the authoritative gate); Postgres advisory lock serializes concurrent calls; anchored to the FACT (refuses if any isPrimaryAdmin user exists), fails CLOSED; enforces min-12 + upper + number + symbol admin password regardless of operator policy; setupLimiter rate-limited | Public |
| GET | /auth/settings/branding | Public branding (name, logo, colours) for the login screen. body No query parameters. returns 200 { companyName, logoUrl, primaryBrandColor, timeZone, locale }. If the identity service does not answer, still 200 with the last branding it served (or the product defaults, without timeZone/locale) plus degraded:true guard a body carrying degraded:true is a fallback, not the saved settings — do not write it back | Public |
MFA, Passkeys & Trusted Devices
api-gateway → identity-service
TOTP, WebAuthn/passkeys, recovery codes, and the trusted-device list. Admin reset actions require sudo.
Worked example: Can this session step up with a passkey
Session cookie. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/auth/webauthn/sudo/available
# Reply
200
{ "available": true }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /auth/mfa/setup/init | Begin TOTP enrolment; returns the secret and provisioning URI. body An MfaSetupStart: { mfaToken } optional — MFA-challenge or full session token; else read from Authorization Bearer header. No other fields. returns 200 a TotpSetup { secret, otpauth_url } (typed under Response Objects); stages a PENDING TOTP secret | 400 { error, code?, correlationId } when the token is missing ('Authentication token required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type, or enrolment not allowed for this session | 404 user not found guard token must be enrollment type (MFA-challenge or full session, not magic-link/pw-change); denyUnlessMayEnroll blocks re-enroll on an already-MFA account unless MFA-verified | Session |
| POST | /auth/mfa/setup/confirm | Confirm TOTP enrolment with a first code and issue recovery codes. body An MfaSetupConfirm: { token } required (6-digit TOTP code); { mfaToken } optional (else Authorization Bearer). returns 200 an MfaEnrolmentResult { auth:true, token, recoveryCodes } (recoveryCodes is null when the account already had codes) | 400 { error, code?, correlationId } in every code or token refusal: 'MFA code is required', 'No MFA setup in progress. Restart enrollment.', and a wrong code ('Invalid token'), a missing token or an expired session (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 user not found guard enrollment token type + denyUnlessMayEnroll; only a proven code promotes pendingSecret to live mfaSecret | Session |
| POST | /auth/mfa/verify | Verify a TOTP code to complete a login challenge. body An MfaVerifyRequest: { mfaToken, token } required — mfaToken is the login-challenge JWT, token is the TOTP code; { rememberDevice } optional bool. returns 200 an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true) | 400 code NO_TOTP_ENROLLED | 400 { error, code?, correlationId } for a wrong code, a reused code (code TOTP_REPLAY), a missing mfaToken, or a challenge that expired or was used up by wrong codes (code MFA_SESSION_EXPIRED: start again from POST /auth/login) (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found | 423 code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes guard mfaToken.isMfaSession must be true; TOTP verified against user.mfaSecret window 1 | Public (login challenge) |
| POST | /auth/mfa/verify-recovery | Complete a login challenge using a one-time recovery code. body An MfaRecoveryVerify: { mfaToken, recoveryCode } required (recoveryCode a string); { rememberDevice } optional bool. returns 200 an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true) | 400 'Recovery code is required' / 'No recovery codes available for this user' | 400 { error, code?, correlationId } for a wrong code, a missing mfaToken or an expired challenge (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found | 423 code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes guard mfaToken.isMfaSession required; one-time — matched code marked used, bcrypt-compared against stored hashes | Public (login challenge) |
| POST | /auth/mfa/recovery-codes/regenerate | Invalidate and reissue your recovery codes. body No body. Caller identified from Authorization Bearer (full session); gateway also forwards cookie token. returns 200 { recoveryCodes } (new raw set, invalidates old) | 400 { error, code?, correlationId } when the session token is missing ('Authentication required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type or code MFA_VERIFICATION_REQUIRED | 404 user not found guard must be a full session (not mfaSession/magicLink/pwChange) AND decoded.mfaVerified===true | Session |
| GET | /auth/webauthn/register/generate | Get a WebAuthn registration challenge for a new passkey. body Query: ?mfaToken= optional (the login-challenge token during enrolment); otherwise the session is read from the Authorization bearer or cookie returns 200 PasskeyRegistrationOptions (typed under Response Objects): WebAuthn registration options (challenge, rp, user, excludeCredentials listing the passkeys already registered; attestation 'none', residentKey 'preferred', userVerification 'required'). The server passes no algorithm list and no prompt timeout, so the WebAuthn library the instance uses decides pubKeyCredParams and timeout | 400 { error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type' or enrolment not allowed for this session | 404 user not found guard each call stores a fresh one-shot challenge for the user and replaces the previous one, so only the latest options can be verified | Session |
| POST | /auth/webauthn/register/verify | Verify the attestation and store the passkey credential. body attestation as whole body, or { attestation } when { mfaToken } rides alongside; token from mfaToken or Authorization Bearer. returns 200 { verified:true, recoveryCodes } (+ token when enrolling via mfaToken; codes only if account had none) | 400 challenge expired / verification failed | 400 { error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 user not found guard isEnrollmentTokenType + denyUnlessMayEnroll; server-stored one-shot challenge reg_<userId> consumed regardless of outcome; enables MFA, clears staged TOTP secret | Session |
| GET | /auth/webauthn/authenticate/generate | Get a WebAuthn authentication challenge. body Query: ?mfaToken= required (the login-challenge token) returns 200 WebAuthn authentication options (challenge, allowCredentials = the user's passkeys, userVerification 'required') | 400 { error, code?, correlationId } when mfaToken is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found, or 'No WebAuthn credentials found for user' | Public (login challenge) |
| POST | /auth/webauthn/authenticate/verify | Verify the assertion and complete passkey sign-in. body { mfaToken, response } required — mfaToken the login-challenge JWT, response the WebAuthn assertion (response.id selects the credential). returns 200 { auth:true, token } | 400 'Passkey response missing', 'Challenge expired or not found' or the verifier's error | 400 { error, code?, correlationId } for a missing or expired mfaToken and for 'Verification failed' (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user or credential not found guard one-shot challenge auth_<userId> consumed before verify (no retry); credential scoped to user.id; counter advanced on success | Public (login challenge) |
| GET | /auth/webauthn/login/options | Start a passkey sign-in with no username typed: a challenge any passkey registered on this instance can answer. body No query parameters. returns 200 WebAuthn authentication options (challenge, rpId, userVerification 'required'; no allowCredentials, so the browser offers the passkeys it holds for this instance) guard the challenge is stored server-side and must come back in POST /auth/webauthn/login/verify | Public |
| POST | /auth/webauthn/login/verify | Finish a passkey sign-in. The passkey counts as both factors, so no separate MFA challenge follows. body The WebAuthn assertion as the whole body, or { response: <assertion> } returns 200 { auth:true, token } (session, also set as the session cookie) — or, instead of a session, a password-change token when a change is forced, or { termsAcceptanceRequired:true, userId, termsVersion, termsToken } | 400 'Malformed passkey assertion.' / 'Malformed client data.' / 'Challenge expired or not found. Please try again.' / the verifier's error | 401 { error, code:'UNKNOWN_PASSKEY' } for a passkey this instance does not know, 401 'Passkey verification failed.' | 403 when the account is locked or not active guard this route forwards the identity service's status unchanged, so a 401 here is a 401 (unlike the MFA-challenge routes above) | Public |
| GET | /auth/webauthn/credentials | List your registered passkeys. body No query parameters. returns 200 a PasskeyList { credentials:[{ id, transports, createdAt }] (oldest first, every passkey in one reply), totpEnrolled:boolean, recoveryCodesRemaining:number } | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type' (an MFA-challenge, magic-link or password-change token) | 404 user not found guard returns everything (no paging); never returns key material | Session |
| DELETE | /auth/webauthn/credentials/:id | Remove one of your passkeys. body No body. Caller resolved from own session token (Authorization Bearer or cookie). returns 200 { ok:true } | 401 SUDO_REQUIRED | 403 MFA_VERIFICATION_REQUIRED | 404 passkey not found | 409 LAST_FACTOR guard fresh step-up (sudo) required; session.mfaVerified===true required; credential scoped to caller userId; refuses to remove the last remaining factor (passkeyCount<=1 && no mfaSecret) | Session + sudo |
| GET | /auth/webauthn/sudo/available | Whether you have a passkey that can be used for step-up (sudo). body No query parameters. returns 200 { available:boolean } (true when the caller has at least one passkey) | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type' | Session |
| GET | /auth/webauthn/sudo/options | Get a challenge for a passkey step-up. body No query parameters. returns 200 WebAuthn authentication options for the caller's passkeys | 404 { error:'NO_PASSKEY', message } when no passkey is enrolled | 401 no or expired session | 403 'Invalid session type' guard 30 requests / 15 min per user (challenge budget) | Session |
| POST | /auth/webauthn/sudo/verify | Verify the passkey assertion and open the step-up window, exactly as POST /auth/sudo does with a password. body The WebAuthn assertion as the whole body (its id selects the credential) returns 200 { auth:true, token } — a sudo-stamped session token, also set as the session cookie | 400 'Challenge expired or not found' | 400 code ASSERTION_NOT_VERIFIED | 400 the verifier's error message | 404 'Credential not found' | 401 no or expired session | 403 'Invalid session type' guard 5 failed attempts / 15 min, the same budget as POST /auth/sudo; cannot create a session, only re-stamp an existing one; not repeatable: the challenge is consumed before verification, so a retry needs new options from GET /auth/webauthn/sudo/options | Session |
| GET | /auth/devices | List your trusted devices / active sessions. body No query parameters. returns 200 { devices: TrustedDevice[] } (typed under Response Objects), each { id, userAgent, ipAddress, lastUsedAt, expiresAt, createdAt, current:boolean }, most recently used first, every device in one reply (no paging) | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type guard expired entries are purged before the list is built; returns everything (no paging) | Session |
| DELETE | /auth/devices/all | Revoke every trusted device on your account. body No body. Caller resolved from own session token (Authorization Bearer or cookie). returns 200 { ok:true, revoked } — count of trusted devices dropped | 400 { error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) guard session must be a full session (rejects mfaSession/magicLink/pwChange, requires username); scoped to caller id | Session |
| DELETE | /auth/devices/:id | Revoke a single trusted device. body No body. Caller resolved from own session token. returns 200 { ok:true } | 400 { error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 'Device not found' guard destroy scoped to { id, userId: caller } so one user cannot revoke another's device | Session |
| POST | /auth/security/mfa/reset/:userId | Administratively reset another user's MFA enrolment. body No body. Target userId in path; actor read from x-user header injected by gateway. returns 200 a FactorResetResult { ok:true, email, passkeysRemoved, devicesRevoked } | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401) | 403 without manage_security | 404 user not found guard manage_security + sudo (requireSudo) at route; destructive — wipes passkeys, TOTP secrets, recovery codes AND all trusted devices; always audited | Session + manage_security + sudo |
| POST | /auth/security/devices/revoke/:userId | Administratively revoke another user's trusted devices. body No body. Target userId in path; actor read from x-user header. returns 200 { ok:true, email, revoked } | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401) | 403 without manage_security | 404 user not found guard manage_security + sudo (requireSudo) at route; revokes only trusted devices (keeps user's factors); audited | Session + manage_security + sudo |
Security Settings, Access Control & Audit Sessions
api-gateway → identity-service / compliance-service
Instance-wide security configuration, geo/IP access rules, and the audit-session bookkeeping used by the Compliance Hub.
Worked example: Is every expected service up
Public: no session or token. The values shown are made up; the field names and the shape are what the route sends.
curl -i https://<your-instance>-api.zonitix.com/api/v1/health/cluster
# Reply
200
{ "status": "operational", "timestamp": "2026-10-10T08:15:02.117Z" }
503 // at least one expected service is not ready
{ "status": "degraded", "timestamp": "2026-10-10T08:15:02.117Z" }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /auth/settings/security | Read security settings (MFA policy, session timeout, password policy, SMTP). body No query parameters. returns 200 a SecuritySettings object (typed under Response Objects): mfaEnabled, passwordPolicies[] (each a PasswordPolicy = { id:string ('default' is the organisation-wide Standard Policy; any other id is a policy scoped to groups), name:string, minLength:integer (8 to 128), requireUppercase:boolean, requireNumbers:boolean, requireSymbols:boolean, groupIds:integer[] (the groups it applies to; empty on 'default') }), passwordHistoryCount, namingConvention, emailDomain, conflictStrategy, sessionTimeout, auditLogRetentionDays, auditLogRetention, workflowRequireSeparateApprover, workflowRequireSeparateRunApprover, reportRecipientPolicy, reportRecipientDomains, the module switches (awsMappingEnabled, azureMappingEnabled, gcpMappingEnabled, securityLabEnabled, complianceEnabled, reportingEnabled, financeEnabled), licenceFlagsExplicitlyOff, activeAiModel, aiModelRouting, aiEnabled, aiRequireUsePermission, smtpConfigured, branding:{ companyName, logoUrl, primaryBrandColor } guard itNotificationRecipients, hrNotificationRecipients and the smtp* fields (smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecure, smtpPassSet) are present only for a caller with manage_security or the administrator role; the SMTP password itself is never returned. Merge rule for passwordPolicies: a person is held to 'default' plus every policy whose groupIds contains a group they belong to, and the strictest value of each rule wins (the largest minLength; a character class required by any one of them stays required). A first-run instance is seeded with one policy: id 'default', name 'Standard Policy', minLength 8, uppercase, number and symbol all required | Session + manage_security | view_users | view_dashboard |
| POST | /auth/settings/security | Update security settings. body All optional (only provided keys change): mfaEnabled bool, passwordPolicies[], namingConvention, sessionTimeout, awsMappingEnabled/azureMappingEnabled/gcpMappingEnabled/securityLabEnabled/complianceEnabled/reportingEnabled/financeEnabled bool, workflowRequireSeparateApprover bool, activeAiModel, branding{companyName,logoUrl,primaryBrandColor}, smtp{Host,Port,User,From,Secure,Pass}, adminEmail returns 200 { ok:true } | 400 { error, correlationId } and nothing saved when a value is refused: a passwordPolicies element that is not an object, or whose minLength is not a whole number from 8 to 128 ('Password policy "<name>": minimum length must be a whole number from 8 to 128. Nothing was saved.'); sessionTimeout outside 5m to 24h; audit-log retention outside 90 to 3650 days; password history outside 0 to 24; an activeAiModel or aiModelRouting entry not on the allowlist | 400 for an unknown reportRecipientPolicy; settings earlier in the same body may already have been saved | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400) | 403 { error:'License-managed setting', correlationId } when a module switch is not yours to change (the sentence naming the modules does not pass the gateway) guard gateway manage_security + identity requireSudo; activeAiModel must match AI_MODELS allowlist (no slash/scheme/host) to block Ollama SSRF; smtpPass write-only, '' leaves stored pass unchanged; mfaEnabled is the organisation-wide default that groups set to inherit follow, and writing it changes no group. passwordPolicies REPLACES the whole stored array (a policy left out is deleted), each element a PasswordPolicy as typed on the GET row; an out-of-range minLength is accepted only when it is unchanged from the stored value of the same id. Saving a new or changed 'default' policy forces every account with a local password to choose a new one at next sign-in; a new or changed group policy does the same for the members of its groupIds | Session + manage_security + sudo |
| POST | /auth/settings/branding | Save company branding and the instance's regional defaults (the Branding page). body { branding:{ companyName, logoUrl, primaryBrandColor } } or the same three fields at the top level; timeZone (an IANA name such as Europe/London) and locale (a tag such as en-GB) inside branding or at the top level. Only the fields sent change returns 200 { success:true } | 400 'No branding fields supplied.' | 400 unknown time zone or locale, naming the value guard audited as BRANDING_UPDATED and, when a regional default changed, REGIONAL_SETTINGS_UPDATED | Session + manage_security |
| POST | /auth/settings/security/smtp/test | Send one test email. With host, user and pass all in the body it uses those without saving them; otherwise it uses the saved mail settings. body { to (required), host?, port?, user?, pass?, from?, secure? } returns 200 { success:true, messageId, message } | 400 { error, correlationId } when to is missing ('A recipient email address is required.') or no mail server is configured | 424 { success:false, error } when the mail server refused the message or could not be reached; error carries the mail server's reason | Session + manage_security |
| GET | /auth/settings/security/access-control | Read the geo/IP access-control rule set. body No query parameters. returns 200 AccessControlRules (typed under Response Objects), the stored rule set merged over the defaults: { enabled, mode:'allowlist'|'blocklist', countries[], blockedIps[], allowedIps[], blockUnknownGeo, dryRun, autoBlock:{ enabled, dryRun, threshold, windowSec, banSec, patterns, tuning }, … }, plus geoDbLoaded:boolean added by the gateway (whether its offline country database loaded; country rules do nothing when false). With nothing saved: enabled:false, mode:'blocklist', empty lists, autoBlock off and in dry-run | Session + manage_security |
| POST | /auth/settings/security/access-control | Update geo/IP access rules (mode, countries, IP lists, dry run). body All coerced/optional: enabled bool, mode 'allowlist'|'blocklist' (anything else->blocklist), countries[], blockedIps[], allowedIps[], blockUnknownGeo bool, dryRun bool returns 200 { ok:true } (writes ACCESS_CONTROL_UPDATED audit log) | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401) guard Session + manage_security + a fresh step-up; non-array list fields default to []; unknown mode coerced to blocklist | Session + manage_security + sudo |
| POST | /auth/settings/security/access-control/invalidate-cache | Force the gateway to reload access rules instead of waiting for the 60s cache. body No body. returns 200 { ok:true } guard Session + manage_security; handled at gateway (calls invalidateGeoCache), never proxied downstream | Session + manage_security |
| POST | /auth/log | Write a compliance/audit-execution event to the central audit log from the app. body { action, details } required; level optional ('info' default); payload optional object returns 200 { ok:true } guard manage_compliance at the gateway; the logging service accepts only an allow-list of audit actions and clamps field sizes; the actor is taken from the signed session, not the body | Session + manage_compliance |
| GET | /auth/audits/history | List historical audit records. body Query: ?summary=1 selects the paged summaries; ?offset= (default 0, only read together with summary). Fixed page size 100, newest completedDate first. Without ?summary the first page of the same shape is returned (the old unpaged list is retired; the gateway response carries no deprecation header) returns 200 { truncated:boolean (another page exists), nextOffset (number or null), items:[{ id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, verifiedScore, evidenceCount }] } — no evidence rows; fetch one record for those | Session + manage_compliance | view_reporting |
| GET | /auth/audits/history/:id | One completed audit in full (evidence rows and sealed verdicts). body No query parameters. returns 200 { id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, sealedVerdicts, sealedScope, verifiedScore, completedItems:[…evidence rows] } | 404 'Audit record not found' | Session + manage_compliance | view_reporting |
| POST | /auth/audits/history | Save a historical audit record. body { id, name, framework, startDate, completedDate, completedBy } + completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}}), totalControls optional returns 200 { AuditRecord } (finalized/completed; the gateway answers 200 whatever the service status) guard Session + manage_compliance; score is RECOMPUTED server-side from evidence status (client score ignored) — a control passes only with >=1 passing and 0 failing evidence; idempotent finalize (upsert by id) | Session + manage_compliance |
| DELETE | /auth/audits/history/:id | Delete a historical audit record. body No body. returns 200 { ok:true, deleted:id } | 404 not found | 409 if record is COMPLETED (immutable) guard Session + manage_compliance; only DRAFT (non-completed) records deletable — completed audit evidence is immutable, refusal itself is logged | Session + manage_compliance |
| GET | /auth/audits/active | List in-progress audit sessions. body No query parameters. returns 200 [ { id, name, framework, startDate, endDate, completedDate (null), score, completedItems:[{ id, title, evidenceDetails:{ status, notes, … } }] } ], newest startDate first; the standing continuous-monitoring record is excluded guard bare array, no paging | Session + manage_compliance |
| POST | /auth/audits/active | Create or update an in-progress audit session. body { id, name, framework, score, startDate, completedBy } + endDate optional, completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}}) returns 200 { AuditRecord } (completedDate forced null = in-progress) guard Session + manage_compliance; upsert by id; autosave preserves harvester evidence tagged '[ACTIVE VERIFICATION]', wiping only manual evidence before re-insert | Session + manage_compliance |
| DELETE | /auth/audits/active/:id | End an in-progress audit session. body No body. returns 200 { ok:true, deleted:id } | 404 not found guard Session + manage_compliance; only deletes records with completedDate=null (in-progress); cascades AuditEvidence delete | Session + manage_compliance |
| GET | /health/cluster | Aggregate health of the instance (operational / degraded / maintenance). Deliberately returns no per-service detail to anonymous callers. body No query parameters. returns 200 { status:'operational', timestamp:string (ISO 8601 date-time) } when every expected service is up | 503 { status:'degraded'|'maintenance', timestamp:string } otherwise ('maintenance' = no expected service is up or degraded) guard services whose module is not licensed are not probed and do not count against the status | Public |
| GET | /health | Liveness of the API gateway process only. It says nothing about the other services; use /health/cluster for that. body No query parameters. returns 200 { status:'UP', service:'api-gateway' } | Public |
Users, Roles, Groups & Permissions
user-management-service
The RBAC core, the org chart, and per-user data export. All paths are prefixed /user-management.
Worked example: First page of the directory
API token holding view_users. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/user-management/users?limit=1&offset=0"
# Reply
200
{
"count": 148,
"rows": [
{
"id": 12,
"firstName": "Dana",
"lastName": "Levi",
"email": "dana.levi@example.com",
"status": "active",
"source": "local",
"managerId": null
// ... the remaining User fields: see Response Objects above
}
]
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/users | List users (server-side pagination, filtering, search): ?limit (default 50, max 500), ?offset, ?status, ?source, ?mfa, ?role, ?manager, ?search. Returns { count, rows }; full records need view_users, manage_users or manage_permissions, other callers get minimal identity cards. body Query: ?limit (default 50, max 500; a missing, zero or non-numeric value is the default, a negative one becomes 1), ?offset (default 0), ?status, ?source, ?mfa ('enabled' | anything else = not enabled), ?role (role name), ?manager ('none' | 'assigned'), ?search (up to 6 words, each matched against email, firstName, lastName, sAMAccountName). 'all' on status/source/mfa/role means no filter. No sort parameter: always id descending returns 200 a UserPage: { count:integer (all matching users), rows:array }, typed under Response Objects. With view_users, manage_users or manage_permissions each row is the full user (the User object; never passwordHash) with Groups, Roles and Manager{ id, firstName, lastName, email }; other callers get { id, firstName, lastName, email, position, department, photoUrl } (the UserCard object) and ?role is ignored | Session + view_users | manage_users | manage_permissions | manage_workflows | view_dashboard |
| GET | /user-management/users/search | Typeahead user lookup for pickers. body Query: ?query= (up to 6 words, each matched against email, firstName, lastName, sAMAccountName) returns 200 [ { id, firstName, lastName, email, sAMAccountName, status } ] — at most 10, ordered by firstName, lastName, id; an empty query answers [] guard no paging; narrow the query instead | Session + view_users | manage_groups | manage_roles | manage_employee_records |
| GET | /user-management/users/stats | Aggregate user counts for dashboard tiles. body No query parameters. returns 200 { total, active, mfaEnabled, synced (source other than local or onboarding), serviceAccounts, mfaEligible (active accounts that can sign in), mfaEligibleEnabled } | Session + view_users | manage_users | view_dashboard |
| GET | /user-management/users/me/export | Export your own profile, groups, roles, and most recent audit entries as JSON (GDPR access request). body No query parameters. returns 200 { exportedAt, requestingUser, profile:{ id, firstName, lastName, email, username, status, department, mfaEnabled, attributes, createdAt, updatedAt }, groups[], roles[], permissions[], recentAuditLogs[] (your newest 200: action, details, level, createdAt), employeeRecord, crossServiceData } | 404 'User profile not found' guard each export is audit-logged as PEOPLE_DATA_EXPORTED | Session |
| POST | /user-management/users | Create a user. body A UserCreate plus password: firstName, lastName, email, sAMAccountName, password (required if source==='local', unless sendInvite:true is sent instead: the account is created without a password and a set-password invitation is issued; local accounts only), position, department, managerId, status, source, employeeId, phoneNumber, attributes, roleIds[], groupIds[], personaId returns 201 created User (with sendInvite:true the same object plus an invite field) | 400 sAMAccountName taken / { error:'Password is required for local users (or send an invitation instead).' } when a local account has neither password nor sendInvite:true / password-policy / sendInvite on a non-local source | 403 privilege escalation | 409 a user with that email or login name already exists guard assigning roleIds/groupIds at create requires manage_roles; roleIds subject to subset invariant (cannot confer a perm you lack); not safe to repeat blindly: email and sAMAccountName are unique, so a second identical call is refused (409, or 400 for a taken sAMAccountName) rather than creating a duplicate | Session + manage_users |
| PUT | /user-management/users/:id | Update a user. body A UserUpdate plus isPrimaryAdmin and password. Allowlist: firstName,lastName,email,sAMAccountName,position,department,managerId,personaId,photoUrl,employeeId,phoneNumber,terminationDate; also status, isPrimaryAdmin, password, roleIds[], groupIds[] returns 200 User | 404 not found | 403 primaryAdmin immutable / reactivation|isPrimaryAdmin need manage_security / roles need manage_roles / groups need manage_groups | 400 name taken/self-manage/cycle/pwd-policy guard mass-assign allowlist; a body that carries password (resetting another user's password) additionally requires the manage_security permission (403 without it) and a sudo step-up; reactivate & isPrimaryAdmin need manage_security; roleIds subset+no-self; groupIds need manage_groups | Session + manage_users (+ manage_security and sudo when the body sets a password) |
| DELETE | /user-management/users/:id | Delete a user and their personal records. body No body. returns 200 { deleted:true, erasure } | 200 { anonymized:true, message, crossServiceErasure, erasure } when other records still reference the user, so the personal fields are overwritten and the row is kept | 404 not found | 403 system administrator account cannot be deleted | 409 the user is still active (offboard first) | 409 code SUPPORT_ACCOUNT_MANAGED for the Zonitix support account guard an account whose status is active is refused; offboard it first, then delete | Session + manage_users |
| POST | /user-management/users/:id/offboard | Start the offboarding flow (deprovision + dated reclaim tasks). body A UserOffboard: { lastDay?: string, immediate?: boolean, reason?: string }, all optional. immediate:true runs the run's revoke steps at once instead of leaving them queued, and then needs a reason of at least 5 characters. returns 201 task | 202 PARTIAL (access revoked but no deprovisioning run created; or, with immediate:true, the forced revocation left something outstanding) | 400 { error:'An immediate termination needs a reason on the record.' } | 403 cannot offboard your own account guard cannot offboard yourself; audited as USER_OFFBOARDING_STARTED, or USER_TERMINATED_IMMEDIATELY with immediate:true; primary-admin refused inside triggerOffboarding; 202 signals best-effort run creation failed | Session + manage_users |
| GET | /user-management/users/org-tree | Org chart hierarchy. body Query: ?format=csv returns the directory as a CSV download instead of JSON returns 200 [ user ] — every active user plus any non-active manager needed to complete a reporting line, id ascending. Fields: id, firstName, lastName, position, department, managerId, photoUrl, source, lastActiveAt, createdAt, attributes; email, employeeId and phoneNumber only for a caller with view_users or manage_users (others get attributes reduced to dottedManagerIds). CSV columns: Name, Email, Title, Department, Manager | 403 ?format=csv without one of those permissions guard bare array, no paging; a CSV export is audit-logged | Session + view_org_chart | view_dashboard |
| GET | /user-management/users/org-rollups | Rolled-up headcount/health metrics per branch of the org chart. body No query parameters. returns 200 { totalHeadcount, departmentCount, departments:[{ department, headcount, managers, ics }] } — active users only, largest department first; a user with no department is counted under 'Unassigned' | Session + view_org_chart | view_dashboard |
| GET | /user-management/users/org-vacancies | Open positions surfaced on the org chart. body No query parameters. returns 200 { total, byManager:{ [hiringManagerId]: count }, byDepartment:{ [department]: count } } — counts job postings whose status is Open or Published | Session + view_org_chart | view_dashboard |
| PUT | /user-management/users/:id/dotted-reports | Set matrix (dotted-line) reporting relationships. body A DottedReportsUpdate: { managerIds: number[] } REPLACES the set — deduped, self removed, max 10, each must be an active user returns 200 { id, dottedManagerIds } | 404 user not found | 400 >10 managers / one or more not valid active users guard stored in user.attributes.dottedManagerIds; route gated manage_org_chart; safe to repeat (the set is replaced, not appended) | Session + manage_org_chart |
| GET | /user-management/groups | List groups. body No query parameters. returns 200 [ Group ] (typed under Response Objects) — each with Users:[{ id:integer }] guard bare array, no paging or sort parameter | Session + view_groups | manage_groups | view_dashboard |
| POST | /user-management/groups | Create a group. body A GroupWrite: name, description, userIds[], mfaPolicy (inherit | require | exempt), mfaExemptUntil, mfaExemptReason. The older boolean requiresMfa is still read when mfaPolicy is not sent. The MFA keys are honored only for a manage_security caller returns 201 created Group | 409 a group with that name already exists guard the MFA keys (MFA-enforcement control) only applied for an elevated manage_security caller; ignored otherwise; group names are unique, so a repeated create is refused with 409 | Session + manage_groups |
| PUT | /user-management/groups/:id | Update a group. body A GroupWrite: name, description, userIds[], mfaPolicy (inherit | require | exempt), mfaExemptUntil, mfaExemptReason. The older boolean requiresMfa is still read when mfaPolicy is not sent. The MFA keys are honored only for a manage_security caller returns 200 Group | 404 not found guard the MFA keys writable only by manage_security caller (delegated manage_groups cannot downgrade MFA) | Session + manage_groups |
| DELETE | /user-management/groups/:id | Delete a group. body No body. returns 204 no content | 404 not found | Session + manage_groups |
| GET | /user-management/roles | List roles. body No query parameters. returns 200 [ Role ] (typed under Response Objects) — each with Permissions[], Users:[{ id:integer }] and Groups:[{ id:integer, name:string }] guard bare array, no paging or sort parameter | Session + view_roles | manage_roles | view_dashboard |
| POST | /user-management/roles | Create a role. body A RoleWrite: name, description, userIds[], permissionIds[], groupIds[] returns 201 created Role | 403 only administrator can create 'administrator' role / privilege escalation (perms you do not hold) | 409 a role with that name already exists guard subset invariant on permissionIds; name==='administrator' admin-only and auto-gets ALL permissions; role names are unique, so a repeated create is refused with 409 | Session + manage_roles |
| PUT | /user-management/roles/:id | Update a role. body A RoleWrite: name, description, userIds[], permissionIds[], groupIds[] returns 200 Role | 404 not found | 403 rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator guard name pinned on entry to block rename-bypass; permissionIds subset invariant; administrator role always reset to ALL perms | Session + manage_roles |
| DELETE | /user-management/roles/:id | Delete a role. body No body. returns 204 no content | 404 not found | 403 the primary administrator role cannot be deleted | Session + manage_roles |
| POST | /user-management/roles/:roleId/permissions | Replace a role's permission set. body A RolePermissionsReplace: { permissionIds: number[] } REPLACES the role's permission set returns 200 { ok:true } | 404 role not found | 403 privilege escalation (cannot grant perms you do not hold) guard subset invariant via permissionsCallerMayNotGrant; administrator role always forced to ALL perms; safe to repeat (replace semantics) | Session + manage_roles |
| GET | /user-management/permissions | List every atomic permission. body No query parameters. returns 200 [ Permission ] (typed under Response Objects) — each with Roles[] and Users:[{ id:integer }] guard bare array, no paging | Session + manage_permissions | manage_roles |
| POST | /user-management/permissions/:id/assignments | Set which roles/users hold a given permission. body { roleIds?: number[], userIds?: number[] } — each REPLACES that side of the join for this permission returns 200 { ok:true } | 404 permission not found | 403 you cannot assign a permission you do not hold guard caller must already hold the permission (subset); administrator role is always re-added to it | Session + manage_permissions |
| POST | /user-management/permissions/compliance-access/:userId | Grant a named user read access to compliance evidence (e.g. an external auditor you have onboarded as a user). body No body. Grants the view_assigned_compliance permission to :userId. returns 200 { ok:true } | 404 user not found | 400 view_assigned_compliance permission not seeded guard direct user->permission grant (addPermission), scoped to the one user | Session + manage_compliance |
| DELETE | /user-management/permissions/compliance-access/:userId | Revoke that compliance access. body No body. Removes view_assigned_compliance from :userId. returns 200 { ok:true } | 404 user not found guard no-op if the permission is not seeded (removePermission only when found) | Session + manage_compliance |
| GET | /user-management/personas | List onboarding personas. body No query parameters. returns 200 [ UserPersona ], ordered by name guard bare array, no paging | Session + view_users | manage_users | manage_personas |
| POST | /user-management/personas | Create a persona. body name (required, unique), description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{} returns 201 created UserPersona (estimatedOneTimeCost/estimatedMonthlyCost computed server-side) | 409 a persona with that name already exists guard service spreads req.body then recomputes cost totals from hardwareTemplate/licenseTemplate; a repeated create with the same name is refused with 409 | Session + manage_users | manage_personas |
| PUT | /user-management/personas/:id | Update a persona. body same fields as create: name, description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{} returns 200 updated UserPersona (costs recomputed) guard service spreads req.body over the row (no explicit write allowlist); costs recomputed server-side | Session + manage_users | manage_personas |
| DELETE | /user-management/personas/:id | Delete a persona. body No body. returns 204 no content | Session + manage_users | manage_personas |
| POST | /user-management/personas/:id/provisioning-plan | Generate the provisioning plan a persona would produce. body { startDate?: string, workflowId? } both optional (each also accepted as a query parameter). With workflowId, items that workflow leaves out for this persona are marked excluded. Read-only — no persistence. returns 200 { persona:{ id, name }, cost:{ oneTime, monthly }, workflow:{ id, name } | null, startDate, items[], summary } | 404 persona not found guard classifies auto vs dated IT tasks against active ExternalProviders; writes nothing | Session + view_users | manage_users | manage_personas | manage_workflows | manage_approvals |
| POST | /user-management/personas/preview/provisioning-plan | Preview a provisioning plan for an unsaved persona definition. body { persona: {...unsaved persona def}, startDate?: string }. Read-only — no persistence. returns 200 { startDate, items[], summary } guard live-preview for an unsaved persona; writes nothing | Session + view_users | manage_users | manage_personas | manage_workflows |
| GET | /user-management/org-snapshots | List saved org-chart snapshots. body No query parameters. returns 200 { snapshots:[{ id, name, snapshotDate, createdBy, metrics, createdAt }] (newest snapshotDate first; the captured tree is not included), metadata:{ nextScheduledSnapshot } } guard returns every snapshot (no paging) | Session + view_org_chart |
| POST | /user-management/org-snapshots | Take an org-chart snapshot. body An OrgSnapshotCreate: { name: string } required returns 201 created OrgSnapshot | 400 Snapshot name is required guard createdBy derived from x-user header (username/email), not the body; not deduplicated: there is no uniqueness check on the name, so repeating the call stores a second snapshot | Session + manage_org_chart |
| GET | /user-management/org-snapshots/diff | Diff two snapshots to show what changed. body Query: ?from= and ?to= (snapshot ids), both required returns 200 { from:{ id, name, date }, to:{ id, name, date }, summary:{ joined, left, moved }, joined:[{ id, name, department, position }], left:[same], moved:[{ id, name, changes:{ managerId?, department?, position? } }] } — each change is { from, to }; email is added to joined/left only for a caller allowed to see personal data | 400 'from and to snapshot ids are required' | 404 'Snapshot not found' | Session + view_org_chart |
| GET | /user-management/org-snapshots/:id | Fetch one snapshot. body No query parameters. returns 200 the snapshot row including data[] (the captured org nodes, redacted for a caller not allowed to see personal data) | 404 'Snapshot not found' | Session + view_org_chart |
| DELETE | /user-management/org-snapshots/:id | Delete a snapshot. body No body. returns 204 no content (destroy is idempotent — no 404 if id absent) guard safe to repeat: a second call still answers 204 | Session + manage_org_chart |
| GET | /user-management/license | Current license status, tier, and entitlements as last synced. body No query parameters. returns 200 { status:'ACTIVE'|'PENDING_APPROVAL'|'PROVISIONING_FAILED'|'UNPROVISIONED', licenseType, features:{ [flag]: boolean }, companyName, serviceId, hasProvisioningKey, lastHeartbeat, nextHeartbeat, heartbeatStatus, heartbeatHealth:{ state:'OK'|'WARNING'|'GRACE_EXPIRED', consecutiveFailures, firstFailureAt, graceExpiresAt, graceHoursRemaining, featuresDisabled:false, policy }, subscription:{ enforced, state:'active'|'grace'|'lapsed'|'unknown', paidThroughAt, graceEndsAt, storeContactSinceExpiry, premiumPaused }, host:{ cpuCount, ramGb }, … } guard the provisioning key itself is never returned, only hasProvisioningKey | Session + manage_security | view_dashboard | view_users |
| POST | /user-management/license/provision | Retired. Always answers 410; activation is automatic and store-managed. body Ignored. returns 410 { error:'Manual provisioning is disabled. This instance is activated automatically by the store.' } guard every call is audited as LICENSE_PROVISION_REJECTED | Session + manage_security |
| POST | /user-management/license/sync | Force an immediate license heartbeat instead of waiting for the 5-minute beat. body No body. returns 200 { message:'Sync completed', config } | 200 { message:'Sync did not complete', config:null } when the instance is not activated or the store did not answer or refused guard a completed sync is audited as LICENSE_SYNCED; one that did not complete as LICENSE_SYNC_NOT_COMPLETED (warning), and entitlements are left unchanged | Session + manage_security |
| POST | /user-management/license/reset | Clear all license and provisioning state on the instance. body No body. returns 200 { message:'License reset successfully' } | 403 'Only an administrator can reset the licence.' guard the administrator role itself is needed: holding the security-management permission without that role is refused with 403; audited as LICENSE_RESET; a refused call as LICENSE_RESET_DENIED; every plan-gated module is switched off | Session + administrator role |
| GET | /user-management/saas-pricing/plans | Vendor pricing catalog entries. body No query parameters. returns 200 [ SaaSPlan ], ordered by vendor then price guard bare array, no paging | Session + view_users | manage_users | manage_security |
| GET | /user-management/saas-pricing/schedule | Read the pricing-catalog sync schedule. body No query parameters. returns 200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'SaaS sync settings not configured' | Session + manage_security |
| PUT | /user-management/saas-pricing/schedule | Update the pricing-catalog sync schedule (cron). body { frequency: string, cron: string } both required returns 200 { message, schedule } | 400 Frequency and Cron expression are required | 404 SaaS sync settings not found | Session + manage_security |
| POST | /user-management/saas-pricing/sync | Run a pricing-catalog sync now. body No body. returns 200 { message, details }. While no vendor price feed is connected, details is { success:false, synced:false, reason:'no_vendor_feed', checked, count:0, message } and no price is changed, although the top-level message still reads 'SaaS vendor prices synchronized successfully'. Read details.synced. | Session + manage_security |
| GET | /user-management/device-catalog/models | Available hardware models for onboarding selection. body No query parameters. returns 200 [ DeviceModel ], ordered by brand then modelName guard bare array, no paging | Session + view_users | manage_users | manage_security |
| GET | /user-management/device-catalog/schedule | Read the device-catalog sync schedule. body No query parameters. returns 200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'Device catalog sync settings not configured' | Session + manage_security |
| PUT | /user-management/device-catalog/schedule | Update the device-catalog sync schedule (cron). body { frequency: string, cron: string } both required returns 200 { message, schedule } | 400 required fields / Invalid cron expression | 404 Device catalog sync settings not found guard validate-then-swap: rejects invalid cron BEFORE persisting, then re-inits scheduler | Session + manage_security |
| POST | /user-management/device-catalog/sync | Run a device-catalog sync now. body No body. returns 409 { error, synced:false, reason:'no_feed', details } while no hardware catalog feed is connected (the current state; models and costs are left unchanged) | 200 { synced:true, message:'Device catalog synchronized successfully', details } only when a feed actually synced | Session + manage_security |
| GET | /user-management/notifications | Your own notification feed (the bell in the top bar). Query: ?unread=1 keeps unread entries only. No paging parameter: at most 50 entries. Unread entries first, newest first; then read entries in the order they were read, earliest first. body Query: ?unread=1 optional. returns 200 [ Notification ] (typed under Response Objects) — a bare array, no total guard only the caller's own entries are ever returned; there is no parameter that reads another user's feed | Session + view_dashboard |
| POST | /user-management/notifications/:id/read | Mark one of your notifications as read. Safe to repeat: an entry that is already read is left as it is and the call answers updated: 0. body No body. returns 200 { updated:integer } — 1 when this call marked it, 0 when it was already read or is not yours | 400 { error:'Invalid notification id.' } when :id is not a positive whole number | Session + view_dashboard |
| POST | /user-management/notifications/read-all | Mark every unread notification of yours as read. Safe to repeat: a second call answers updated: 0. body No body. returns 200 { updated:integer } — how many entries this call marked | Session + view_dashboard |
Workflows & Tasks
workflow-service
Designing a workflow and approving it are deliberately different rights, so a designer cannot put their own work live. Most mutating workflow routes additionally require sudo (marked per row). A tenant can run several named pipelines per type; one of them is the default that unnamed runs use.
Worked example: Workflow runs, with the total
API token holding execute_workflows, manage_approvals or manage_workflows. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/user-management/tasks?envelope=1&limit=1"
# Reply
200
{
"rows": [
{
"id": 311,
"workflowId": 4,
"title": "Onboard Dana Levi",
"currentStep": 2,
"userId": 12
// ... the remaining WorkflowRun fields; in a list each node is cut down and edges are left out
}
],
"total": 27
}
// Without ?envelope=1 the same call answers a bare array and no total.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/workflows | List workflow definitions and versions. body Query: ?includeArchived=true also returns soft-deleted (archived) versions returns 200 [ Workflow ] (typed under Response Objects) — every version row, ordered by name then version descending, each with purgeable:boolean (true only when no version of that pipeline is default, approved, or has ever been run) guard bare array, no paging | Session + manage_workflows | approve_workflows | execute_workflows | manage_approvals |
| POST | /user-management/workflows | Create a workflow definition (a new version of a named pipeline). body A WorkflowWrite. Client-writable: name (optional, trimmed to 120 chars — omit it and the standard name 'Master Onboarding' / 'Master Offboarding' is used), type ('onboarding'|'offboarding'), description, nodes[], edges[], automationMode ('manual'|'provisioning'|'auto'; anything else is treated as 'manual'), personaOverrides (can only leave items OUT of a persona, never add), status ('draft'|'pending'). version/isCurrent/createdBy/approvedBy are server-owned; other status values are ignored (forced 'draft'). returns 201 Workflow row. Versions are numbered per name, so a new name starts its own pipeline at v1; isCurrent true only if first of that name. guard mass-assignment guard: only the fields above are picked; status clamped to draft|pending (approved/rejected unreachable here). | Session + manage_workflows |
| PUT | /user-management/workflows/:id | Update a draft version. Status is not writable here. body Client-writable only: type, description, nodes[], edges[], automationMode, personaOverrides, status ('draft'|'pending') — the WorkflowWrite keys without name. status not settable to approved/rejected here. returns 200 Workflow. If editing nodes/edges of an approved version, FORKS a new 'pending' version (fresh row) instead of mutating in place; else 200 updated row. 404 if not found. guard manage_workflows + sudo. Same field whitelist as create; approved versions immutable in place (edits fork). | Session + manage_workflows + sudo |
| DELETE | /user-management/workflows/:id | Delete a workflow. body No body. returns 204 no content (soft-delete/paranoid: sets deletedAt, retained for audit). 400 if version isCurrent (promote another first). 404 if not found. guard manage_workflows + sudo. Refuses to archive the live (isCurrent) version. | Session + manage_workflows + sudo |
| POST | /user-management/workflows/:id/promote | Approve a pending version and put it live. body No body. Approver identity read from x-user header. returns 200 promoted Workflow (isCurrent/approved/isActive, approvedBy/At stamped; prior versions soft-archived). 403 no approver / SoD; 400 if definition invalid; 404 not found. guard approve_workflows + sudo. Separation of duties: author cannot approve own workflow unless administrator, and SoD can be toggled off per-install (fails CLOSED if policy unreadable). Definition must validate or 400. | Session + approve_workflows + sudo |
| POST | /user-management/workflows/:id/reject | Reject a version awaiting approval. body A WorkflowReject: { reason } required (non-empty string). returns 200 rejected Workflow (status 'rejected', rejectionReason stored, isCurrent/isActive false). 400 if reason missing or version already approved; 404 not found. guard approve_workflows + sudo. Reason mandatory; cannot reject an already-approved version. | Session + approve_workflows + sudo |
| POST | /user-management/workflows/:id/dry-run | Validate a definition with no side effects: graph integrity, per-node readiness, provider configuration. body No body. Validates the stored definition of :id. returns 200 { workflow:{id,name,version}, valid:bool, errors[], warnings[], nodes[{id,label,provider,status,messages}] }. 404 if not found. No side effects. guard manage_workflows. Pure validation (graph integrity + per-node provider readiness); executes nothing. | Session + manage_workflows |
| GET | /user-management/workflows/:id/export | Export a workflow as portable JSON. body No query parameters. returns 200 { zonitixWorkflow:1, name, type, version, description, automationMode, personaOverrides, nodes[], edges[], exportedAt } — the shape POST /user-management/workflows/import accepts | 404 'Workflow not found' | Session + manage_workflows |
| POST | /user-management/workflows/import | Import a JSON definition as a new draft version. body { type ('onboarding'|'offboarding'), nodes[] (non-empty) } required; edges[], name (trimmed, max 120 chars), description, automationMode, personaOverrides optional. status forced to 'draft'. returns 201 Workflow created as a new DRAFT version (never auto-activated). 400 if payload invalid / type wrong / nodes empty / edges not array. guard manage_workflows + sudo. Structural validation only; delegates to createWorkflow: the imported name is honoured (an omitted name falls back to the standard name) and versions are numbered per name, so only version and status (always 'draft') are server-owned. | Session + manage_workflows + sudo |
| POST | /user-management/workflows/bulk-decision | Promote or reject several pending versions in one call. body A WorkflowBulkDecision: { ids:[] (1 to 200 workflow ids; duplicates are processed once), action:'promote'|'reject' } required; reason required (non-empty) when action is 'reject' returns 200 { succeeded:[id], failed:[{ id, error }] } — one failure does not stop the rest | 400 unknown action, empty ids, more than 200 ids, or reject without a reason guard same guards as the single approve/reject; separation of duties and validate-before-promote are enforced per version | Session + approve_workflows + sudo |
| POST | /user-management/workflows/:id/clone | Copy an existing pipeline into a new named draft. It still has to pass approval before it runs. body A WorkflowClone: { name? } — optional name for the copy, trimmed to 120 characters; omitted, the copy is named '<source name> (copy)' returns 201 the Workflow row of the copy, status 'draft', as version 1 of a new pipeline | 404 'Workflow not found' | 409 the name already belongs to a pipeline (live or archived) guard manage_workflows + sudo. Not repeatable with the same name: the second call is refused with 409 instead of creating a duplicate | Session + manage_workflows + sudo |
| POST | /user-management/workflows/:id/retire | Take a whole named pipeline out of service, or bring it back. body A WorkflowRetire: { retired:boolean } — true retires, anything else reinstates returns 200 { name, type, isActive, versionsChanged } | 404 'Workflow not found' | 400 the pipeline is the default for its type, or it is the only active pipeline of its type guard acts on the pipeline name; refuses the default pipeline and the last active pipeline of a type; safe to repeat (it sets the state on every version of the name) | Session + manage_workflows + sudo |
| POST | /user-management/workflows/:id/default | Choose which pipeline every unnamed run uses. body No body. returns 200 the Workflow row, now isDefault:true (the flag is cleared on every other pipeline of the same type) | 404 'Workflow not found' | 400 the version is not the live approved one, or the pipeline is not active guard safe to repeat; audited | Session + manage_workflows |
| DELETE | /user-management/workflows/:id/purge | Permanently delete a never-used draft pipeline (hard delete, unlike the soft archive above). body No body. returns 200 { name, type, versionsPurged } — every version of that pipeline name is hard-deleted | 404 'Workflow not found' | 400 the pipeline is the default, has an approved version, or has been used by a run guard refused for anything approved, default, or ever run; a second call answers 404 (the rows are gone) | Session + manage_workflows + sudo |
| GET | /user-management/tasks | List workflow runs / task queue. body Query: ?limit (default 100, max 500), ?offset (default 0), ?status (one value or a comma-separated list, at most 10), ?q (free text, at most 100 characters; matches title, assignedTo, createdBy and string values in the run data), ?envelope=1 to get the total. No sort parameter: newest createdAt first returns 200 [ Task ] (bare array, the default) | with ?envelope=1: { rows:[ Task ], total } — list rows are trimmed: each node is reduced to { id, data:{ label, type } } and edges are omitted guard an administrator or manage_workflows holder sees every run; anyone else sees runs in their own queues, runs assigned to them, and runs they took part in | Session + execute_workflows | manage_approvals | manage_workflows |
| GET | /user-management/tasks/notifications | Bounded notification feed for the dashboard badge. body No query parameters. returns 200 [ Task ] — active runs only, newest first, at most 50, edges omitted guard scoped to the caller's queues unless they are an administrator or hold manage_workflows | Session + execute_workflows | manage_approvals | manage_workflows |
| POST | /user-management/tasks | Start a workflow run. body A WorkflowRunCreate. Allowlisted: workflowId (required), title (required), description, status, assignedTo, currentStep, data, userId, sourceCandidateId, startDate, dueDate (dates as YYYY-MM-DD). Any other key is dropped; the run's steps come from the stored pipeline, never from the request. returns 201 a WorkflowRun (the Task row) | 400 body is not a JSON object, title missing, workflowId missing or invalid, or a date that is not a real YYYY-MM-DD date | 403 the pipeline is rejected, retired, archived or not yet approved | 404 pipeline not found | 422 a leaver run on a pipeline with no revocation step that is not linked to an employee | 403 the persona confers access the caller may not grant guard execute_workflows. createdBy is set by the server. Not deduplicated: each call starts a new run. | Session + execute_workflows |
| GET | /user-management/tasks/:id | One run by id. Managers and administrators see any run; others only a run in their own queue. body No query parameters. returns 200 a WorkflowRun (the Task row, typed under Response Objects: title, status, assignedTo, currentStep, data, nodes, edges, startDate, dueDate, …) | 404 'Task not found' | 403 'Access Denied' for a run outside the caller's queues | Session + execute_workflows | manage_approvals | manage_workflows |
| POST | /user-management/tasks/:id/claim | Mark a step as being handled by you (audited; it does not change who may act). body No body. returns 200 the Task row; data gains <currentNodeId>_claimedBy and <currentNodeId>_claimedAt | 404 'Task not found' | 403 the run is not in the caller's queue, or no step is in progress guard safe to repeat: a later claim overwrites the stamp; audited as TASK_CLAIMED | Session + execute_workflows | manage_approvals |
| POST | /user-management/tasks/:id/recall | Send a run back to an earlier human step. Only allowed before provisioning has started. body A WorkflowRunRecall: { targetNodeId } required — a human step the run has already passed; reason optional returns 200 { recalledTo, reopened (steps reopened), notified (people told their completed step was reopened) } | 404 'Task not found' | 403 with the reason: the run is finished, the step is not in this run, is not a human step, is already the current step, is not behind the current step, the caller owns neither that step nor the current one, an automated step is running, or a step in between has already provisioned something or was attempted guard not repeatable: once recalled, the target is the current step and a second call is refused with 403 | Session + execute_workflows | manage_approvals |
| PUT | /user-management/tasks/:id | Update a run (status, assignment, data). body A WorkflowRunUpdate. Whitelisted only: status, assignedTo, data, currentStep. Other columns (workflowId, title, createdBy, id) ignored. returns 200 updated WorkflowRun (the Task row). 403 if caller not in the task's current queue; 403 if assignedTo move skips ahead in the graph; 404 not found. guard execute_workflows|manage_approvals. Queue-ownership (canActOnQueue: only administrator or the queue's group; manage_workflows does NOT bypass). completedBy/At forced to verified actor; assertLegalQueueMove blocks skipping an approval stage (fails OPEN if graph indeterminate). | Session + execute_workflows | manage_approvals |
| DELETE | /user-management/tasks/:id | Delete a run. body No body. returns 204 no content (hard destroy of the Task row). Succeeds even if row already gone. guard manage_workflows + sudo. | Session + manage_workflows + sudo |
| POST | /user-management/tasks/:id/execute-node | Execute one step of a run. This fires real external provisioning. body A WorkflowNodeExecute: { nodeId, taskData } — ONLY these taken from body. action/provider/target derived server-side from the run's stored graph. returns 200 execution result { success, ... nextStatus, message } (or { success:true, idempotent:true } if node already executed). 403 not in queue; throws if task/node missing. Fires REAL external provisioning. guard execute_workflows|manage_approvals + queue-ownership (canActOnQueue). Idempotent: refuses to re-run an already-executed node. Node must exist in the graph; action/provider never trusted from client. | Session + execute_workflows | manage_approvals |
| POST | /user-management/tasks/:id/kickoff | Run all currently-eligible automated steps for a run on demand. body No body. returns 200 { ranAutomated:int, message, task } — runs every currently-eligible automated step (stops at human gates). 404 if task not found; 403 if caller not in the task's queue. guard execute_workflows|manage_approvals + queue-ownership (canActOnQueue on task.assignedTo). Delegates to advanceTask, attributed to caller; fires real provisioning for the steps it runs. | Session + execute_workflows | manage_approvals |
| PUT | /user-management/tasks/:id/schedule | Move a run's dates when the start date changes. body A WorkflowRunSchedule: { startDate } — a real YYYY-MM-DD date, or null to clear it returns 200 { id, startDate, redated (outstanding dated steps moved) } | 400 startDate is not a real YYYY-MM-DD date | 403 not in the run's queue | 404 'Task not found' guard safe to repeat (sets the dates; steps already executed keep theirs); audited as TASK_RESCHEDULED | Session + execute_workflows | manage_approvals | manage_workflows |
| GET | /user-management/tasks/:id/evidence | The deprovisioning record for a run (read-only). body No query parameters. returns 200 the run's record, built from the run and the graph it was started with | 403 the caller cannot see this run | 404 'Task not found' | Session + execute_workflows | manage_approvals | manage_workflows |
| POST | /user-management/tasks/:id/reveal-credential | Show a new hire's temporary password once; it is purged after the reveal. body A CredentialReveal: { nodeId } required — the step that created the account returns 200 { password } | 400 'nodeId is required' | 403 not in the run's queue | 404 'Task not found' | 410 the password was already revealed, or it was delivered by email guard queue-ownership checked in the controller; single use: the stored value is deleted on the first reveal, so a repeat answers 410; audited as TASK_CREDENTIAL_REVEALED | Session + execute_workflows | manage_approvals | manage_workflows |
Identity Hooks & Alignment
integration-service
Connected identity/SaaS providers, drift detection against a source of truth, and governed write-back.
Worked example: Which identity hooks are connected
API token holding manage_hooks, manage_integrations or manage_compliance. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/providers/compliance-summary
# Reply
200
[
{ "id": 1, "type": "OKTA", "name": "Okta production", "isActive": true, "isVerified": true, "lastSync": "2026-10-09T22:00:04.000Z" },
{ "id": 2, "type": "AWS", "name": "AWS main account", "isActive": true, "isVerified": false, "lastSync": null }
]
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/providers | List configured provider hooks (secrets never returned). body No query parameters. returns 200 [ HookProvider ] (typed under Response Objects) — every hook in one reply (no paging); secret values are stripped from config and replaced by configuredCredentials[] (the names of the credentials that are set) guard bare array, no paging or filter | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes |
| POST | /user-management/providers | Create a provider hook. body { name, type, config, description?, isActive?, syncInterval?, canReadUsers?, canReadGroups?, canWriteUsers?, canWriteGroups?, canReadAllResources? } — only these are persisted returns 201 the HookProvider with configuredCredentials[] (secret ciphertext stripped) guard mass-assignment allowlist (PROVIDER_WRITABLE_FIELDS); isVerified is server-set, never accepted from body; not deduplicated: hook names are not unique, so repeating the call creates a second hook | Session + manage_hooks | manage_integrations |
| PUT | /user-management/providers/:id | Update a provider hook. body same allowlisted fields as create (all optional); config is MERGED into existing, not replaced returns 200 the HookProvider | 404 'Provider not found' guard allowlist blocks forging isVerified; changing a CONNECTION_FIELD auto-resets isVerified=false; omitted secret keys preserved so config edits don't wipe credentials | Session + manage_hooks | manage_integrations |
| DELETE | /user-management/providers/:id | Delete a provider hook. body No body. returns 204 empty | 404 'Provider not found' guard releases the users and groups the hook owned (they stay in the directory), deletes the hook's SaaS usage rows, and audits PROVIDER_DELETED with the counts | Session + manage_hooks | manage_integrations |
| POST | /user-management/providers/:id/test | Test connectivity/credentials for a hook. body No body (id in path). returns 200 testProviderConnection result | 404 | error passed to next() (logs PROVIDER_TEST_FAILED) | Session + manage_hooks | manage_integrations |
| POST | /user-management/providers/:id/validate | Validate per-capability readiness of a hook. body No body (id in path). returns 200 { ...checks result, provider:safe(p) }; persists isVerified=canFinish + force-disables any failed capability | 404 guard only forces capabilities OFF, never ON — an unchecked capability stays the operator's choice | Session + manage_hooks | manage_integrations |
| POST | /user-management/providers/:id/sync | Run a directory sync for a hook now. body { syncType?='delta', isManual?=true, dryRun?=false } all optional returns 200 { message, skipped?, unsupported?, inFlight?, partial? } | 404 'Provider not found'. A sync that did not run (skipped, unsupported, or one already in flight) and one that imported only part of the directory still answer 200; read the flags, not only message | Session + manage_hooks | manage_integrations |
| GET | /user-management/providers/:id/logs | Sync history and failures for a hook. body No query parameters. returns 200 [ ProviderSyncLog ] (typed under Response Objects) — the 10 most recent for that hook, newest first guard fixed at 10 rows; there is no paging parameter | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes |
| GET | /user-management/providers/:id/evidence/:type | Pull compliance evidence directly from a connected hook. body Path: :type names the evidence set to pull; what is accepted depends on the hook's type returns 200 the evidence data as returned by the provider adapter | 404 'Provider not found' (plain text) | 400 'Evidence fetching not supported for provider type: …' (plain text) for a hook that is not Okta, Entra ID, AWS, Intune or Jamf | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance |
| GET | /user-management/providers/calendar/freebusy | Calendar free/busy for interview scheduling (Google Workspace hook). body Query: ?emails= (comma-separated), ?from= and ?to= (dates; default now to now + 7 days) returns 200 { configured:true, calendars:{ [email]: { busy:[{ start, end }] } } } | 200 { configured:false } when no active Google Workspace hook exists | 200 { configured:true, error } when the service account is not authorised for calendars | 400 'from/to must be valid dates.' | Session + schedule_interviews | conduct_interview | manage_recruitment |
| GET | /user-management/providers/lifecycle-events | User-lifecycle change events across connected providers. body Query: ?since= (a date; ignored when it does not parse) returns 200 { events[], sources[] } | 200 { events:[], sources:[], error } when collection fails — the call does not answer 5xx for a provider error guard no paging parameters are read; ?since= is the only way to narrow the reply | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes |
| GET | /user-management/providers/cloud-summary | Which cloud hooks (AWS / Azure / GCP) exist and are verified. No configuration is returned. body No query parameters. returns 200 [ { id, type, name, isVerified } ] — cloud hooks only, id ascending guard bare array, every cloud hook in one reply (no paging) | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | view_cloud_infrastructure |
| GET | /user-management/providers/compliance-summary | Which hooks are connected, for the compliance pages. No configuration is returned. body No query parameters. returns 200 [ { id:integer, type:string (for example OKTA, AWS), name:string, isActive:boolean, isVerified:boolean, lastSync:string (date-time)|null } ] — every hook, id ascending | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance |
| GET | /user-management/providers/requestable-apps | Id, type and name of the active hooks, for the Access Requests catalogue. body No query parameters. returns 200 [ { id, type, name } ] — active hooks only, ordered by name guard bare array, every active hook in one reply (no paging) | Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | request_access | approve_access_request | manage_users |
| GET | /user-management/align/users | Users as seen across connected sources. body Query: ?limit (default 50, max 200), ?offset (default 0). No filter or sort parameter: ordered by email returns 200 [ { id, firstName, lastName, email, department, position, status, source, sourceProviderId, externalId, attributes } ] — bare array, no total guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| GET | /user-management/align/drift | Scan for drift between your source of truth and connected directories. body Query: ?refresh=1 (or true) bypasses the 5-minute cache returns 200 { connected, totalUsers, comparable, inSync, inDrift, localOnly, errors, syncPct, driftPct, byUser, truncated, sourceLabels, unscannedSources, cached:boolean } guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes; a fresh scan reads the connected directories and is audit-logged as IDENTITY_DRIFT_SCAN; concurrent callers share one scan | Session + view permission |
| GET | /user-management/align/drift/trend | Drift over time. body Query: ?limit (default 30, max 365) — the number of most recent snapshots returns 200 [ { date, syncPct, driftPct, inDrift, comparable } ], oldest first | 200 [] when the history cannot be read guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| GET | /user-management/align/orphans | Accounts present in a directory with no matching source record. body Query: ?refresh=1 (or true) bypasses the 5-minute cache returns 200 { generatedAt, connected, byProvider, totals:{ orphans, missing, statusMismatch }, cached:boolean } guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes; a fresh scan is audit-logged as IDENTITY_ORPHAN_SCAN | Session + view permission |
| GET | /user-management/align/compare | Field-by-field comparison for one user across sources. body Query: ?email= required returns 200 { email, profiles, matrix, hasConflicts } — compared on the fields the alignment policy has enabled | 400 'Email parameter is required' guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| GET | /user-management/align/policy | Read the alignment policy. body No query parameters. returns 200 the alignment policy object, in the shape PUT /user-management/align/policy accepts guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| PUT | /user-management/align/policy | Update the alignment policy. body policy object (e.g. { attributes:[{field,enabled,...}], autoReconcile:{...} }) passed whole to savePolicy returns 200 saved policy guard requireLicenseSoft('alignSyncEnabled') → 403 only if store explicitly disabled; POLICY perm (manage_alignment_policy|manage_hooks) | Session + policy permission |
| GET | /user-management/align/source-file | Read the uploaded source-of-truth file. body No query parameters. returns 200 { loaded:true, fields[], rowCount, uploadedAt, uploadedBy } | 200 { loaded:false } — metadata only, the rows are not returned guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| POST | /user-management/align/source-file | Upload a source-of-truth file. body { csv } required — CSV text string returns 200 { loaded:true, rowCount, fields[] } | 400 if csv missing/non-string or parse fails guard alignSync soft-license gate; POLICY perm; uploader taken from x-user header | Session + policy permission |
| DELETE | /user-management/align/source-file | Remove the source-of-truth file. body No body. returns 200 { loaded:false } guard alignSync soft-license gate; POLICY perm | Session + policy permission |
| POST | /user-management/align/sync | Write back corrections for a single user. body { email, sourceOfTruth, targets:string[] } required; dryRun? optional returns 200 alignUserAttributes result (with verification on real apply) | 400 'Invalid input payload'; failed apply audited then re-thrown guard alignSync soft-license gate; WRITEBACK perm (execute_identity_writeback|manage_hooks); real apply re-reads targets to confirm writes | Session + writeback permission |
| POST | /user-management/align/bulk-sync | Write back corrections in bulk. body { emails:string[], sourceOfTruth, targets:string[] } required; dryRun?, reason?, override? optional returns 200 { summary, results[], dryRun } | 200 { pendingApproval:true, requestId } if approval required | 400 invalid | 409 blast_radius_exceeded guard alignSync gate; perm approve_identity_changes|manage_hooks (SoD); real bulk at/over configured size routes to approval queue; blast-radius cap refuses >cap% of directory unless override:true | Session + manage_hooks | approve_identity_changes |
| GET | /user-management/align/history | History of write-back operations. body Query: ?limit (default 500, max 1000), ?offset (default 0). No filter parameter: newest first returns 200 [ IdentityAlignmentLog ] (typed under Response Objects) with source labels resolved — bare array, no total guard alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes | Session + view permission |
| POST | /user-management/align/rollback/:id | Roll back a previous write-back. body No body (log id in path). returns 200 executeRollback result | 400 'Log ID is required' guard alignSync soft-license gate; WRITEBACK perm; adminUser from x-user header | Session + writeback permission |
| GET | /user-management/align/change-requests | List identity change requests awaiting approval. body Query: ?status= (exact match, for example pending); omitted, every status is returned returns 200 [ AlignmentChangeRequest ] (typed under Response Objects) with source labels resolved — newest first, capped at 200 guard no paging parameter beyond the 200-row cap; when alignSync is switched off on the licence only held releases are listed | Session + approve_identity_changes |
| POST | /user-management/align/change-requests/:id/approve | Approve a change request. body { comment? } optional returns 200 apply outcome (successCount, failed, targets, mismatches...) | 409 { error } on invalid state guard alignSync gate; approve_identity_changes only (separation of duties — approver need not be an operator) | Session + approve_identity_changes |
| POST | /user-management/align/change-requests/:id/reject | Reject a change request. body { comment? } optional returns 200 updated request row | 409 { error } on invalid state guard alignSync gate; approve_identity_changes only (SoD) | Session + approve_identity_changes |
| GET | /user-management/discovery/summary | SaaS Discovery KPI tiles: the SaaS apps your people actually sign in to, read from connected IdP usage signals (Okta System Log, Google Workspace Reports) after a directory sync. body No query parameters. returns 200 { appsDiscovered, shadowItApps, dormantAccounts, activeUsers, sources } — degrades to an empty-but-valid shape rather than erroring guard alignSync soft-license gate (requireLicenseSoft('alignSyncEnabled') → 403 only on an explicit store disable); view_saas_discovery (a read-only right that implies view_dashboard) | Session + view_saas_discovery |
| GET | /user-management/discovery/apps | One record per discovered app. Apps with at least one account that is not a directory user (status 'unmanaged', shown as Unmatched accounts) are sorted to the top. body No query parameters. returns 200 { apps: [{ appKey, appName, category, sources, corroborated, userCount, activeUsers, dormantAccounts, status:'approved'|'unmanaged', lastActiveAt }] } — activeUsers = signed in within a rolling ~30-day window; corroborated = seen from 2+ sources; { apps: [] } on no data or on a read failure guard alignSync soft-license gate; view_saas_discovery. Read-only w.r.t. identity (never writes directory users); source-bounded to connected IdPs — not a network scanner or endpoint agent; every discovered app in one reply (no paging): unmanaged apps first, then by user count | Session + view_saas_discovery |
| GET | /user-management/discovery/apps/:appKey/users | Per-person drilldown for one discovered app. body No query parameters. returns 200 { appKey, users: [{ userId, userEmail, source, status, lastActiveAt }] }, most recently active first guard alignSync soft-license gate; view_saas_discovery. Active-user counts (matched users only) are pushed best-effort to Finance so the SaaS Spend page can compute reclaimable spend when the finance entitlement is licensed | Session + view_saas_discovery |
Compliance, Reporting & System Health
compliance-service
Readiness assessments, control states, remediation tracking, evidence storage, and the admin-approved backup/patch workflow. Compliance Hub reads accept view_reporting or manage_compliance. The Compliance Hub paths (/compliance/reports/compliance/*, /auth/audits/*) answer 403 when the Compliance module is explicitly switched off on your licence; system-health, patch and backup routes are never licence-gated.
Worked example: Host OS patch status
API token holding view_system. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/host-patches
# Reply
200 // the host agent has not reported yet: read this as "not known", not as "up to date"
{ "available": false, "os": null, "manager": "unknown", "total": 0, "security": 0, "packages": [], "checkedAt": null }
// Once it has reported: { "available": true, os, manager, total, security, phased, packages, refreshFailed, checkedAt }.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /compliance/metrics/overview | Dashboard compliance overview. body No query parameters. returns 200 { totalUsers, activeWorkflows, logStats:[{ level, count }], mfaActivations7, mfaActivations30, totalMfaEnabled, totalUsersForMfa, loginSuccess, loginFailure, authWindow, trendWindowDays:7, securityScore, uptimeScore, serviceStatuses[], loginTrends:[{ date, success, failure }] } guard securityScore is service health 50% + MFA coverage 25% + verified hooks 15% + sign-in success 10%; a call may also start a background evidence collection (rate-limited server-side) | Session + view_dashboard |
| GET | /compliance/metrics/tco | Total-cost-of-ownership metrics. body No query parameters. returns 200 { currentMonthlyBurn, totalAssetValue, projectedMonthlyAdd, projectedOneTimeAdd, projectedAnnualImpact, pipelineHeadcount, reclaimOpportunity, totalMonthlyWaste, optimizationAlerts[], personaDistribution:[{ name, userCount, monthlySpend }] } guard answers 403 when the Finance module is explicitly switched off on the licence | Session + view_finance |
| GET | /compliance/metrics/recent-critical-logs | Most recent critical log events. body No query parameters. returns 200 [ AuditLog ] — the 5 most recent entries at a critical level, newest first | Session + view_logs |
| GET | /compliance/reports/log-stats | Log volume/severity statistics. body No query parameters. returns 200 { levelDist:[{ level, count }], actionDist:[{ action, count }] (the 10 most frequent actions) } over the statistics window | Session + view_logs | view_dashboard |
| GET | /compliance/reports/stats | Headline reporting statistics. body No query parameters. returns 200 { devicesByType:[{ type, count }], usersByGroup:[{ name, userCount }], userStatusDist:[{ status, count }], userSourceDist:[{ source, count }], userSourceStatusDist:[{ source, status, count }], workflowStatusDist:[{ status, count }], taskStatusDist:[{ status, count }], providerHealth:[{ name, type, isVerified, isActive, lastSync, syncCount }] } | Session + view_dashboard | view_users |
| POST | /compliance/reports/compliance/assessments | Record a compliance assessment for a framework. The server computes it; nothing you post is scored. body { framework } required — a framework the instance knows. Any readinessScore / counts / findings in the body are ignored. returns 201 { id, createdAt, readinessScore, serverComputed:true, passCount, failCount, manualCount, naCount, total } | 400 unknown framework | 503 'Could not compute … — nothing was recorded' guard the server re-runs the same report-engine build the interactive report and the sealed artifact use and stores ITS readiness; there is no fallback to client numbers — if the server cannot compute, it records nothing and answers 503 | Session + view_reporting |
| GET | /compliance/reports/compliance/assessments | List past assessment runs. body Query: ?framework= (exact name), ?limit (default 50, max 200). No offset: newest first, automatic trend snapshots excluded returns 200 { history:[{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, generatedBy, createdAt }] } | Session + view_reporting | manage_compliance |
| GET | /compliance/reports/compliance/assessments/:id | Drill into one assessment run. body No query parameters. returns 200 { id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, findings, generatedBy, createdAt } | 404 'Assessment not found' | Session + view_reporting | manage_compliance |
| GET | /compliance/reports/compliance/trend | Readiness trend over time. body Query: ?framework= (exact name), ?limit (default 30, max 365) — the number of most recent automatic snapshots returns 200 { trend:[{ date, readinessScore, failCount, framework, total, applicable, verifiedScore }] }, oldest first | Session + view_reporting | manage_compliance |
| GET | /compliance/reports/compliance/control-states | Current state of each mapped control. body No query parameters. returns 200 [ ControlState ] ordered by controlId — fields include controlId, status, origin ('auto' | 'manual'), message, owner, dueDate, frameworks, sourceId, sourceType, signal, freshnessAt, freshnessTtlDays, submittedBy guard bare array, returns every control (no paging or filter) | Session + view_reporting | manage_compliance |
| POST | /compliance/reports/compliance/control-states/refresh | Re-evaluate automated control checks. body No body. Query: ?async=1 starts the collection in the background instead of waiting for it returns 200 { updated:int, states[], harvestOk, harvestError, report, failedCollectors:[{ id, label, error }] } | with ?async=1: 202 { running:true, joined:boolean, jobId } guard a collection failure is reported in harvestOk / failedCollectors, not as an error status: the rollup still runs on the last good evidence. Safe to repeat; with ?async=1 a call made while a job is running joins it (joined:true) | Session + manage_compliance |
| PUT | /compliance/reports/compliance/control-states/:controlId | Manually set a control state. body controlId in path; body patch may include status, owner, dueDate, message, frameworks — only those keys applied returns 200 { control state row } | 400 if controlId missing guard marks row origin='manual' so auto-rollup leaves it alone until cleared | Session + manage_compliance |
| POST | /compliance/reports/compliance/control-states/:controlId/attest | Record a manual pass on a control, governed by the attestation policy. This replaces the legacy attestation write. body { framework } required; note — required when the attestation policy is note_required (the default) returns 200 the ControlState row, now a manual Pass | 400 framework missing, or a note is required | 403 the policy is review_only | 409 { message, reason:'AUTO_FAIL_NEEDS_EVIDENCE' } an automated check is failing and no evidence is attached | 409 { message, reason:'changed' } the control changed since it was read guard audited as COMPLIANCE_CONTROL_ATTESTED, or COMPLIANCE_CONTROL_ATTEST_OVERRODE_REVIEW (warning) when it overrides a review someone else submitted; refusals are audited too | Session + manage_compliance |
| POST | /compliance/reports/compliance/control-states/:controlId/submit | Submit a control for review (the assignee side of the submit-then-review path). body { framework } required returns 200 the ControlState row, status in_review | 400 framework missing | 403 the control is not assigned to the caller (and the caller lacks manage_compliance) | 409 { message, reason } with reason already_in_review, already_passing, not_applicable or changed | 409 an automated check is failing and no evidence is attached guard guarded by a state check, so a repeated submit is refused with 409 already_in_review rather than queued twice; reviewers are notified | Session + view_assigned_compliance | manage_compliance |
| POST | /compliance/reports/compliance/control-states/:controlId/review | Review a submitted control (the reviewer side; separation of duties applies). body { framework, decision:'approve'|'reopen' } required; reviewNotes required when decision is 'reopen' returns 200 the ControlState row | 400 a required field is missing or decision is not approve|reopen | 403 separation of duties: the caller submitted this control and is not an administrator (when the review policy requires a separate reviewer) | 409 the control was not submitted, or is not awaiting review | 409 { message, reason:'changed' } guard guarded by a state check: once decided the control is no longer in review, so a repeat is refused with 409; the owner is notified of the outcome | Session + manage_compliance |
| POST | /compliance/reports/compliance/control-states/:controlId/revoke-pass | Withdraw a manual pass. body { note } required (at most 1000 characters); framework optional (names the framework in the owner's notification) returns 200 the ControlState row | 400 note missing or too long | 409 { message, reason } with reason absent, not_manual, not_passing or changed guard only a manual pass can be revoked; a repeat is refused with 409 not_passing; audited at warning level and the owners are notified | Session + manage_compliance |
| POST | /compliance/reports/compliance/control-states/:controlId/return-to-automated | Hand a manually-set control back to the automated checks. body No body. returns 200 { returned:true } | 409 { message, reason } with reason in_review, already_automated, no_collector or changed guard a repeat is refused with 409 already_automated; audited | Session + manage_compliance |
| GET | /compliance/reports/compliance/attestation-policy | Read the policy that governs manual attestations. body No query parameters. returns 200 { mode:'allow'|'note_required'|'review_only' } — note_required when nothing is saved | Session + view_reporting | manage_compliance |
| POST | /compliance/reports/compliance/attestation-policy | Change the attestation policy. body { mode:'allow'|'note_required'|'review_only' } returns 200 { mode } — the mode now in force guard an unknown mode is stored as note_required, not rejected; safe to repeat; audited as COMPLIANCE_ATTESTATION_POLICY_SET | Session + manage_security |
| GET | /compliance/reports/compliance/attestations | Read-only history of the legacy attestations. New manual verdicts are recorded on the control itself (see control-states/:controlId/attest). body Query: ?framework= (exact name) returns 200 { attestations:[{ framework, controlId, satisfied, note, attestedBy, attestedAt }] } guard returns every matching row (no paging) | Session + view_reporting | manage_compliance |
| POST | /compliance/reports/compliance/attestations | Retired. Legacy attestation write — use control-states/:controlId/attest, or submit then review. body Ignored. returns 410 Gone { message, governedPath:{ attest, submit, review } } — nothing is written | Session + manage_compliance |
| DELETE | /compliance/reports/compliance/attestations | Retired. Legacy attestation delete. body Ignored. returns 410 Gone { message, governedPath } — nothing is deleted | Session + manage_compliance |
| GET | /compliance/reports/compliance/assignments | List control ownership assignments. body Query: ?framework= (exact name) returns 200 [ ComplianceAssignment ] ordered by domain — framework, domain, assigneeId, assigneeName, assigneeEmail, dueDate, note, assignedBy guard bare array, no paging | Session + view_reporting | manage_compliance |
| GET | /compliance/reports/compliance/my-assignments | Controls assigned to you. body No query parameters. returns 200 [ ComplianceAssignment ] where the caller is the assignee (matched on user id or email), earliest dueDate first | 200 [] when the caller cannot be identified guard bare array, every assignment of the caller in one reply (no paging) | Session + view_assigned_compliance | manage_compliance | view_reporting |
| POST | /compliance/reports/compliance/assignments | Assign a control to an owner with a deadline. body { framework, domain } required; assigneeId/assigneeName/assigneeEmail/dueDate/note optional returns 200 { assignment row } | 400 if framework or domain missing guard assignedBy is set by the server from the session; upsert by framework+domain; safe to repeat (the same framework and domain update the existing row) | Session + manage_compliance |
| DELETE | /compliance/reports/compliance/assignments | Remove an assignment. body No body — framework & domain passed as QUERY params (both required) returns 200 { ok:true } | 400 if framework or domain missing | Session + manage_compliance |
| POST | /compliance/reports/compliance/reminders/send | Email deadline reminders to control owners. body No body (uses session for audit context). returns 200 { result } summarizing reminder emails sent to assignees guard email is best-effort/gated: nothing sends unless SMTP + recipients configured | Session + manage_compliance |
| GET | /compliance/reports/compliance/remediations | List tracked remediations. body Query: ?framework= (exact name) returns 200 { remediations:[{ id, framework, controlId, title, owner, dueDate, status, createdBy, updatedAt }] }, earliest dueDate first guard returns every matching row (no paging) | Session + view_reporting | manage_compliance |
| POST | /compliance/reports/compliance/remediations | Create or update a remediation item. body { framework, controlId, title } required; owner/dueDate/status optional (status default 'open') returns 201 { id, framework, controlId, title, owner, dueDate, status } | 400 if framework/controlId/title missing guard upsert by framework+controlId; safe to repeat (the same framework and controlId update the existing row) | Session + manage_compliance |
| DELETE | /compliance/reports/compliance/remediations | Delete a remediation item. body No body — framework & controlId passed as QUERY params returns 200 { ok:bool } (false if nothing matched) | Session + manage_compliance |
| GET | /compliance/reports/compliance/digest/config | Read the stored configuration of the retired compliance digest (read-only; the digest no longer sends). body No query parameters. returns 200 { enabled, recipients[], intervalHours, framework, lastSent, smtpConfigured, retired:true, replacement } | Session + view_reporting | manage_compliance |
| POST | /compliance/reports/compliance/digest/config | Retired. The compliance digest was replaced by Scheduled reports (/compliance/reports/schedules). body Ignored. returns 410 Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged guard an enabled digest was carried into a scheduled report automatically when the digest was retired | Session + manage_compliance |
| POST | /compliance/reports/compliance/digest/send | Retired. Use Scheduled reports to send a report now (/compliance/reports/schedules/:id/run). body Ignored. returns 410 Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged | Session + manage_compliance |
| POST | /compliance/reports/compliance/audit | Write a report-engine action to the audit trail. body { event: 'EXPORT'|'REMEDIATION' } required (case-insensitive); detail (optional, truncated 300), framework optional returns 200 { ok:true } | 400 { error:'Unknown report audit event' } guard event whitelist: only EXPORT/REMEDIATION map to an audit action, anything else rejected | Session + view_reporting |
| GET | /compliance/reports/system-health | Live host load and container status (via a read-only Docker socket proxy). body No query parameters. returns 200 { at, host:{ cpuCores, load1, load5, load15, loadPct, memTotal, memUsed, memPct, swapTotal, swapUsed, swapPct, disk }, dockerAvailable, containerCount, runningCount, unhealthyCount, containers:[{ name, image, state, status, cpuPct, memBytes, memPct, unhealthy }] } — containers sorted by CPU, highest first guard the result is cached for 25 seconds, so polling faster returns the same reading | Session + view_system |
| GET | /compliance/reports/patch-status | Application (git) update status. body No query parameters. returns 200 { current:{ commit, tag, committedAt, deployedAt }, repo, branch, upToDate (true | false | null when the check itself failed), behind, securityBehind, updates[], checkedAt, source, … } | Session + view_system |
| GET | /compliance/reports/host-patches | Host OS patch status. body No query parameters. returns 200 { available:true, os:string|null, manager:string, total:integer, security:integer, phased:integer, packages:array, refreshFailed:boolean, checkedAt:string|null } | 200 { available:false, os:null, manager:'unknown', total:0, security:0, packages:[], checkedAt:null } when the host agent has not reported yet | Session + view_system |
| GET | /compliance/reports/update-status | State of any pending update request. body No query parameters. returns 200 { status, pending, pendingApproval, backupConfig:{ offboxConfigured, … }, pendingTtlMs } — status is the host agent's last report; pending is a request in flight; pendingApproval is one waiting for an approver | Session + view_system |
| POST | /compliance/reports/update-request | Request a backup, patch, or OS update. body { mode } required — one of backup|security|full|os-security|os-full returns 202 { status:'dispatched'|'pending_approval', request } | 400 invalid mode | 409 if one already queued/awaiting approval guard admins auto-approve (dispatched immediately); non-admins parked for approval; approver notify is best-effort/non-blocking; not repeatable while one is open: a second request is refused with 409 until the first finishes, is rejected, or expires | Session + request_system_updates |
| POST | /compliance/reports/update-approve | Approve a requested update so the host agent may run it. body No body (approver = session user). returns 200 { approved:true, request } | 404 if none awaiting | 409 if op already in progress guard for destructive restore/delete-backup, approver must differ from requester (403); dispatches to host agent; acts on the single pending request, so a repeat answers 404 (nothing awaiting) or 409 (already in progress) and never runs it twice | Session + approve_system_updates |
| POST | /compliance/reports/update-reject | Reject a requested update. body No body (rejecter = session user). returns 200 { rejected:true, request } | 404 if none awaiting approval guard a repeat answers 404 (nothing awaiting approval) | Session + approve_system_updates |
| GET | /compliance/reports/maintenance-status | State of the app-wide maintenance notice. body No query parameters. returns 200 { state:'none' } | { state:'scheduled', at } (a maintenance window starts within 60 minutes) | { state:'running', kind:'update'|'restore', rollingBack, scheduled } guard readable by every signed-in user; carries no release tag, host detail or requester; any internal failure reads as state 'none' | Session |
| GET | /compliance/reports/auto-update-config | Read the automatic update schedule. body No query parameters. returns 200 { enabled:boolean, day:'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat', time:string (HH:MM), timezone:string (IANA zone), timezoneSaved:boolean, osSecurity:boolean, osFull:boolean, maxMinutes:integer, appSecurity:boolean, appFull:boolean, smtpConfigured:boolean } guard a setting never saved reads as enabled:false, appSecurity:false, appFull:false, osSecurity:true, osFull:false, day:'sat', time:'02:00', maxMinutes:60; a new instance is seeded with enabled, appSecurity, appFull and osSecurity on; appFull:true implies appSecurity:true; with no timezone saved, timezone is the instance's regional zone and timezoneSaved is false | Session + view_system | request_system_updates |
| POST | /compliance/reports/auto-update-config | Save the automatic update schedule: the weekly maintenance window and what it installs. body { enabled:boolean, day:'sun'..'sat', time:string (24-hour HH:MM), timezone:string (IANA zone), osSecurity:boolean, osFull:boolean, appSecurity:boolean, appFull:boolean, maxMinutes:integer } — every field optional; only the fields sent are changed returns 200 the object the GET answers | 400 { error } for a day, time or timezone the server cannot schedule (nothing is stored) | 400 { error:'Configure email (SMTP) first — automatic updates require it to send reminders.' } when enabled:true is sent while the schedule is off and no email is configured guard maxMinutes is held between 30 and 240; an instance that is already on may move its window before email is set up, and the scheduler starts nothing until email works; safe to repeat; audited as AUTO_UPDATE_CONFIGURED | Session + request_system_updates |
| GET | /compliance/reports/file-archives | List file-store archives (signed documents and the signing key, compliance evidence, candidate résumés), newest first. body No query parameters. returns 200 [ { filename, sizeBytes, modifiedAt, local, offbox, restorable, fetch, bucket, foundInStorage? } ] guard bare array; no paging parameters are read | Session + view_system |
| POST | /compliance/reports/restore-files-request | Request a restore of the file store from one archive. A different approver must approve it. body { target } required — an archive filename matching zonitix_files_*.tar.gz, no path returns 202 { status:'pending_approval', request } | 400 invalid target | 409 another request is pending or running guard always parked for a second administrator; audited as FILES_RESTORE_REQUESTED; expires after 30 minutes | Session + approve_system_updates |
| POST | /compliance/reports/restore-files-approve | Approve the pending file restore so the host agent may run it. body No body (approver = session user). returns 200 { approved:true, request } | 403 approver is the requester | 404 nothing awaiting approval | 409 the pending request is not a file restore guard audited as FILES_RESTORE_APPROVED; the host agent takes a safety copy of the current files first | Session + approve_system_updates |
| GET | /compliance/reports/backup-destination | Read the configured off-box backup destination. body No query parameters. returns 200 { enabled, provider, credentialSource:'manual'|'provider', providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret:boolean, encrypted:boolean, destinationChangedAt, restoreTest:{ status, ageDays, stale, … }, restoreDrill } — with nothing saved: enabled:false, provider:'s3', empty strings guard the secret access key is never returned, only hasSecret; restoreTest.stale is true when the last restore test is more than 14 days old, and status is 'never' when none has run | Session + view_system |
| POST | /compliance/reports/backup-destination | Configure the backup destination: an S3-compatible bucket or an Azure Blob Storage container that you own. body { enabled:bool, provider, credentialSource ('manual'|'provider'), providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey } — all optional but validated when enabled returns 200 { enabled, provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret } (secret never returned) | 400 missing bucket/creds when enabled guard when enabled: bucket required; manual needs accessKeyId+secretAccessKey; provider source needs providerId; blank secret keeps stored one, switching provider discards it; safe to repeat (it overwrites the one stored destination) | Session + approve_system_updates |
| POST | /compliance/reports/backup-destination/test | Verify the backup destination is reachable and writable. body Same shape as save (transient, never persisted): provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey returns 200 { bucket, creds, write, read, prune } per-check {ok,error} results | 400 if bucket missing or provider without providerId guard tests the POSTed values, not stored config; runs a real write/read/prune probe against the bucket | Session + approve_system_updates |
| GET | /compliance/reports/backup-setup | Mandatory backup setup: the gate every screen of the dashboard reads, plus the checklist for people who can see the System screens. body No query parameters. returns 200 { gate:{ phase:'storage'|'escrow'|'complete', mode:'fresh'|'existing'|null, completed:boolean, proven:boolean, graceEndsAt:string|null, escrowDeadlineAt:string|null, locked:boolean, inGrace:boolean, reason:string } } — with view_system or the administrator role also steps:{ destination:{ done, bucket, provider }, encryption:{ done }, firstBackup:{ done, provenAt, file, uploaded, lastVerify }, keys:{ done, items[] }, agentInstalled }, health:{ state:'ok'|'failing'|'unknown', reason, detail, since, lastCheckAt, lastGoodAt }, policy:{ graceDays:30, escrowDays:14 } guard readable by every signed-in user; reason is one of origin_unknown, complete, storage_required, storage_grace, escrow_required, escrow_pending; with no origin recorded the gate answers locked:false | Session |
| POST | /compliance/reports/backup-setup/verify | Check again: read the newest uploaded backup back from the destination, verify it, and advance backup setup if the facts allow it. body No body. returns 200 { result:{ advanced:('proven'|'completed')[], reason:string|null, error? }, gate, steps, health, policy } — the same gate, steps, health and policy the GET answers guard result.reason when nothing advanced: no_destination, no_backup_key, no_uploaded_backup, verify_failed (with error), keys_not_escrowed or complete, all with 200; safe to repeat; a first verification is audited as BACKUP_SETUP_PROVEN, a failed read-back as BACKUP_SETUP_VERIFY_FAILED, completion as BACKUP_SETUP_COMPLETED | Session + approve_system_updates |
| GET | /compliance/reports/backup-providers | List already-connected AWS providers reusable as backup credentials. body No query parameters. returns 200 [ { id, name, region, verified } ] — AWS hooks only guard bare array; no paging parameters are read | Session + approve_system_updates |
| GET | /compliance/devices | Compliance view of registered devices. body No query parameters. returns 200 [ Device ] — each with Owner:{ id, email }; devices whose enrolment was explicitly not approved are left out guard bare array, returns every device (no paging). For a paged fleet list use GET /user-management/devices | Session + view_dashboard | manage_compliance |
| POST | /compliance/devices | Register a device for compliance tracking. body Allowlisted: name (the Device model does not allow it to be empty), type, serialNumber (unique), status (default 'available'), manufacturer, userId, assetTag, department, location, costCenter. Any other key is dropped and the audit entry DEVICE_INVENTORY_CREATED lists it in droppedFields. returns 201 { created device row } guard serialNumber has a unique constraint (DB error on duplicate); a repeat with the same serialNumber fails on the unique constraint instead of creating a duplicate, and a repeat without one creates a second device | Session + manage_compliance |
| POST | /evidence/upload | Upload an evidence file (multipart). body multipart/form-data, single field 'file' (multer). No JSON body. returns 200 { attachmentUrl, sha256 } | 400 'No file uploaded' if file missing | 403 a caller holding only view_assigned_compliance who does not own the control | 413 over 25 MB guard one file per request; needs the Compliance Hub licence; a caller without manage_compliance must send controlId and framework and may attach only to a control assigned to them; logs COMPLIANCE_EVIDENCE_UPLOADED (SOC2 CC7.2) with uploader identity | Session + manage_compliance | view_assigned_compliance |
| GET | /evidence/files/:filename | Download a stored evidence file. body Path: :filename — the stored name returned by the upload, with no path separators returns 200 the file as a download | 400 'Invalid filename' (plain text) when the name contains .., / or \ | 404 'File not found' (plain text) guard not licence-gated, so evidence uploaded while licensed stays readable; every download is audit-logged as COMPLIANCE_EVIDENCE_DOWNLOADED | Session + manage_compliance | view_assigned_compliance | view_reporting |
Security Lab
security-lab-service
Automated probes and scans. Reached through the gateway under /auth/security-lab. Launching requires the Security Lab entitlement on your license.
Worked example: Current findings, before any scan has run
Session cookie of a user holding a Security Lab view permission. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/auth/security-lab/results
# Reply
200
{ "findings": [], "riskScore": null, "lastScanAt": null, "lastScanBy": null, "total": 0 }
// riskScore is null when there is nothing to score. Read it as "not known", never as "secure".
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /auth/security-lab/launch | Launch a probe run. body { attackType } required, targetUrl? optional. attackType selects the probe; targetUrl null/omitted = internal (local) probe, present = remote target. returns 200 { ok:true, result } | probe result shape from launchAttack. Writes SECURITY_PROBE_LAUNCHED audit log. guard canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled') | Session + run_security_scan | manage_compliance (licensed) |
| GET | /auth/security-lab/results | Latest probe results (compliant vs at-risk). body No query parameters (the gateway forwards none). returns 200 { findings:array (each a SecurityFinding plus its own riskScore), riskScore:number|null (null = nothing to score, not 'secure'), lastScanAt:string (date-time)|null, lastScanBy:string|null, total:integer (the length of findings) } — archived findings excluded, most recently seen first guard Returns every open finding (no paging) | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/assets | Discovered assets in scope. body No query parameters (the gateway forwards none). returns 200 [ asset ] guard bare array, every asset in one reply (no paging), ordered by type then name | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/scopes | List authorised scan scopes. body No query parameters (the gateway forwards none). returns 200 [ SecurityScope ] — the stored scope rows (name, description, allowedHosts, allowedCidrs, maxProfile, ownerAttestation, enabled, …) with any credential inside testEndpoints masked guard bare array, every scope in one reply (no paging), newest first | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/scopes | Define a scan scope you are authorised to test. body { name } required; description?, allowedHosts?:string[], allowedCidrs?:string[], maxProfile?='standard', ownerAttestation?:bool=false, enabled?:bool=true. returns 201 SecurityScope object | 400 {error} if name missing. Writes SECURITY_SCOPE_CREATED log. guard canManageScopes perm (manage_security_scopes OR manage_compliance) | Session + manage_security_scopes | manage_compliance |
| POST | /auth/security-lab/scans | Create a scan. body { suite?='full', profile?='safe'|'standard'|'aggressive' (default standard), targetMode?='local'|'remote', targetUrl? (required when remote, ignored otherwise), attestation?: true, targetAuth? (remote only; used for that scan), allowInsecureTLS? (remote only; audit-logged) } returns 202 SecurityScan | 400 if remote w/o targetUrl | 401 SUDO_REQUIRED if profile=aggressive w/o a fresh step-up | 403 NOT_AUTHORIZED_TARGET | 403 SCOPE_PROFILE_EXCEEDED guard canRun + requireLicense('securityLabEnabled'). The target guard fails CLOSED: a remote host outside an enabled, ownership-verified scope is refused (403 NOT_AUTHORIZED_TARGET) unless the body carries attestation:true, which clamps the scan to the safe profile and is logged as SECURITY_REMOTE_ATTESTATION. A profile above the scope's maxProfile is 403 SCOPE_PROFILE_EXCEEDED. Aggressive needs a fresh step-up, local or remote. | Session + run_security_scan | manage_compliance (licensed) |
| GET | /auth/security-lab/scans | List scans. body No query parameters (the gateway forwards none). returns 200 [ SecurityScan ] — the 50 most recent, newest first guard Fixed at 50 rows, there is no paging parameter | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/scans/:id | Scan detail and live status. body No query parameters. returns 200 { scan, findings[], findingsSource:'live'|'snapshot' } — 'snapshot' when the frozen copy taken for the report holds more findings than the live table | 404 'Scan not found' | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/scans/:id/report | Export a scan report. body Query: ?format= json (default) | markdown (or md) | sarif | csv returns 200 the report as JSON, Markdown (text/markdown), SARIF (JSON) or CSV (attachment zonitix-scan-<id>.csv) | 404 'Scan not found' guard The report is built from a snapshot of the scan's findings, taken on first request | Session + export_security_report | view_security_findings | manage_compliance |
| POST | /auth/security-lab/scans/:id/abort | Abort a running scan. body No body. Scan id in path. returns 200 updated SecurityScan | 404 if scan not found. Writes SECURITY_SCAN_ABORTED log (warning). guard canRun perm (run_security_scan OR manage_compliance). | Session + run_security_scan | manage_compliance |
| PATCH | /auth/security-lab/findings/:id | Update a finding status (triage, accept risk, close). body All optional: status (open|acknowledged|in_remediation|retest_pending|risk_accepted|false_positive), statusNote, owner, ticketRef. Only supplied fields update. returns 200 updated SecurityFinding | 404 not found | 400 invalid status | 400 if risk_accepted/false_positive without a statusNote justification | for risk_accepted also 400 EXPIRY_REQUIRED without acceptedUntil (a future date) and 409 EXCEPTION_EXISTS if an active or pending exception already exists. guard risk_accepted is written through the exception register: every acceptance expires, and for Critical and High findings it is created pending and the finding stays open until a different person approves. canManageFindings perm (manage_security_findings OR manage_compliance). 'verified_fixed' is NOT settable here — only a clean re-scan reaches it. | Session + manage_security_findings | manage_compliance |
| POST | /auth/security-lab/findings/:id/retest | Retest a single finding. body No body. Finding id in path; probe+target derived from the stored finding. returns 200 { result, finding } (re-fetched finding, may become verified_fixed on clean result) | 404 if finding not found. guard canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled') | Session + run_security_scan | manage_compliance (licensed) |
Cloud Discovery (AWS / Azure / GCP)
security-lab-service
Read-only cloud inventory, topology, identity-fusion findings, and governed remediation. All require the cloud-mapping entitlement; remediation additionally requires sudo.
Worked example: Stored snapshots for one AWS hook
Session cookie of a user holding view_cloud_infrastructure; the module must be on your plan. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/auth/aws-snapshots/2
# Reply
200 // newest first; an empty array when none has been captured
[
{ "id": 41, "snapshotDate": "2026-10-09T02:00:00.000Z", "metadata": { ... } }
]
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /auth/aws-resources/:providerId | Current AWS inventory and topology for a connected account. body Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists returns 200 the resource map for that AWS hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set guard a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/aws-snapshots/:providerId | List AWS discovery snapshots. body No query parameters. returns 200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that AWS hook, newest first — the captured inventory is not included; fetch one snapshot for it guard bare array, returns every snapshot (no paging) | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/aws-snapshot-detail/:id | One AWS snapshot in full. body No query parameters. returns 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found' | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/aws-changes/:providerId | What changed in the AWS inventory between two discovery snapshots. body Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it returns 200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.' guard read-only; one object, not a list | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/aws-identity-findings/:providerId | Identity-fusion findings (IAM principals cross-referenced with your employee directory). body No query parameters. returns 200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND guard computed on request against the cloud provider (long-running); nothing is stored by this call | Session + view_cloud_infrastructure (licensed) |
| POST | /auth/aws-identity-remediate/:providerId | Execute a governed remediation for an AWS identity finding. body { finding: { remediable:boolean, keyIds:string[], principal:string } } required — the finding object from the identity-findings GET (req.body.finding) returns 200 dry-run(default) { ok:true, dryRun:true, planned[], message } | 200 executed { ok:true, dryRun:false, executed[], message } | 404 provider not found | 400 if finding not remediable / no keyIds guard manage_compliance OR manage_security + sudo + license awsMappingEnabled; DRY RUN by default — only mutates AWS keys when REMEDIATION_DRY_RUN=false | Session + manage_compliance | manage_security + sudo |
| POST | /auth/aws-resources/:providerId/sync-invoices | Pull AWS cost/billing data into the finance ledger. body No body. providerId in path. returns 200 { message, ingested:number } | 200 { message:'No invoices found...', ingested:0 } | 500 'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...' guard manage_compliance. Parser hand-off best-effort: per-invoice ingest errors are logged, not surfaced | Session + manage_compliance |
| GET | /auth/azure-resources/:providerId | Current Azure inventory and topology. body Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists returns 200 the resource map for that Azure hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set guard a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/azure-snapshots/:providerId | List Azure discovery snapshots. body No query parameters. returns 200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that Azure hook, newest first — the captured inventory is not included; fetch one snapshot for it guard bare array, returns every snapshot (no paging) | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/azure-snapshot-detail/:id | One Azure snapshot in full. body No query parameters. returns 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found' | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/azure-changes/:providerId | What changed in the Azure inventory between two discovery snapshots. body Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it returns 200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.' guard read-only; one object, not a list | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/azure-identity-findings/:providerId | Azure/Entra ID identity findings. body No query parameters. returns 200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND guard computed on request against the cloud provider (long-running); nothing is stored by this call | Session + view_cloud_infrastructure (licensed) |
| POST | /auth/azure-identity-remediate/:providerId | Execute a governed remediation for an Azure identity finding. body { finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding) returns 200 { ok:false, automated:false, planned[], message:'Automated remediation for AZURE_AD isnt available yet...' } — only AWS is automated | 404 provider not found | 400 if finding not remediable / no keyIds guard manage_compliance OR manage_security + sudo + license awsMappingEnabled. Azure path always returns honest not-automated (no cloud mutation) | Session + manage_compliance | manage_security + sudo |
| POST | /auth/azure-resources/:providerId/sync-invoices | Pull Azure cost data into the finance ledger. body No body. providerId in path. returns 200 { message, ingested:number, totalCost:number, currency, subscriptionId, periodFrom, periodTo } | 500 'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...' guard manage_compliance. Requires azureSubscriptionId on provider config and Cost Management Reader role | Session + manage_compliance |
| GET | /auth/gcp-resources/:providerId | Current GCP inventory and topology. body Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists returns 200 the resource map for that GCP hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set guard a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/gcp-snapshots/:providerId | List GCP discovery snapshots. body No query parameters. returns 200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that GCP hook, newest first — the captured inventory is not included; fetch one snapshot for it guard bare array, returns every snapshot (no paging) | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/gcp-snapshot-detail/:id | One GCP snapshot in full. body No query parameters. returns 200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found' | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/gcp-changes/:providerId | What changed in the GCP inventory between two discovery snapshots. body Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it returns 200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.' guard read-only; one object, not a list | Session + view_cloud_infrastructure (licensed) |
| GET | /auth/gcp-identity-findings/:providerId | GCP identity findings. body No query parameters. returns 200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND guard computed on request against the cloud provider (long-running); nothing is stored by this call | Session + view_cloud_infrastructure (licensed) |
| POST | /auth/gcp-identity-remediate/:providerId | Execute a governed remediation for a GCP identity finding. body { finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding) returns 200 { ok:false, automated:false, planned[], message:'Automated remediation for GCP isnt available yet...' } — only AWS automated | 404 provider not found | 400 if finding not remediable / no keyIds guard manage_compliance OR manage_security + sudo + license awsMappingEnabled. GCP path always returns honest not-automated (no cloud mutation) | Session + manage_compliance | manage_security + sudo |
Recruitment & Hiring
recruitment-service
Requisitions and their headcount budget. Note the deliberate separation of duties: the finance gate on a requisition requires manage_finance alone, so a recruiter cannot approve their own headcount. Rows marked (licensed) sit behind the recruitment licence check and answer 403 when recruitment is switched off for your plan and 402 while the subscription is lapsed; rows without it stay reachable regardless. The administrator role passes every permission check. A capitalised name in a body or returns line, such as JobPosting, Candidate, Offer or Interview, is an object typed field by field under Data Objects on the Recruitment page. The gateway in front of the service adds answers of its own, each { error:string }: 401 "No token provided" with no session or token, 503 "Service temporarily unavailable" while the service behind it keeps failing, and 504 "Gateway Timeout" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.
Worked example: The live requisitions, for a picker
API token holding view_recruitment, manage_recruitment or source_candidates. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/recruitment/job-postings/options
# Reply
200 // a bare array, ordered by title; the talent pool is left out
[
{ "id": 14, "title": "Finance Manager", "department": "Finance", "status": "Published" },
{ "id": 9, "title": "Support Engineer", "department": null, "status": "Open" }
]
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /recruitment/job-postings | List job postings. body No query parameters are read: the list is not paged, filtered or sorted by the caller. returns 200 array of JobPosting — a bare array with no envelope. Every caller the route admits gets every requisition; the Candidates preview holds only candidates in that caller's own candidate scope. The talent-pool requisition is never listed. Each row carries candidateCount, stageCounts and a Candidates preview of at most the 6 newest applicants guard a caller who holds manage_finance but none of manage_recruitment, view_recruitment or conduct_interview gets every posting with an empty Candidates list | Session + manage_recruitment | view_recruitment | manage_finance (licensed) |
| GET | /recruitment/job-postings/options | Requisition choices for the Add candidate picker. body No query parameters; not paged. returns 200 [{ id:integer, title:string, department:string|null, status:string }], ordered by title — live requisitions only, the talent pool excluded | Session + manage_recruitment | view_recruitment | source_candidates (licensed) |
| POST | /recruitment/job-postings | Create a job posting. body The JobPostingWrite object: { title:string } required; description?, department?, status?, location?, employmentType?, seniority?, workMode?, currency?, salaryRange?, payPeriod? (strings); minExperienceMonths?, salaryMin?, salaryMax? (whole numbers, 0 to 2147483647; blank is stored as empty); competencies?:array, requirements?:array, applicationQuestions?:object[]; hiringManagerId?, ownerId?, personaId? (integers). Any other key is ignored; createdById is set by the server. returns 201 JobPosting | 409 REQUISITION_NOT_BUDGET_APPROVED if status is Open/Published (new reqs are Pending). 400 code INVALID_NUMBER when minExperienceMonths, salaryMin or salaryMax is not a number in range guard finance fields (financeApprovalStatus/ApprovedBy/At) NOT body-writable — mass-assign to skip finance blocked; live status refused until budget approved. Repeat: not deduplicated: every call creates another requisition, also with the same title | Session + manage_recruitment (licensed) |
| PUT | /recruitment/job-postings/:id | Update a job posting. body The JobPostingWrite object, all keys optional; only the keys sent are changed and any other key is ignored. returns 200 updated JobPosting | 404 'Job Posting not found' guard setting status to Open/Published requires financeApprovalStatus==='Approved' else 409 REQUISITION_NOT_BUDGET_APPROVED; finance fields not writable | Session + manage_recruitment (licensed) |
| PUT | /recruitment/job-postings/:id/owner | Reassign the HR owner of a requisition, optionally cascading to its candidates. body The RequisitionOwnerWrite object: { ownerId:integer|null, cascade?:boolean }. null removes the owner; cascade true also moves the requisition's candidates that belonged to the previous owner or had none. returns 200 { id:integer, ownerId:integer|null, cascaded:integer } (cascaded is the count of candidates moved to the new owner) guard Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_REQUISITION_REASSIGNED audit entry is written | Session + manage_recruitment (licensed) |
| DELETE | /recruitment/job-postings/:id | Delete a requisition. body No body. returns 200 result | 409 { error:string } when candidates are still linked | 404 { error:string } when it does not exist guard Repeat: a second call answers 404, because the requisition is gone | Session + manage_recruitment (licensed) |
| POST | /recruitment/job-postings/:id/approve-finance | Independent finance approval of headcount/budget. body No body. returns 200 updated JobPosting (financeApprovalStatus=Approved) | 404 'Job Posting not found' | 409 code ALREADY_APPROVED — so a repeat changes nothing guard requires manage_finance ALONE (manage_recruitment does NOT satisfy — SoD); assertNotSelfBudgetApproval: requisition creator cannot approve own budget (root-admin exempt). Repeat: a second call answers 409 with code ALREADY_APPROVED and changes nothing | Session + manage_finance (only) (licensed) |
| POST | /recruitment/job-postings/:id/reject-finance | Refuse the headcount budget of a requisition. body The BudgetRefusal object: { reason:string } required: at least 3 characters, cut to 1000. returns 200 updated JobPosting (the reason is stored on it) | 404 'Job Posting not found' | 409 code ALREADY_REJECTED — so a repeat changes nothing | 400 code REASON_REQUIRED when the reason is shorter than 3 characters (it is cut to 1000) guard the person who created the requisition cannot refuse its budget (same self-approval check as approve-finance). Repeat: a second call answers 409 with code ALREADY_REJECTED and changes nothing | Session + manage_finance (only) (licensed) |
| POST | /recruitment/job-postings/:id/resubmit-budget | Send a refused budget back to Finance after changing the terms (Rejected back to Pending). body No body. returns 200 updated JobPosting | 404 { error:string } | 409 code NOT_REJECTED when the budget is not currently refused guard Repeat: a second call answers 409 with code NOT_REJECTED, because the budget is Pending again | Session + manage_recruitment (licensed) |
| GET | /recruitment/job-postings/:id/activity | Activity trail of one requisition. body Query: limit (default 50, clamped to 1–200). returns 200 { items:[{ action:string, label:string, details:string, actor:string (an email address, or 'system'), at:string (date-time), status?:string }] } — the newest 50 unless ?limit says otherwise (1 to 200) | Session + manage_recruitment | view_recruitment | manage_finance (licensed) |
| POST | /recruitment/competencies/suggest | Suggest scorecard competencies for a role. body The CompetencySuggest object: { jobTitle:string } required; jobDescription?:string. returns 200 { competencies } | 400 if jobTitle missing | 502 if AI generation fails | Session + manage_recruitment (licensed) |
| POST | /recruitment/job-description/suggest | Draft a job description with the local LLM. body The JobDescriptionSuggest object: { jobTitle:string } required; the other role fields of the create-job form are passed through to the model. returns 200 { description:string } | 400 when jobTitle is missing | 502 when generation fails | Session + manage_recruitment (licensed) |
Recruitment: Candidates & Pipeline
recruitment-service
The candidate pipeline: listing, screening, stage moves, tags, notes, messages, rejection, erasure and export. A permission admits a caller to the route; the service then checks the caller against the individual candidate, and a candidate outside that caller's scope is refused. Erasure, export and the résumé read stay available when recruitment is switched off.
Worked example: One page of the pipeline, at one stage
API token holding view_recruitment (or any other permission the row lists). The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/recruitment/candidates?limit=1&offset=0&stage=Interview"
# Reply
200
{
"candidates": [
{
"id": 311, "firstName": "Maya", "lastName": "Cohen", "email": "maya.cohen@example.com",
"phoneNumber": null, "stage": "Interview", "matchScore": 82, "scoreSource": "...",
"jobPostingId": 14, "ownerId": 9, "tags": ["referral"], "technicalFitScore": 80,
"Job": { "id": 14, "title": "Finance Manager" },
"Owner": { "id": 9, "firstName": "Sam", "lastName": "Cohen", "email": "sam.cohen@example.com" },
"Offer": null,
"Interviews": [ { "id": 77, "interviewerId": 12, "status": "Completed", "score": 4 } ]
// ... the remaining Candidate, JobPosting and Interview fields
}
],
"total": 23
}
// total counts every candidate that matches, not the page: page with limit (default 200, at most 500) and offset.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /recruitment/candidates | List candidates in the pipeline. body Query: limit (default 200, clamped to 1–500), offset (default 0), ownerId, search, sort, stage, tag, jobPostingId. sort is score (highest match first) or name; any other value, or none, is newest first. ownerId takes a user id, or none | unassigned for candidates with no owner. returns 200 { candidates:[ Candidate ], total:integer } — total counts every candidate matching the filters, not the page. Page with limit and offset against total; there is no page parameter. A call with no query parameters at all may return more than 200 rows: when the first 200 fill the page, candidates the caller is assigned to interview that the cap left out are appended (up to 300 more). Send limit or offset to get exactly one page guard the service applies the caller's candidate scope, so the list holds only candidates that caller may see | Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed) |
| GET | /recruitment/candidates/summary | Pipeline counts for the dashboard, scoped like the list. body No query parameters. returns 200 { byStage:{ <stage>: integer } (only stages that hold a candidate), total:integer, hired:integer, rejected:integer, hiredThisMonth:integer, hiredLastMonth:integer } | Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed) |
| GET | /recruitment/candidates/filter-options | Owner and tag choices for the pipeline filters, over the same scoped set as the list. body No query parameters. returns 200 { owners:[{ id:integer, name:string }] sorted by name, tags:string[] sorted, hasUnassigned:boolean } — built from at most the newest 20,000 candidates in the caller's scope | Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed) |
| POST | /recruitment/candidates/bulk | Apply one action to many candidates: move stage, reject, reassign or tag. body The CandidateBulkAction object: { ids:integer[] (1 to 200), action: stage | reject | reassign | tag } plus, per action: stage:string (stage); sendEmail?:boolean, reason?:string (reject: the email is sent unless sendEmail is false); ownerId:integer|null (reassign); add?:string[], remove?:string[] (tag). returns 200 { succeeded:integer[], failed:[{ id:integer, error:string }], skipped:[{ id:integer, reason:string }] } | 400 empty list, more than 200 ids, or an unknown action guard each item runs the same per-candidate check as the single route, so a row the caller may not act on lands in failed; a reassign item fails unless the caller holds manage_recruitment itself | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| POST | /recruitment/candidates/bulk-offer-decision | Approve or refuse many offers that are awaiting approval. body The OfferBulkDecision object: { ids:integer[] (1 to 200), approve:boolean, reason?:string, termsVersions?:{ <candidateId>: string } }. Only approve:true approves; anything else refuses. returns 200 { succeeded:integer[], failed:[{ id:integer, error:string, code?:string }] } | 400 empty list or more than 200 ids guard each offer goes through the same service call as the single approve and refuse routes, including the self-approval block | Session + manage_recruitment | approve_offer | manage_finance (licensed) |
| POST | /recruitment/candidates | Add a candidate. body The CandidateCreate object: { firstName:string, lastName:string, email:string, jobPostingId:integer } required; phoneNumber?:string (stored encrypted), source?:string, ownerId?:integer. No other candidate field is read from the body: the stage, the scores and the consent fields are set by the server. ownerId is honoured only for a caller holding manage_recruitment; otherwise the candidate takes the requisition's owner, else the caller. returns 201 Candidate | 409 code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition guard duplicate-email check on create; a repeat with the same email on the same requisition answers 409 and creates nothing | Session + manage_recruitment | source_candidates (licensed) |
| POST | /recruitment/candidates/ai-screen | Screen a candidate against the job's requirements (demographic fields stripped first). body multipart/form-data: resume (one file), jobPostingId (required) and the text fields of the CandidateCreate object: firstName, lastName, email, phoneNumber?, source?, ownerId?. ownerId is honoured only for a caller holding manage_recruitment; otherwise the candidate takes the requisition's owner, else the caller. returns 201 Candidate with matchScore | 400 if jobPostingId missing or screen error | 404 'Job Posting not found' | 409 code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition guard AI screening strips demographic fields before scoring; the screening decision is audited | Session + manage_recruitment | source_candidates (licensed) |
| POST | /recruitment/candidates/:id/rescreen | Re-run screening for a candidate. body No body — re-screens using the candidate's stored résumé. returns 200 { status:string } (e.g. 'started') | Session + manage_recruitment | source_candidates (licensed) |
| POST | /recruitment/candidates/parse-resume | Parse an uploaded resume into structured fields. body multipart/form-data: resume (single file). No other fields. returns 200 { firstName:string, lastName:string, email:string, phone:string } (empty strings, plus warning:string, when the file could not be read) | 400 'No resume uploaded' guard fail-open: on extraction failure returns empty fields + warning (200), never 500 | Session + manage_recruitment | source_candidates (licensed) |
| GET | /recruitment/candidates/:id/onboarding-status | Onboarding run status and step progress for a hired candidate. returns 200 onboarding status | 404 'Candidate not found' when the caller has no broad access and is not related to the candidate | Session + manage_recruitment | view_recruitment (licensed) |
| GET | /recruitment/candidates/:id/history | This person's earlier applications. returns 200 { email:string, priorCount:integer, priorApplications:[{ id:integer, jobPostingId:integer, jobTitle:string, department:string|null, stage:string, source:string|null, appliedAt:string (date-time) }], everHired:boolean, everRejected:boolean } — other applications under the same email that the caller may see, newest first, at most 50 | 404 'Candidate not found' | Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed) |
| GET | /recruitment/candidates/:id/messages | Email thread with a candidate. returns 200 [ CandidateMessage ], oldest first, at most 200; not paged | Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed) |
| POST | /recruitment/candidates/:id/messages | Send an email to a candidate from the thread. body The CandidateMessageSend object: { subject:string, body:string }, both required and not blank; subject is cut to 500 characters. returns 201 the stored message | 409 code SMTP_OFF when outgoing email is not configured. 400 when the subject or body is blank, or the candidate has no email address guard Repeat: not deduplicated: every call sends another email and stores another message | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| PATCH | /recruitment/candidates/:id/stage | Move a candidate between pipeline stages. body The CandidateStageWrite object: { stage:string } required: Sourced | Screen | Interview | Offer | Hired | Rejected, in any capitalisation. returns 200 updated Candidate | 409 code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed guard hiring-decision stages (e.g. Hired) restricted to HR; interviewer/sourcer scope enforced in service | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| PATCH | /recruitment/candidates/:id/tags | Add or remove recruiter tags on a candidate. body The CandidateTagsUpdate object: { add?:string[], remove?:string[] }. A label is trimmed and cut to 40 characters; a candidate keeps at most 30. returns 200 { tags:string[] } | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| GET | /recruitment/candidates/:id/notes | Internal hiring-team notes on a candidate. returns 200 [ CandidateNote ], newest first, each with its Author; not paged | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| POST | /recruitment/candidates/:id/notes | Add an internal note, optionally mentioning colleagues. body The CandidateNoteCreate object: { body:string } required (trimmed, cut to 5000 characters); mentions?:integer[] (user ids to notify). returns 201 the stored CandidateNote. 400 'A note cannot be empty.' guard Repeat: not deduplicated: every call stores another note and notifies the people mentioned again | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| POST | /recruitment/candidates/:id/reject | Reject a candidate. body The CandidateReject object: { sendEmail?:boolean, reason?:string } — all optional. returns 200 result (adverse-action email sent if sendEmail) guard assertCanActOnCandidate: caller must be related to candidate (or HR/admin), fails closed. Repeat: a candidate who is already Rejected is left unchanged, no second email is sent, and the answer is 200 | Session + manage_recruitment | conduct_interview | source_candidates (licensed) |
| POST | /recruitment/candidates/:id/calibrate | Calibrate scorecards across interviewers. body No body. returns 200 calibration result (interviewer names/scores/feedback) guard assertCanActOnCandidate — real relationship required, 404 fail-closed | Session + manage_recruitment | view_recruitment (licensed) |
| POST | /recruitment/candidates/:id/send-rejection-email | Send a rejection email. body No body. returns 200 result — sends the adverse-action/rejection email | 409 code SMTP_OFF when outgoing email is not configured (nothing is sent) | Session + manage_recruitment | conduct_interview (licensed) |
| PUT | /recruitment/candidates/:id/owner | Reassign a candidate's HR owner. body The CandidateOwnerWrite object: { ownerId:integer|null }. returns 200 { id:integer, ownerId:integer|null } guard Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_CANDIDATE_REASSIGNED audit entry is written | Session + manage_recruitment (licensed) |
| DELETE | /recruitment/candidates/:id | Purge a candidate and their files (GDPR erasure). body No body. returns 200 { result } — permanent purge including the résumé file, audited | 207 with the same body when the local purge succeeded but a signed document could not be erased downstream | Session + manage_recruitment |
| GET | /recruitment/candidates/:id/data-export | Export everything held about one candidate (data-subject access request). returns 200 JSON attachment candidate-<id>-data-export.json | the service error status with { error:string } | Session + manage_recruitment |
| GET | /recruitment/candidates/:id/resume | Stream a CV to the in-app viewer. returns 200 the file, served inline with a Content-Type chosen from its extension | 404 text 'Resume not found' — also for a caller with no relationship to the candidate | 404 text 'File not found on disk' | 400 text 'Invalid filename' | 500 text 'Could not open resume' guard a caller without broad recruitment access must be the candidate's owner, the requisition's owner or hiring manager, or an assigned interviewer; every read is audited as RESUME_ACCESSED | Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet |
Recruitment: Interviews & Scorecards
recruitment-service
Scheduling, candidate self-scheduling proposals, scorecards and the interview templates behind them.
Worked example: The interview templates of one requisition
API token holding view_recruitment, manage_recruitment or conduct_interview. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/recruitment/interview-templates?jobPostingId=14"
# Reply
200 // a bare array; not paged
[
{
"id": 5, "name": "Finance technical round", "description": null, "roundName": "Technical",
"jobPostingId": 14, "sourceJobId": null, "sourceJobTitle": null, "usageCount": 3,
"createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-09-01T10:00:00.000Z",
"Questions": [
{ "id": 41, "interviewTemplateId": 5, "questionText": "...", "category": null, "weight": 1, "mustHave": false, "order": 0 }
// ... the remaining InterviewQuestion fields
]
}
]
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /recruitment/interviews/:id | Interview detail and scorecards. returns 200 Interview | 404 'Interview not found' — also returned to a caller who is not on that interview's panel | Session + manage_recruitment | view_recruitment | conduct_interview | schedule_interviews | review_executive_packet (licensed) |
| POST | /recruitment/candidates/:id/interviews | Schedule an interview. body The InterviewCreate object: { interviewerId:integer } required; scheduledAt?:string (date-time), sequence?:integer, interviewTemplateId?:integer, isShadow?:boolean, durationMins?:integer, allowOverlap?:boolean. returns 201 Interview (interviewer notified best-effort). 400 code INVALID_TIME when scheduledAt is not a date | 409 code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window and allowOverlap is not true guard the caller must be allowed to act on that candidate. Repeat: not deduplicated as such, but a second call for the same interviewer and time answers 409 DOUBLE_BOOKED unless allowOverlap is true; without a time every call creates another interview | Session + manage_recruitment | conduct_interview | schedule_interviews (licensed) |
| POST | /recruitment/candidates/:id/interviews/propose | Propose interview times for the candidate to pick from (self-scheduling). body The InterviewSlotsPropose object: { interviewerId:integer, slots:string[] (date-times) } required: at least two future times, the first 10 kept; interviewTemplateId?:integer, durationMins?:integer, allowOverlap?:boolean, isShadow?:boolean. returns 201 { id:integer, proposedSlots:array } | 400 code INTERVIEWER_REQUIRED or NOT_ENOUGH_SLOTS. 409 code DOUBLE_BOOKED when fewer than two of the times are free for the interviewer guard the caller must be allowed to act on that candidate; slots that clash with the interviewer's existing bookings are dropped unless allowOverlap is true. Repeat: not deduplicated: every call creates another interview with its own link and sends the candidate another invitation | Session + manage_recruitment | conduct_interview | schedule_interviews (licensed) |
| PATCH | /recruitment/interviews/:id/reschedule | Move an interview to a new time. body The InterviewReschedule object: { scheduledAt:string (date-time) } required; durationMins?:integer, allowOverlap?:boolean. returns 200 updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED, 400 code INVALID_TIME, 409 code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window, unless allowOverlap is true guard Repeat: not a no-op: the same time is stored again, the calendar invitation is re-issued with a higher sequence number and the interviewer is notified again | Session + manage_recruitment | conduct_interview | schedule_interviews (licensed) |
| POST | /recruitment/interviews/:id/cancel | Cancel an interview. body The InterviewCancel object: { reason?:string }, cut to 500 characters. returns 200 updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED. Safe to repeat: an interview that is already cancelled is returned unchanged guard Repeat: safe; an interview that is already cancelled is returned unchanged and the interviewer is not notified again | Session + manage_recruitment | conduct_interview | schedule_interviews (licensed) |
| POST | /recruitment/interviews/:id/scorecard | Submit an interview scorecard. body The ScorecardSubmit object: { structuredFeedback?:object, score?:integer (1 to 5), feedback?:string }; the recommendation is carried inside structuredFeedback. Sets status to Completed. returns 200 updated Interview | 403 code SCORECARD_NOT_ASSIGNED | 409 code INTERVIEW_CANCELLED | 409 code SCORECARD_LOCKED | 400 code INVALID_SCORE (score must be 1–5) guard only the interviewer assigned to that interview can submit its scorecard (the administrator role is exempt); the refusal is audited; a scorecard can be submitted again and replaces the earlier one until the candidate is Hired or Rejected, after which it answers 409 SCORECARD_LOCKED | Session + manage_recruitment | conduct_interview (licensed) |
| GET | /recruitment/interview-templates | List interview templates. body Query: jobPostingId? returns 200 bare array of InterviewTemplate, each with usageCount and its Questions in order; not paged — jobPostingId is the only filter | Session + manage_recruitment | conduct_interview | view_recruitment (licensed) |
| POST | /recruitment/interview-templates | Create an interview template. body The InterviewTemplateWrite object: { name:string } required; description?:string, roundName?:string, jobPostingId?:integer|null, questions?:[ InterviewQuestionInput ], each { questionText:string, category?, context?, idealAnswer?, weight?:integer, mustHave?:boolean, anchors?:object, order?:integer }. returns 201 InterviewTemplate (with questions) guard Repeat: not deduplicated: every call creates another template, also with the same name | Session + manage_recruitment (licensed) |
| PUT | /recruitment/interview-templates/:id | Update an interview template. body The InterviewTemplateWrite object, all keys optional. Sending questions replaces the question list; leaving it out keeps the questions as they are. returns 200 updated InterviewTemplate | Session + manage_recruitment (licensed) |
| DELETE | /recruitment/interview-templates/:id | Delete an interview template. body No body. returns 204 No Content guard Repeat: a second call answers 404, because the template is gone | Session + manage_recruitment (licensed) |
| POST | /recruitment/interview-templates/ai-generate | Generate interview questions with the local LLM. body The DraftForRequisition object: { jobPostingId?:integer }, optional; the role is used as context when given. returns 200 { questions } (LLM-generated, HR-compliance-guarded) | 502 if generation fails | Session + manage_recruitment (licensed) |
Recruitment: Offers & Signing
recruitment-service
Offer terms, approval, sending for signature through Zonitix Sign, withdrawal, rescission and offer templates. The permission lists are alternatives, so they do not by themselves keep setting terms and approving them apart: the service refuses an approval from the person who set the terms.
Worked example: Require scorecards before an offer
Session cookie of a user holding manage_recruitment or manage_security, plus an Origin header because it is a write. With an API token, send the Bearer header instead and leave Origin out. The values shown are made up; the field names and the shape are what the route sends.
curl -X PUT --cookie '__Host-token=<session-jwt>' -H 'Origin: https://<your-instance>.zonitix.com' -H 'Content-Type: application/json' \
-d '{"requireScorecardsBeforeOffer": true}' https://<your-instance>-api.zonitix.com/api/v1/recruitment/settings/offer-gates
# Reply
200
{ "requireScorecardsBeforeOffer": true }
400 // the value is not true or false
{ "error": "requireScorecardsBeforeOffer must be true or false." }
// GET on the same path answers { "requireScorecardsBeforeOffer": true, "defaultValue": true }.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /recruitment/settings/offer-gates | Read the scorecard-before-offer policy. returns 200 { requireScorecardsBeforeOffer:boolean, defaultValue:boolean } | Session + manage_recruitment | view_recruitment | approve_offer | manage_security | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed) |
| PUT | /recruitment/settings/offer-gates | Turn the scorecard-before-offer policy on or off. body The OfferGatePolicyWrite object: { requireScorecardsBeforeOffer:boolean } required. returns 200 { requireScorecardsBeforeOffer:boolean } | 400 when the value is not a boolean guard the change is audited, at warning level when the policy is switched off. Repeat: safe; the one stored setting is written again with the same value | Session + manage_recruitment | manage_security (licensed) |
| PUT | /recruitment/candidates/:id/offer-terms | Set or change the terms of an offer. body The OfferTermsWrite object: { salary?:number, startDate?:string (YYYY-MM-DD), currency?:string, payPeriod?:string, level?:string, targetBonusPct?:integer (0 to 100), signOnBonus?:number, hiringManagerId?:integer|null }. Only the keys sent are changed; null or an empty text clears a value. returns 200 the offer. 400 { error:string } for a start date that is not YYYY-MM-DD, a negative salary or sign-on bonus, or a target bonus outside 0 to 100 | 404 'Candidate not found' guard the offer records who set the terms; that person cannot approve it | Session + manage_recruitment | approve_offer (licensed) |
| GET | /recruitment/offers/pending-approval | Offers awaiting approval (the approver queue). body No query parameters. returns 200 array of offers — a bare array, most recently updated first, at most 200; only offers that are Draft with approval Pending and whose candidate is still at the Offer stage. Each: { id, candidateId, candidateName, candidateEmail, stage, jobTitle, department, location, postedRange, salary, currency, payPeriod, level, targetBonusPct, signOnBonus, startDate, approvalStatus, status, termsSetBy, termsSetByName, selfApproval, updatedAt, termsVersion, matchScore, scoreSource, aiSummary, seniority, totalYears, skills, interviews } | Session + manage_recruitment | approve_offer | manage_finance (licensed) |
| GET | /recruitment/offers/:id/signed-document | Download the sealed signed offer, or its certificate of completion. body Query: type=certificate for the certificate; anything else returns the document. returns 200 application/pdf | 403 when the caller may not see offer compensation | 404 when no signed document exists yet | Session + manage_recruitment | view_recruitment (licensed) |
| POST | /recruitment/candidates/:id/approve-offer | Approve an offer before it is sent. body The OfferApprove object: { termsVersion?:string }, optional, cut to 64 characters: the version of the terms the approver saw, passed to the service as the expected version. returns 200 approved Offer | error status from service on failure (self-approval blocked) | 400 code OFFER_TERMS_MISSING when no start date is set | 403 code SELF_APPROVAL | 409 code CANDIDATE_REJECTED | 409 code OFFER_TERMS_CHANGED when termsVersion no longer matches the stored terms (also when the terms change while the approval is being written; the approval is then withdrawn) guard SoD in offerService: approver must differ from offer.termsSetBy (root-admin exempt); self-approval → warn-audited RECRUITMENT_OFFER_SELF_APPROVAL_BLOCKED | Session + manage_recruitment | approve_offer | manage_finance (licensed) |
| POST | /recruitment/candidates/:id/reject-offer | Refuse approval of an offer (this does not reject the candidate). body The OfferReject object: { reason?:string }. returns 200 the offer | 404 code OFFER_NOT_FOUND when the candidate has no offer | 409 code OFFER_ALREADY_SENT when the offer is no longer a draft. Safe to repeat while the offer is a draft: a repeat answers 200 again guard Repeat: while the offer is still a draft a second call answers 200 and leaves it Rejected; once the offer has been sent the answer is 409 with code OFFER_ALREADY_SENT | Session + manage_recruitment | approve_offer | manage_finance (licensed) |
| POST | /recruitment/candidates/:id/send-offer | Generate the contract and dispatch it for signing via Zonitix Sign. body The OfferSend object: { offerTemplateId?:integer | null }. Generates the contract and sends it through Zonitix Sign. returns 200 result { submissionId } | 409 when the offer is not Approved | 404 candidate/template missing | 502 signature integration | 503 not configured | 409 code OFFER_TERMINAL when the offer is already accepted or declined guard offer must be in Approved state before send (workflow precondition). Repeat: not a no-op: while the offer is a draft or already sent, a second call sends it for signature again; once it is accepted or declined the answer is 409 with code OFFER_TERMINAL | Session + manage_recruitment | approve_offer (licensed) |
| POST | /recruitment/candidates/:id/mark-signed | Record an offer as signed outside Zonitix Sign (paper or manual signature). body No body. returns 200 { success:true, offer } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code OFFER_TERMINAL (declined), 400 code OFFER_TERMS_MISSING (no start date), 409 code OFFER_NOT_APPROVED guard Repeat: safe; an offer that is already accepted is returned unchanged | Session + manage_recruitment (licensed) |
| POST | /recruitment/candidates/:id/withdraw-offer | Withdraw a sent offer. body No body. returns 200 { offer, signingRevoked } | 404 code OFFER_NOT_FOUND | 409 code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent guard Repeat: a second call answers 409 with code OFFER_NOT_WITHDRAWABLE, because the offer is no longer Sent | Session + manage_recruitment (licensed) |
| PATCH | /recruitment/candidates/:id/offer/decline-reason | Record or clear why a candidate declined. body The OfferDeclineReason object: { reason:string }; empty clears it. returns 200 the Offer as stored (salary is null in this reply; read amounts from the candidate list) guard Repeat: safe; the same reason is stored again | Session + manage_recruitment (licensed) |
| POST | /recruitment/candidates/:id/rescind-request | Request rescission of a signed offer (step one of two). body The OfferRescindRequest object: { reason:string } required. returns 200 { status:'requested' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_SIGNED, 409 code ALREADY_REQUESTED (what a repeat gets), 400 code REASON_REQUIRED guard Repeat: a second call answers 409 with code ALREADY_REQUESTED while the first request is waiting for approval | Session + manage_recruitment | approve_offer (licensed) |
| POST | /recruitment/candidates/:id/rescind-approve | Approve a requested rescission (step two of two). body No body. returns 200 { status:'approved' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_REQUESTED when no rescission is pending, 403 code SELF_APPROVAL for the person who requested it guard Repeat: a second call answers 409 with code NOT_REQUESTED, because no rescission is pending any more | Session + manage_recruitment | approve_offer (licensed) |
| GET | /recruitment/offer-templates | List offer templates. body Query: jobPostingId? returns 200 bare array of OfferTemplate, each with usageCount; not paged — jobPostingId is the only filter | Session + manage_recruitment | view_recruitment | approve_offer (licensed) |
| POST | /recruitment/offer-templates | Create an offer template. body The OfferTemplateWrite object: { name:string, body:string } required; jobPostingId?:integer|null (null for every requisition), supportingDocs?:object[] (at most 20). returns 201 OfferTemplate guard Repeat: not deduplicated: every call creates another template, also with the same name | Session + manage_recruitment (licensed) |
| PUT | /recruitment/offer-templates/:id | Update an offer template. body The OfferTemplateWrite object, all keys optional; only the keys sent are changed. returns 200 updated OfferTemplate | Session + manage_recruitment (licensed) |
| DELETE | /recruitment/offer-templates/:id | Delete an offer template. body No body. returns 204 No Content guard Repeat: a second call answers 404, because the template is gone | Session + manage_recruitment (licensed) |
| POST | /recruitment/offer-templates/ai-draft | Draft an offer template with the local LLM. body The DraftForRequisition object: { jobPostingId?:integer }, optional; the role and its hiring manager are used as context when given. returns 200 { body:string } (LLM-drafted offer body) | 502 if generation fails | Session + manage_recruitment (licensed) |
| POST | /recruitment/offer-documents | Upload a supporting file for an offer template. body multipart/form-data: file (one file, at most 10 MB); label?:string (cut to 120 characters, defaults to the file name). returns 200 { id:string (a UUID), type:'file', label:string, filename:string, size:integer (bytes), storedName:string } | 400 no file, file too large, or a file type that is not allowed guard Repeat: not deduplicated: every call stores the file again under a new storedName | Session + manage_recruitment (licensed) |
| GET | /recruitment/offer-documents/:storedName | Download a stored offer supporting file. returns 200 the file | 404 'File not found' | Session + manage_recruitment | view_recruitment | approve_offer (licensed) |
Recruitment: Assistant, Referrals & Reports
recruitment-service
The recruitment assistant, employee referrals, pipeline analytics, the hiring report and the careers-page settings.
Worked example: The careers-page settings
API token holding manage_recruitment. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/recruitment/careers-settings
# Reply
200 // nothing has been saved yet: every text is empty and both switches are on
{
"intro": "", "whereWeWork": "", "teamSize": "", "replyTime": "", "topBenefit": "",
"linkedinUrl": "", "privacyContact": "",
"talentPoolEnabled": true, "alertsEnabled": true
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /recruitment/chat | Recruitment assistant chat. body The AssistantQuery object: { query:string }. returns 200 { response:string } | 500 { error:string } guard AI context scoped to candidates/jobs THIS caller may see (no cross-user PII leak) | Session + manage_recruitment | view_recruitment | source_candidates (licensed) |
| POST | /recruitment/chat/stream | Recruitment assistant chat, streamed. body The AssistantQuery object: { query:string }, not empty. returns 200 application/x-ndjson stream | 400 when query is missing or empty | Session + manage_recruitment | view_recruitment | source_candidates (licensed) |
| POST | /recruitment/chat/refine | Refine drafted text. body The FeedbackRefine object: { text:string }. returns 200 { refinedText:string } | 500 { error:string } | Session + manage_recruitment | conduct_interview | review_executive_packet (licensed) |
| GET | /recruitment/referrals | List your own employee referrals. body No query parameters; not paged. returns 200 [ Referral ]: the caller's own, newest first, each with its Candidate guard only the signed-in user's own referrals; screening results and internal notes about the candidate are not included | Session + view_referrals | manage_referrals (licensed) |
| POST | /recruitment/referrals | Submit a referral with a resume (multipart). body multipart/form-data: firstName, lastName, email, jobPostingId (required), phoneNumber?, notes? as text fields, plus an optional resume file. With a file the referral is screened against the role. returns 201 Referral (creates a Referral-sourced Candidate; AI-screens if résumé attached) | 409 code CANDIDATE_ALREADY_EXISTS when that person is already a candidate for the role | 404 code JOB_NOT_FOUND guard scoped to the signed-in user. Repeat: a second referral of the same email for the same role answers 409 and creates nothing | Session + view_referrals | manage_referrals (licensed) |
| GET | /recruitment/analytics/pipeline | Pipeline analytics. body No query parameters. returns 200 { perStage, bottleneck:{ stage, avgDays } | null, timeToHireDays, timeToHireSamples, stalled, avgInStageDays, agingDays, eventCount } guard figures cover only the candidates the caller may see | Session + manage_recruitment | view_recruitment (licensed) |
| GET | /recruitment/analytics/provisioning-impact/:id | Cost/provisioning impact of a hire. body :id is the job posting id. No query parameters. returns 200 { jobId, jobTitle, costSource: persona | none, isUsingPersona, personaName, oneTimeHardwareCost, monthlySaaSBound, annualImpact, availableDevices, unitsNeeded, inventoryStatus: LOW_STOCK | HEALTHY, recommendation } — the three cost figures are null when the requisition has no persona | Session + manage_recruitment | view_finance (licensed) |
| GET | /recruitment/reports/hiring | Consolidated hiring report: funnel, stage conversion, time to hire, source effectiveness and offers. body Query: from?, to? (dates). returns 200 { generatedAt:string (date-time), window:{ from:string|null, to:string|null }|null (null for all time), funnel:[{ stage:string, count:integer }], totals:{ total, hired, rejected, hiredThisMonth, hiredLastMonth } (integers), conversion:[{ from:string, to:string, reached:integer, advanced:integer, rate:integer (a whole percentage) }], efficiency:{ timeToHireDays, timeToHireSamples, bottleneck, avgInStageDays, stalled, agingDays }, sources, offers, openRequisitions:integer } — efficiency, sources and offers are not typed here guard figures cover only the candidates the caller may see | Session + manage_recruitment | view_recruitment | review_executive_packet (licensed) |
| GET | /recruitment/insights | LLM-generated pipeline insights. body No query parameters. returns 200 { insights:array, generatedAt, aiAvailable:boolean } — aiAvailable is false and insights is empty when the local model returned nothing guard one generated result is cached and shared by every caller; it is regenerated only when the cache has expired and the pipeline snapshot has changed | Session + manage_recruitment | view_recruitment (licensed) |
| GET | /recruitment/careers-settings | Read the careers-page content and feature switches. returns 200 { intro, whereWeWork, teamSize, replyTime, topBenefit, linkedinUrl, privacyContact: each string; talentPoolEnabled:boolean, alertsEnabled:boolean } — text fields default to an empty string, both switches to true | Session + manage_recruitment (licensed) |
| PUT | /recruitment/careers-settings | Update the careers-page content and feature switches. body Partial of the same fields; only keys present are applied. Text is trimmed and cut to its maximum: intro 600, whereWeWork 120, teamSize 60, replyTime 60, topBenefit 120, linkedinUrl 300, privacyContact 200. talentPoolEnabled and alertsEnabled are booleans. returns 200 the saved settings (all fields) | 400 code BAD_URL when linkedinUrl is not an https:// address | 400 code BAD_PRIVACY_CONTACT when privacyContact is neither an email address nor an https:// link guard changed fields are audited; safe to repeat — a call that changes nothing saves nothing and writes no audit entry | Session + manage_recruitment (licensed) |
Recruitment: Careers Site, Candidate Portal & Webhooks
recruitment-service
Routes that carry no session: the careers site and job feeds, the token-addressed candidate portal and self-scheduling pages, and the two inbound webhooks. The gateway rate-limits all of them and the service rate-limits again per client address, answering 429 with a Retry-After header. The portal, self-scheduling and webhook routes are outside the licence check, so an offer already in flight still completes when recruitment is switched off.
Worked example: The name, logo and colour the public pages use
Public and rate-limited: no session or token. The values shown are made up; the field names and the shape are what the route sends.
curl https://<your-instance>-api.zonitix.com/api/v1/recruitment/public/branding
# Reply
200
{ "companyName": "Example Ltd", "logoUrl": null, "brandColor": "#0075FF" }
429 // too many requests from one address; wait for the Retry-After header
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /recruitment/public/jobs | Public job board listing. body No query parameters; not paged. returns 200 bare array of Published roles (the talent-pool requisition excluded), newest first: { id:integer, title:string, department:string|null, location:string|null, description:string|null, createdAt:string (date-time), employmentType:string|null, workMode:string|null, salaryMin:integer|null, salaryMax:integer|null, currency:string|null, payPeriod:string|null, seniority:string|null, competencies:array, applicationQuestions:[{ id, label, type, required, options }] (a key is left out when the question does not have it) } | Public (rate-limited, licensed) |
| GET | /recruitment/public/jobs/:id | One public job posting (numeric id). returns 200 the posting | 404 { error:string } when the position is no longer open — same fields as one row of the list | Public (rate-limited, licensed) |
| GET | /recruitment/public/jobs/jsonld | Open roles as schema.org JobPosting JSON-LD. returns 200 bare array of schema.org JobPosting objects, one per role in the public list; a role without a pay band has no baseSalary; not paged | Public (rate-limited, licensed) |
| GET | /recruitment/public/jobs/:id/jsonld | One open role as schema.org JobPosting JSON-LD. returns 200 JSON-LD | 404 { error:string } when the position is no longer open | Public (rate-limited, licensed) |
| GET | /recruitment/public/jobs/indeed.xml | Job feed in the Indeed XML format. returns 200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged | Public (rate-limited, licensed) |
| GET | /recruitment/public/jobs/linkedin.xml | Job feed in the LinkedIn XML format. returns 200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged | Public (rate-limited, licensed) |
| GET | /recruitment/public/careers | Careers-page content: company name and logo, intro and facts, which features are on, and the department list for filters. Open roles come from GET /recruitment/public/jobs. returns 200 { companyName:string, logoUrl:string|null, intro:string|null, facts:{ whereWeWork, teamSize, replyTime, topBenefit: each string|null }, linkedinUrl:string|null, privacyContact:string|null, talentPoolEnabled:boolean, alertsEnabled:boolean, departments:string[] } — unset text is null; departments lists those with a Published role, sorted | Public (rate-limited, licensed) |
| POST | /recruitment/public/apply/:jobId | Submit an application from the careers site. body multipart/form-data: firstName (required, cut to 100 characters), email (required, must look like an address, cut to 200), lastName? (100), phoneNumber? (40), consent (must be the string true), resume? (one file, at most 5 MB, .pdf, .docx or .txt), applicationAnswers? (a JSON string keyed by question id), humanReviewRequested? (true skips automated résumé scoring), aiNoticeAck?, source? (indeed | linkedin | careers; anything else is recorded as Career Site) returns 201 { success:true } | 400 { error:string } for a missing first name or invalid email, a file over 5 MB or of another type, consent not given, or an unanswered required question | 404 { error:string } 'This position is no longer open.' — any role that is not Published guard a second application with the same email for the same role also answers 201 and creates nothing, so a repeat is safe and the response does not reveal whether the email is known | Public (rate-limited, also per email, licensed) |
| POST | /recruitment/public/apply-general | Submit a general application into the talent pool, with no specific role. body Same multipart fields as POST /recruitment/public/apply/:jobId. returns 201 { success:true } | 400 { error:string } as for a role application | 404 { error:string } when the talent pool is switched off in the careers settings guard lands in the single General applications requisition; a repeat with the same email answers 201 and creates nothing | Public (rate-limited, also per email, licensed) |
| POST | /recruitment/public/job-alerts | Subscribe an email address to job alerts (double opt-in). body The JobAlertSubscribe object: { email:string, consent:true } required; department?:string, locale?:string. email is cut to 200 characters, department to 120, locale to 8. returns 202 { success:true } for every valid request | 400 code BAD_EMAIL | 400 code CONSENT_REQUIRED | 404 code ALERTS_OFF when job alerts are switched off. Safe to repeat: the confirmation email is sent at most once per 10 minutes per address, and an already confirmed subscription is left as it is guard Repeat: safe; an address that is already confirmed for the same department is left as it is and gets no email, and a confirmation email is sent at most once per 10 minutes per address | Public (rate-limited, also per email, licensed) |
| POST | /recruitment/public/job-alerts/confirm | Confirm a job-alert subscription from the emailed link. body The JobAlertToken object: { token:string }. returns 200 { confirmed:true } | 404 { error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time guard Repeat: safe; a second call answers 200 and keeps the first confirmation time, and writes another JOB_ALERT_CONFIRMED audit entry | Public (rate-limited, licensed) |
| POST | /recruitment/public/job-alerts/unsubscribe | Withdraw a job-alert subscription. body The JobAlertToken object: { token:string }. returns 200 { unsubscribed:true, found:boolean } — the subscription row is deleted; a repeat answers 200 with found:false guard Repeat: safe; the subscription is deleted by the first call and a second answers 200 with found:false | Public (rate-limited, licensed) |
| GET | /recruitment/public/branding | Company name, logo and brand colour for the candidate portal. returns 200 { companyName:string, logoUrl:string|null, brandColor:string } | Public (rate-limited) |
| GET | /recruitment/public/self-schedule/:token | Candidate self-scheduling page data, addressed by a one-time token. returns 200 the offered times | the service error status (400 by default) with { error:string, code:string, alreadyScheduled:boolean } — 404 code INVALID_TOKEN, 410 code CANCELLED, CLOSED, ALREADY_SCHEDULED or SLOTS_EXPIRED | Public (rate-limited) |
| POST | /recruitment/public/self-schedule/:token | Candidate books one of the offered times. body The SelfScheduleChoice object: { chosen:string (date-time) }: one of the times offered by GET on the same path. returns 200 { ok:true, scheduledAt:string (date-time) } | the service error status (400 by default) with { error:string, code:string } — 404 code INVALID_TOKEN, 410 code CANCELLED or CLOSED, 400 code SLOT_NOT_OFFERED, 409 code SLOT_PAST, SLOT_TAKEN or ALREADY_SCHEDULED (what a repeat gets once a time is booked) guard Repeat: a second call answers 409 with code ALREADY_SCHEDULED once a time is booked | Public (rate-limited) |
| GET | /recruitment/public/portal/:token | Candidate portal, addressed by a one-time token. returns 200 portal data | 404 { error:string } for an unknown or malformed token | 403 { error:string } for an expired one | Public (rate-limited) |
| GET | /recruitment/public/portal/:token/document/:docId | Candidate downloads a supporting file attached to their offer. returns 200 the file | 404 unknown token or document | 410 'This offer is no longer available.' guard the file must be one of that offer's supporting documents | Public (rate-limited) |
| POST | /recruitment/public/portal/:token/hardware | Candidate submits their hardware selection. body Hardware selection object (stored verbatim as candidate.onboardingData.hardwareSelection). returns 200 { success:true } | 400 'Selection is only available for active or accepted offers.' | 404 { error:string } for a malformed token guard public + rate-limited; token-authed (onboardingToken); offer must be Sent/Accepted; may trigger onboarding | Public (rate-limited) |
| POST | /recruitment/webhooks/docuseal | Zonitix Sign completion callback (path name retained for compatibility). body { event_type, data:{ id, ... } } — Zonitix Sign completion callback. returns 200 'OK' | 400 text 'Invalid signature' for a missing, stale or mismatched signature (the service answers 401; the gateway relays it as 400, with the body unchanged) | 400 'Malformed payload' | 503 text 'Webhook not configured' if the signing secret is unset in production guard X-Zonitix-Signature: t=<ms timestamp>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)> — bound to the exact payload, with a ±5-minute replay window; the secret itself is never sent on the wire and no static-secret header is accepted; the sender retries a failed delivery up to 4 attempts | Signed request (HMAC, see Inbound Webhooks) |
| POST | /recruitment/webhooks/inbound-email | Inbound-email provider callback that records a candidate reply on their thread. body { recipient | to, from | sender, subject, text | body-plain | body, messageId | Message-Id | id, token? }; the secret goes in the X-Inbound-Secret header or the body field secret returns 200 { ok:true, recorded:true, candidateId:integer, messageId:integer } | 200 { ok:true, ignored:'not_configured'|'bad_secret'|'no_token'|'no_match' } | 200 { ok:true, deduped:true, candidateId:integer } guard always answers 200; nothing is recorded until an inbound secret is configured; a bad or missing secret is audited; the reply is matched to a candidate by the reply token in the recipient address; safe to re-deliver when the provider sends a message id — an inbound message id already recorded answers deduped:true and stores nothing | Shared secret (rate-limited) |
Finance & Document Parser
document-parser-service (Python/FastAPI)
Invoice OCR and the invoice ledger. Runs entirely on your instance — no document is sent to a third-party parsing API. Permissions are checked inside the service on every call, and the administrator role passes each check. Rows marked (licensed) are part of Finance: they answer 403 when Finance is switched off for your plan and 402 while the subscription is lapsed. The résumé screening and document routes at the end of the table are shared with recruitment and are not behind the Finance licence. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number. Error bodies differ from the other services: a refusal raised by a handler is { detail:string }, a request the framework rejects before the handler runs (a missing or mistyped parameter) is 422 { detail:[ ... ] }, and the two licence refusals (403 and 402) are { error:string, message:string }. The gateway in front of the service adds answers of its own, each { error:string }: 401 "No token provided" with no session or token, 503 "Service temporarily unavailable" while the service behind it keeps failing, and 504 "Gateway Timeout" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.
Worked example: One page of the invoice ledger
API token holding view_finance or manage_finance. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/parser/invoices?page=1&limit=1"
# Reply
200
{
"items": [
{
"id": 42, "db_id": 42, "displayId": "INV-042", "filename": "acme-october.pdf",
"vendor": "ACME Ltd", "canonicalVendor": "Acme", "vendorId": 7,
"amount": 1250.0, "currency": "EUR", "currencyDefaulted": false, "amountBase": 1362.5,
"date": "Oct 01, 2026", "dateIso": "2026-10-01T00:00:00", "renewalDate": "N/A",
"category": "Software", "costCenter": null, "department": "Engineering", "project": null,
"status": "Pending", "approvalStatus": "Submitted", "extractionConfidence": 0.93,
"invoiceNumber": "A-1091", "taxAmount": null, "poNumber": "PO-2026-014", "glCode": null,
"customFields": {}, "matchStatus": "unmatched",
"submittedBy": 12, "approvedBy": null, "paidBy": null, "rejectionReason": null
}
],
"total": 318, "page": 1, "limit": 1, "pages": 318, "filteredTotalAmount": 412870.4
}
// Amounts are JSON numbers. total and filteredTotalAmount cover every matching invoice, not the page.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /parser/parse | Parse an uploaded invoice PDF into structured fields. body multipart form: file (UploadFile) required — pdf/docx/png/jpg/jpeg/xlsx/txt, at most 10 MB. No JSON body. returns 200 {success, document_id, displayId, approvalStatus, holdReasons, currency, filename, text, extracted_fields{total,date,renewal_date,vendor,category,status,user_id}, method} | 400 unsupported extension, content that does not match the extension, or over 10 MB | 409 the same file was already uploaded, or the same vendor and invoice number already exist | 500 guard file_safety validation must pass. Repeat: the same file sent again answers 409 and stores nothing | Session + manage_finance | submit_invoice (licensed) |
| GET | /parser/my-documents | Documents you uploaded yourself. body Query: limit (default 50, clamped to 1–200). returns 200 { items:object[] } — newest first. Each item: { id:integer, db_id:integer, displayId:string, filename:string|null, vendor:string|null, amount:number, currency:string, currencyDefaulted:boolean, date:string (a display date, or 'N/A'), category:string|null, status:string, approvalStatus:string, createdAt:string (date-time), isoDate:string (date)|null, invoiceNumber:string|null, poNumber:string|null, note:string|null, rejectionReason:string|null (set only on a rejected invoice), holdReasons:array, canDelete:boolean, canEdit:boolean, hasFile:boolean }. limit is the only paging control: there is no offset or page guard only documents the caller uploaded | Session + view_finance | manage_finance | submit_invoice (licensed) |
| GET | /parser/my-documents/:doc_id/timeline | History of one invoice the caller may see. returns 200 { docId:integer, displayId:string, approvalStatus:string, steps:[{ step:string, label:string, at:string (date-time), by?:string, reason?:string (on a rejection) }] }, oldest step first, built from the newest 200 audit entries of the invoice; not paged | 404 'Document not found' guard a self-service submitter can read only their own invoice; the answer is the same 404 either way | Session + view_finance | manage_finance | submit_invoice (licensed) |
| GET | /parser/invoices | Paginated, filterable invoice ledger. body Query: page (default 1), limit (default 25, clamped to 1–200), sort_by (date | amount | amountBase | vendor | category | costCenter | status | approvalStatus | createdAt; default date), sort_dir (asc | desc; default desc), date_from, date_to, vendor, category, status, cost_center, approval_status, search. An unknown sort_by falls back to date; rows with the same sort value are ordered by id. returns 200 the InvoicePage object { items:[ Invoice ], total:integer, page:integer, limit:integer, pages:integer, filteredTotalAmount:number } — total and filteredTotalAmount cover every invoice matching the filters, not the page. Page-numbered: page and limit, no offset | Session + view_finance | manage_finance (licensed) |
| GET | /parser/invoices/export | Export the ledger as CSV. body Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search. returns 200 text/csv attachment zonitix-invoices.csv — every invoice matching the filters, newest date first, not paged. Columns: Invoice ID, Vendor, Canonical Vendor, Amount, Currency, Amount (Base), Date, Renewal Date, Category, Cost Center, Department, Project, Status, Filename | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/invoices/export.xlsx | Export the ledger as Excel. body Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search. returns 200 .xlsx attachment zonitix-invoices.xlsx, same filters as the CSV export; not paged | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/invoices/export.pdf | Export the ledger as PDF. body Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search. returns 200 application/pdf attachment zonitix-invoice-ledger.pdf, same filters as the CSV export; not paged | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| POST | /parser/invoices/import | Bulk-import invoices. body multipart form: file (UploadFile) required — CSV. No JSON body. returns 200 an InvoiceImportSummary { imported:integer, skipped:integer, errors:string[] (the first 50), currencyDefaulted:integer, amountAmbiguous:integer, dateHeld:integer, duplicatesSkipped:integer, duplicatesHeld:integer } | 400 unsafe file | 500 guard file_safety validation must pass. Repeat: a row whose vendor and invoice number are already on file is skipped and counted in duplicatesSkipped; a row with no invoice number is stored again, and held for review when an invoice with the same vendor, amount and date exists | Session + manage_finance (licensed) |
| GET | /parser/stats | Parsing and spend statistics. body No query parameters. returns 200 { baseCurrency, totalSpend, monthlyBudget, vendorCount, pendingCount, monthlyTrend, forecast, categoryBreakdown, topVendors, upcomingRenewals, awsBilling, azureBilling, awsInvoiceTotal, azureInvoiceTotal, awsReconcileSpend, azureReconcileSpend, cloudBillingByProvider, cloudPrimaryProvider, recentInvoices:[{ id, db_id, vendor, amount, currency, amountBase, costCenter, date, renewalDate, category, status }] } | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/health | Is the document parser answering. Add ?ready=1 to test its database as well. body Query: ready? (1 or true). returns 200 { status:'ok', service:'document-parser', results:[{ name:string, status: up | down | not-licensed }], rate:number }. With ready=1: 200 { status:'ready', service:'document-parser', db:'up' } | 503 { status:'degraded', service:'document-parser', db:'down', reason:string } guard Any signed-in person; the gateway requires a session. | Session |
| GET | /parser/finance/documents/:doc_id | One invoice in full. returns 200 the InvoiceDetail object | 404 'Document not found' | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/documents/:doc_id/file | The original uploaded file of an invoice. returns 200 the file | 404 'No original file stored for this document' | Session + view_finance | manage_finance | submit_invoice (licensed) |
| PATCH | /parser/finance/documents/:doc_id/review | Correct the extracted fields of an invoice held for review, and optionally release it. body The InvoiceReview object: { vendor?:string, category?:string, currency?:string, date?:string, amount?:number, po_number?:string (trimmed, upper-cased and cut to 64 characters), note?:string (trimmed and cut to 1000 characters), commit?:boolean }. commit releases an invoice that is held for review; it is read only from a caller who holds manage_finance. returns 200 { success:true, approvalStatus, changes } | 404 not found | 403 not your upload | 409 the invoice can no longer be changed, or it cannot be released yet | 400 an amount that cannot be read, or, for a caller who holds manage_finance, a currency with no exchange rate set guard a caller without manage_finance can correct only an invoice they uploaded | Session + manage_finance | submit_invoice (licensed) |
| PATCH | /parser/finance/documents/:doc_id/allocation | Allocate a document to cost centres. body The InvoiceAllocation object: { costCenter?:string, department?:string, project?:string, currency?:string, glCode?:string, customFields?:object }, all optional. currency re-derives the base amount; glCode must exist in the chart of accounts; customFields keeps only the keys defined in the allocation custom-field schema. doc_id is an integer in the path. returns 200 { success:true, id:integer } | 404 not found | 409 if Submitted/Approved/Paid (frozen) | 400 a GL code that is not in the chart of accounts | 500 guard 409 ledger-freeze — cost-center/currency frozen once in approval workflow. Repeat: the same values are stored again and the answer is 200, until the invoice is submitted | Session + manage_finance (licensed) |
| POST | /parser/finance/documents/:doc_id/:action | Approve or reject a document (approval flow with separation of duties). body The InvoiceAction object. action is in the path: submit | approve | reject | pay | clear_review. reject needs { reason:string }; approve reads { budget_override?:boolean, override_reason?:string, comment?:string }; pay reads no body; an approve with budget_override also needs override_reason and the approve_invoice permission; submit and clear_review read none either. returns 200 { success:true, approvalStatus:string } | 404 unknown action or doc | 403 SoD violation | 409 budget-exceeded or illegal transition | 400 illegal transition, a reject without a reason, or a budget override without a written reason guard per-action, amount-aware permissions: submit = manage_finance | approve_invoice | pay_invoice; reject = manage_finance | approve_invoice; approve = approve_invoice at or above the high-value floor (default 25000 in base currency), otherwise manage_finance | approve_invoice; pay = pay_invoice at or above the floor, otherwise manage_finance | pay_invoice; clear_review = manage_finance. SoD: approver != uploader & submitter, payer != approver. Budget-over approval needs budget_override. Repeat: each step checks the invoice's state first, so a second identical call is refused and changes nothing: submit, reject and clear_review answer 400 once the invoice has moved on, a second approve by the same person answers 409 while the invoice still waits for another approver and 400 once it is Approved, and pay answers 400 once the invoice is Paid | Session + per-action permission, see guard (licensed) |
| POST | /parser/finance/documents/bulk | Apply one action to many invoices: any single-invoice action, allocate or delete. body The InvoiceBulkAction object: { action:string, ids:integer[] (1 to 500), allocation?:InvoiceAllocation, reason?:string, budget_override?:boolean, override_reason?:string, comment?:string }. action is submit, approve, reject, pay, clear_review, allocate or delete; allocation is read for allocate and carries the same fields as the allocation route; reason is shared by every invoice of a reject, and comment by every invoice of an approve. returns 200 a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string, approvalStatus?:string } | 400 unknown action, empty list, or more than 500 ids guard to reach the route: allocate needs manage_finance; delete needs manage_finance | submit_invoice; every other action needs manage_finance | approve_invoice | pay_invoice. Each invoice then passes the same amount-aware checks as the single route, and one the caller may not action is reported in results. Repeat: each invoice gets what a repeat of its single route gets, reported in results; a delete of an invoice that is already gone is reported as failed | Session + per-action permission, see guard (licensed) |
| POST | /parser/finance/rematch/:doc_id | Re-run purchase-order matching for one invoice. body No body. returns 200 { success:true, matchStatus:string } | 404 'Document not found' | Session + manage_finance (licensed) |
| DELETE | /parser/documents/:doc_id | Delete a document. body No body. doc_id path param (int). returns 200 { success:true, message:string } | 404 not found | 403 not your document | 409 if Submitted/Approved/Paid, if it has approval or ledger history, or if it is still referenced elsewhere guard a caller without manage_finance may delete only a document they uploaded; a document dated in a locked accounting period cannot be deleted; only Draft, Rejected and held-for-review documents can be. Repeat: a second call answers 404 'Document not found' | Session + manage_finance | submit_invoice |
| PATCH | /parser/documents/:doc_id/status | Update a document status. body The DocumentStatusWrite object: { status:string } required. doc_id is an integer in the path. returns 200 { success:true, message:string } | 400 missing status | 404 not found | 403 IDOR or controlled target guard a caller who is not an administrator can change only a document they own; setting 'paid' or 'approved' is refused with 403 (those go through the approval flow). Repeat: the same status is stored again, the answer is 200 and another status_changed audit entry is written | Session + manage_finance |
| POST | /parser/screen-resume | Extract structured data from a resume. body multipart form: file required (pdf, docx or txt). Query parameters, all optional: job_description (string, default empty), competencies (a JSON array as text, default '[]'), requirements (a JSON array as text, default '[]'). returns 200 {success:true, match_score, score_source, ai_summary, raw_text_preview} | 400 unsupported/unsafe file | 500 | Session + view_recruitment | manage_recruitment |
| POST | /parser/extract-text | Extract raw text from a document. Internal: used between Zonitix services, not callable with a session or API token. body multipart form: file (UploadFile) required — pdf/docx/txt only. No JSON body. returns 404 through the gateway — the gateway refuses this path for every session and API token guard service-to-service only: the gateway blocks it by name, and the service itself rejects a call that arrived through the gateway | Internal only |
Finance: Insights, Vendors & Settings
document-parser-service (Python/FastAPI)
Spend insights, the vendor directory, custom fields, currency and exchange rates, finance settings, the digest and the finance audit trail. Exchange rates and the control settings are policy: manage_finance alone cannot change them. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.
Worked example: The vendor list with spend
API token holding view_finance or manage_finance. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/parser/finance/vendors
# Reply
200
{
"vendors": [
{
"id": 7, "canonicalName": "Acme", "aliases": ["ACME Ltd", "Acme Inc."],
"category": "Software", "owner": "dana.levi@example.com", "contractRef": null,
"contractRenewal": "2027-01-01T00:00:00", "notes": null, "customFields": {},
"totalSpend": 18420.5, "mergedIntoId": null
}
]
}
500 // the list could not be built
{ "detail": "..." }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /parser/finance/insights | Local-AI spend insights. body No query parameters. returns 200 { duplicates:[{ vendor, amount, daysApart, invoiceIds, dbIds, severity, message }], priceIncreases:[{ vendor, basis, previousAmount, latestAmount, increasePct, message }] } — priceIncreases is ordered by increasePct, largest first. Not paged: both lists come back whole; the handler takes no query parameters. | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/ask | Natural-language query over your finance data. body The FinanceQuestion object: { question:string } required; blank after trimming is refused. returns 200 { answer:string, aiUsed:boolean, grounded?:true (the answer was worked out from your figures without the model), aiDisabled?:true (AI features are switched off) } | 400 missing question | 500 guard answered by local LLM only, no data leaves box | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/variance | Spend variance figures. body No query parameters. returns 200 { currentMonth:{ label, amount }, vsLastMonth:{ label, amount, delta, deltaPct }, vsLastYear:{ label, amount, delta, deltaPct }, movers:[] } — movers holds at most 8 categories whose spend changed | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/commitments | Open commitments. body No query parameters. returns 200 { openPoCount, openPoValue, invoicedAgainst, uninvoicedCommitment } — over purchase orders that are neither closed nor cancelled, in base currency | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/category-trend | Spend by category over time. body Query: months (default 12). Clamped to 3–24. returns 200 { months:[label], series:[{ category, points:[] }] } — the 6 largest categories, the rest summed into one Other series | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/saas-spend | Spend per SaaS application. body No query parameters. returns 200 { apps:[], totalSpend, appCount, renewals:[{ window: 30 | 60 | 90, count, amount }], vendorsWithoutOwner, unusedCount, overBudgetCount, reclaimableMonthly, withUsageCount, reclaimableByDepartment:[{ department, reclaimableMonthly }] }. Not paged: apps holds every app in one reply. | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| PUT | /parser/finance/saas/:vendor_id/budget | Set or clear the annual budget of one application. body The AppBudgetWrite object: { amount?:number }. Zero, a negative number, a missing key, or a blank or non-numeric value clears the budget. returns 200 { vendor, period, budget } — period is the current year | 404 'App/vendor not found' | 409 when that budget is submitted, approved or locked (it then changes only through the budget routes; the refusal is audited) guard Repeat: safe to repeat: the one budget for that vendor and year is set to the same amount, and clearing a budget that is already cleared answers 200 with budget null | Session + manage_finance (licensed) |
| GET | /parser/finance/connectors | Finance connectors and their status. body No query parameters. returns 200 { connectors:[{ key, name, category, status: connected | identity | available, billing, note }], connectedCount, identityCount, availableCount }. Not paged: the whole connector list. | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/vendors | Vendor list. body No query parameters; not paged. returns 200 { vendors:[ Vendor ] } — every vendor, ordered by canonicalName | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| PATCH | /parser/finance/vendors/:vendor_id | Update vendor details. body The VendorUpdate object: { canonicalName?:string, category?:string, owner?:string, ownerUserId?:integer, contractRef?:string, contractRenewal?:string (date-time), notes?:string, aliases?:string[], customFields?:object }, all optional; only the keys sent are applied; customFields keeps only the keys defined in the vendor custom-field schema. vendor_id is an integer in the path. returns 200 { success:true, id:integer } | 404 vendor not found | 409 another vendor already has that name | 500 guard logs vendor_updated audit entry. Repeat: the same values are stored again and the answer is 200 | Session + manage_finance (licensed) |
| POST | /parser/finance/vendors/bulk-update | Set the category and/or owner on many vendors. body The VendorBulkUpdate object: { ids:integer[] (1 to 500), patch:VendorBulkPatch } with patch { category?:string, owner?:string }. patch must set at least one of the two. returns 200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or a patch that sets neither field guard Repeat: the same values are stored again; every vendor is reported ok and another vendor_updated audit entry is written for each | Session + manage_finance (licensed) |
| POST | /parser/finance/vendors/merge | Merge duplicate vendors. body The VendorMerge object: { keepId:integer, mergeId:integer }, both required (vendor ids). returns 200 a VendorMergeResult { success:true, keptVendorId:integer, repointed:integer (invoices moved), skippedLocked:array (invoices left in place because their accounting period is locked), loserRetained:boolean } | 400 missing ids or invalid/equal ids | 500 guard repoints invoices, absorbs aliases, deletes loser; logs vendor_merged. Repeat: when the merged vendor was deleted, a second call answers 400 'Invalid vendor ids' | Session + manage_finance (licensed) |
| GET | /parser/finance/vendor-custom-fields | Read the vendor custom-field schema. returns 200 { fields } — every field definition; not paged | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/vendor-custom-fields | Replace the vendor custom-field schema. body { fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema. returns 200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries guard Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged | Session + manage_finance (licensed) |
| GET | /parser/finance/allocation-custom-fields | Read the allocation custom-field schema. returns 200 { fields } — every field definition; not paged | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/allocation-custom-fields | Replace the allocation custom-field schema. body { fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema. returns 200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries guard Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged | Session + manage_finance (licensed) |
| GET | /parser/finance/allocation-values | Values already in use for the allocation fields, for pickers. body No query parameters. returns 200 { costCenters:string[], projects:string[], departments:string[] } — each sorted; departments also includes those in the synced organisation directory. Not paged. | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/categories | Spend categories in use. returns 200 { categories:string[] } (sorted); not paged | Session + view_finance | manage_finance | submit_invoice (licensed) |
| GET | /parser/finance/currency | Read currency configuration. body No query parameters. returns 200 a CurrencyTable { baseCurrency:string, missingRates:array, rates:[ CurrencyRate ] } with each rate { code:string, rateToBase:number, updatedAt:string (date-time) } — rates ordered by code | 500 { detail:string } Every stored rate comes back; not paged. | Session + view_finance | manage_finance | manage_finance_policy (licensed) |
| POST | /parser/finance/currency | Update currency configuration. body The CurrencyPolicyUpdate object: { baseCurrency?:string, code?:string, rateToBase?:number }. Sets the base currency, or one exchange rate (code and rateToBase together), or both. returns 200 the CurrencyTable as it now stands, with base totals worked out again | 400 invalid FX rate (non-numeric, <=0, or outside 1e-6..1e6) | 500 guard manage_finance alone is refused 403 and the attempt is audited as currency_change_denied; upsert_rate rejects insane rates; recompute_all_base re-values only Draft/Rejected (frozen otherwise); logs currency_changed. Repeat: safe to repeat: the same base currency and rate are stored again, the answer is 200 and another currency_changed audit entry is written, saying no field changed | Session + manage_finance_policy (licensed) |
| DELETE | /parser/finance/currency/:code | Delete an exchange rate. body No body. code is the three-letter currency in the path; it is upper-cased. returns 200 the CurrencyTable without that rate | 404 No exchange rate for <code> | 409 when the rate is still in use | 400 with the reason guard manage_finance alone is refused 403 and the attempt is audited. Repeat: a second call answers 404 'No exchange rate for <code>' | Session + manage_finance_policy (licensed) |
| GET | /parser/finance/settings | Read the editable finance settings with their defaults. returns 200 { settings:[{ key, label, kind, value, default, critical }] } — every setting; not paged | Session + view_finance | manage_finance (licensed) |
| PUT | /parser/finance/settings | Change one finance setting. body The FinanceSettingWrite object: { key:string, value }. One setting per call. returns 200 { key, value } (as stored) | 400 an unknown key, or a non-numeric value for a number setting — a number outside the setting's range is clamped to it, not refused guard the control settings — enforce_sod, finance.high_value_floor, match.tolerance_pct and match.require_grn — need manage_security; every other key needs manage_finance; only keys on the fixed list can be written. Repeat: safe to repeat: the one stored value is written again, and another setting_changed audit entry is written | Session + manage_finance, or manage_security for a control setting (licensed) |
| GET | /parser/finance/digest | The finance digest summary (the scheduled digest email is built from the same summary). body No query parameters. returns 200 { baseCurrency, totalSpend, invoiceCount, committedCount, pendingApproval, awaitingFinance:{ draft, draftAmount, held, heldAmount }, topVendors:[{ name, amount }], exceptions, momVariance, fxWarnings, generatedAt } | 500 { detail:string } | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/digest/send | Send the finance digest now. body The DigestSend object: { recipients?:string[] }, optional (email addresses); without it the configured recipients are used. Addresses are trimmed, lower-cased and de-duplicated. returns 200 { success:true, message:string } | 400 if send fails (message from send_digest) guard best-effort SMTP send via customer's configured SMTP — failure returns 400 not 500. Repeat: not deduplicated: every call queues the digest again for each recipient | Session + manage_finance (licensed) |
| GET | /parser/finance/audit | Finance audit trail. body Query: doc_id?, limit (default 200, clamped to 1–500). returns 200 { entries } — newest first: { id, docId, action, actorId, actorName, detail, at }; limit is the only paging control, there is no offset | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/audit.csv | Controls-evidence export of the finance audit trail (auditor-ready CSV; who submitted/approved/paid/deleted what). body Query: doc_id? (scope to one document), action? (filter one action). No JSON body. returns 200 text/csv attachment 'finance_audit_trail.csv', oldest entry first. Not paged, and capped at 10,000 entries: on a longer trail the newest entries are the ones left out, so narrow the file with doc_id or action, or read the newest with GET /finance/audit | 500 guard logs an 'exported' finance-audit entry recording the export itself | Session + view_finance | manage_finance (licensed) |
Finance: Purchasing, Approvals & Close
document-parser-service (Python/FastAPI)
Purchase orders, goods receipts, match exceptions, approval rules, the approvals queue, the chart of accounts, period close, journals and ledger sync. Approval rules are policy and need manage_finance_policy to change; closing a period needs close_finance. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.
Worked example: Create a purchase order
Session cookie of a user holding manage_finance, plus an Origin header because it is a write. With an API token, send the Bearer header instead and leave Origin out. The values shown are made up; the field names and the shape are what the route sends.
curl -X POST --cookie '__Host-token=<session-jwt>' -H 'Origin: https://<your-instance>.zonitix.com' -H 'Content-Type: application/json' \
-d '{"po_number": "PO-2026-014", "vendor": "Acme", "currency": "EUR", "total_amount": 5000, "department": "Engineering"}' \
https://<your-instance>-api.zonitix.com/api/v1/parser/finance/purchase-orders
# Reply
200
{ "success": true, "id": 14, "po_number": "PO-2026-014" }
409 // sent again with the same number: nothing is created
{ "detail": "PO PO-2026-014 already exists" }
400
{ "detail": "po_number is required" }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /parser/finance/purchase-orders | List purchase orders. body Query: status? No paging parameters. returns 200 { items:[ PurchaseOrder ], baseCurrency:string } — newest first, at most 500 | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/purchase-orders | Create a purchase order. body The PurchaseOrderCreate object: { po_number:string } required; vendor?:string, currency?:string (default USD), total_amount?:number, cost_center?:string, department?:string, project?:string, lines?:[ PurchaseOrderLineInput ] (each { description?:string, quantity?:number, unit_price?:number }), budget_override?:boolean. With lines, the total is the sum of quantity times unit_price and total_amount is not read. returns 200 { success:true, id:integer, po_number:string } | 400 po_number is required | 409 the PO number already exists, or the PO would exceed its budget and budget_override is not set. A repeat with the same po_number answers 409 and creates nothing guard Repeat: the PO number is unique, so a second call with the same po_number answers 409 and creates nothing | Session + manage_finance (licensed) |
| GET | /parser/finance/purchase-orders/:po_id | One purchase order with its lines and receipts. returns 200 the PurchaseOrderDetail object | 404 'Purchase order not found' | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/purchase-orders/:po_id/close | Close, cancel or reopen a purchase order. body The PurchaseOrderStatusWrite object: { status?:string }: closed (default), cancelled or open. returns 200 { success:true, id:integer, status:string } | 404 not found | 400 an unknown status guard Repeat: the same status is stored again, the answer is 200 and another po_status_changed audit entry is written | Session + manage_finance (licensed) |
| POST | /parser/finance/purchase-orders/bulk-close | Close, cancel or reopen many purchase orders. body The PurchaseOrderBulkStatus object: { ids:integer[] (1 to 500), status?:string }: closed (default), cancelled or open. returns 200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or an unknown status guard Repeat: as the single route: every order is reported ok again | Session + manage_finance (licensed) |
| POST | /parser/finance/goods-receipts | Record a goods receipt against a purchase order. body The GoodsReceiptCreate object: { po_id:integer } required; grn_number?:string, lines?:[ GoodsReceiptLineInput ] (each { po_line_id?:integer, quantity_received?:number }). returns 200 { success:true, id:integer } | 404 'Purchase order not found'. Not deduplicated: every call records another receipt against the purchase order, so read the purchase order back before re-sending guard Repeat: not deduplicated: every call records another receipt against the purchase order | Session + manage_finance (licensed) |
| GET | /parser/finance/match-exceptions | Invoice-to-order match exceptions. body Query: status (default open). An empty status returns every status. No paging parameters. returns 200 { exceptions:[ MatchException ] } — newest first, at most 200 | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/match-exceptions/:exc_id/waive | Waive one match exception. body The MatchExceptionWaive object: { reason:string } required. returns 200 { success:true } | 404 not found | 400 no reason | 403 guard where segregation of duties applies to the invoice amount, the person who uploaded or submitted the invoice cannot waive its exception. Repeat: a second call answers 200 and stores the newer reason and the person who gave it | Session + manage_finance (licensed) |
| POST | /parser/finance/match-exceptions/bulk-waive | Waive many match exceptions with one reason. body The MatchExceptionBulkWaive object: { ids:integer[] (1 to 500), reason:string }, both required. returns 200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or no reason guard each exception passes the same check as the single route. Repeat: an exception that is no longer open is not waived again: it is reported in results as failed with "Already waived" | Session + manage_finance (licensed) |
| GET | /parser/finance/approval-rules | List the amount-tiered approval rules. returns 200 { rules:[ ApprovalRule ] }, lowest minAmount first; every rule, not paged | Session + view_finance | manage_finance | manage_finance_policy (licensed) |
| GET | /parser/finance/approval-rules/preview | Show which approval tier an amount would fall in. body Query: amount (default 0). returns 200 { amount:number, requiredApprovals:integer, rule:ApprovalRule|null } | 400 an amount that cannot be read | Session + view_finance | manage_finance | manage_finance_policy (licensed) |
| POST | /parser/finance/approval-rules | Create an approval rule. body The ApprovalRuleWrite object: { name?:string (at most 80 characters, default "Rule"), minAmount?:number (default 0), maxAmount?:number|null (null for no ceiling), requiredApprovals?:integer (1 to 10, default 1), isActive?:boolean (default true) }. returns 200 { success:true, id:integer } | 400 with the validation message guard manage_finance alone is refused 403 and the attempt is audited. Repeat: not deduplicated: every call creates another rule, also with the same name and amounts | Session + manage_finance_policy (licensed) |
| PATCH | /parser/finance/approval-rules/:rule_id | Change an approval rule. body The ApprovalRuleWrite object; only the keys sent are changed, and the result is validated as a whole (maxAmount must stay above minAmount). returns 200 { success:true } | 400 with the validation message | 404 'Rule not found' guard manage_finance alone is refused 403 and the attempt is audited. Repeat: the same values are stored again, the answer is 200 and another approval_rule_updated audit entry is written, saying no field changed | Session + manage_finance_policy (licensed) |
| DELETE | /parser/finance/approval-rules/:rule_id | Delete an approval rule. body No body. rule_id is an integer in the path. returns 200 { success:true } guard manage_finance alone is refused 403 and the attempt is audited. Repeat: a second call, like a call for a rule that never existed, answers 200 { success:true } and changes nothing | Session + manage_finance_policy (licensed) |
| GET | /parser/finance/approvals-queue | Submitted invoices awaiting approval, oldest first, with approval progress. returns 200 { queue:[ ApprovalQueueItem ] } — at most 200 rows | Session + view_finance | manage_finance | approve_invoice (licensed) |
| GET | /parser/finance/documents/:doc_id/approvals | Approval history and progress of one invoice. returns 200 { required:integer, count:integer (distinct approvers so far), actions:[{ actorId:integer|null, actorName:string|null, decision:string, comment:string|null, at:string (date-time) }] }, oldest action first; every action, not paged | 404 'Document not found' | Session + view_finance | manage_finance (licensed) |
| GET | /parser/finance/gl-accounts | List the chart of accounts (GL accounts). body No body. returns 200 { accounts:[ GLAccount ] } (ordered by code); every account, not paged | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/gl-accounts | Create a GL account. body The GLAccountCreate object: { code:string } required; name?:string (defaults to code), type?:string. returns 200 { success:true, id:integer } | 400 code is required | 409 GL account <code> already exists guard code unique; logs gl_account_created. Repeat: the code is unique, so a second call with the same code answers 409 and creates nothing | Session + manage_finance (licensed) |
| GET | /parser/finance/close/:period | Month-end close readiness for a period (open blockers: documents in review/awaiting approval, unresolved match exceptions). body period path param (e.g. '2026-08'). No body. returns 200 { period:string, status:string (open until the period is locked), needsReview:integer, awaitingApproval:integer, openExceptions:integer, committed:integer, total:integer, uncoded:integer, readyPct:integer, canLock:boolean }; canLock is true when the first three counts are all zero | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/close/:period | Lock a period (month-end close). Posts balanced double-entry GL journals (net expense / input tax / AP) for the period; refuses to lock a dirty period. body The PeriodClose object: { force?:boolean, reason?:string }. period is in the path (such as 2026-08). Forcing a close over open blockers requires a written reason. returns 200 a PeriodCloseResult { period:string, status:'locked', journalsPosted:integer, batch:string, forced:boolean } | 400 'Forcing a close over open items requires a written reason.' | 409 'Period <period> is already locked.' — what a repeat gets | 409 when the period still has open blockers and force is not set guard a dedicated close permission separated from approve/pay (SoD); a forced override is captured on the audit trail with its reason. Repeat: a second call answers 409 'Period <period> is already locked.' and posts nothing | Session + close_finance (licensed) |
| GET | /parser/finance/close/:period/journals.csv | Export the period's double-entry GL journals as CSV in a QuickBooks, Xero, or generic template. body period path param; query format=generic|quickbooks|xero (default generic; unknown falls back to generic). No JSON body. returns 200 text/csv attachment 'journals_<period>_<fmt>.csv' guard logs an 'exported' entry recording the period + format | Session + view_finance | manage_finance (licensed) |
| POST | /parser/finance/adjustments | Post a manual adjustment journal. body The AdjustmentCreate object: { reason:string, debitGl:string, creditGl:string, amount:number }, all required; amount greater than zero; the two accounts must differ and, once a chart of accounts exists, both must be in it. returns 200 { period, batch, amount, debitGl, creditGl } — booked to the current period | 400 { detail:string } for a missing reason, a missing or identical account, an account not in the chart, or an amount that is not positive | 409 { detail:string } when the current period is locked guard not deduplicated: every call posts another balanced debit and credit pair; the batch reference is ADJ-<UTC timestamp to the second>, so two calls in the same second share it; the entry and its reason are written to the finance audit trail. Repeat: not deduplicated: every call posts another debit and credit pair | Session + manage_finance | close_finance (licensed) |
| GET | /parser/finance/reconciliation | Reconciliation figures for one period. body Query: period (required). returns 200 { period:string, invoiceActual:number, glPosted:number, difference:number, accounts:[{ glCode:string, invoiceActual:number, glPosted:number, difference:number }], reconcilingItems:[ the accounts whose difference is at least 0.01 ] }, accounts ordered by glCode; every account, not paged | 422 when period is absent | 400 when it is empty | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed) |
| GET | /parser/finance/gl-sync | Outbound GL-sync status: active connector, and synced vs pending journal counts. body Query: period? optional (scope counts to one period). No JSON body. returns 200 { connector, period, total, synced, pending, live, configured } — 'manual' is always configured; a live connector reports configured:false until its credentials are supplied | Session + view_finance | manage_finance (licensed) |
| PUT | /parser/finance/gl-sync/connector | Choose the outbound GL connector. body The GLConnectorWrite object: { connector:string }: one of manual | quickbooks | netsuite | xero. returns 200 { connector } | 400 Unknown GL connector '<x>' guard logs gl_sync_connector; only 'manual' (file/CSV transport) is active on an instance without provider credentials. Repeat: safe to repeat: the one stored setting is written again with the same value | Session + manage_finance (licensed) |
| POST | /parser/finance/gl-sync/:period | Push the period's unsynced journals through the active connector. Idempotent — a journal that already carries an external_ref is never pushed again, so a re-run or retry cannot double-book. body No body. period is in the path. returns 200 a GLSyncRun { connector:string, pushed:integer, live:boolean, note:string } | 400 (fail-closed) if the selected live connector's OAuth credentials are not configured — never a silent no-op | 500 guard 'manual' stamps a deterministic external_ref and marks rows exported; live connectors fail closed until credentialled; logs gl_synced. Repeat: with the manual connector a second call finds nothing left to mark and answers 200 with pushed 0 | Session + manage_finance (licensed) |
| GET | /parser/finance/chargeback | Chargeback breakdown by cost centre. body Query: dimension (default cost_center), period? dimension is cost_center | department | project | category; any other value is treated as cost_center. returns 200 { dimension, period, allocations:[{ label, amount, invoiceCount, sharePct }] } — largest amount first; spend with no value on the dimension is labelled Unallocated. Not paged: every value of the dimension in one reply. | 500 { detail:string } | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed) |
Finance: Budgets, Transfers & Spend Requests
document-parser-service (Python/FastAPI)
Budgets and their lifecycle (submit, approve, reforecast, lock, freeze), budget lines, alerts, transfers, spend requests and linked systems. Budget permissions come in two kinds: organisation-wide ones (view_finance, manage_finance, view_finance_consolidated, manage_all_finance_budgets, approve_finance_budget) and scoped ones (view_finance_budgets, manage_finance_budget, submit_finance_budget) that the service limits to the caller's own scope; a scoped maker can change only a budget they own. A budget outside the caller's scope answers 403 or 404 without confirming that it exists. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.
Worked example: The budgets you may see
API token holding view_finance (or any other permission the row lists); the reply holds only the budgets in that user's scope. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/parser/finance/budgets
# Reply
200
{
"budgets": [
{
"id": 3, "scopeType": "department", "scopeValue": "Engineering", "label": "Engineering",
"name": null, "displayName": "Engineering \u00b7 2026", "period": "2026",
"currency": "USD", "baseCurrency": "USD", "budget": 250000.0, "budgetEntered": 250000.0,
"actual": 148210.75, "committed": 5000.0, "softCommitted": 0.0, "available": 96789.25,
"remaining": 101789.25, "consumedPct": 59.3, "status": "OK", "lifecycleStatus": "Approved",
"versionNo": 1, "isCurrent": true, "locked": true, "frozen": false
// ... the remaining Budget fields
}
]
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /parser/finance/budgets | List budgets. body Query: budgetType?, status?, period?, scopeType?, department?, over?:boolean, atRisk?:boolean, sort? sort is period | name | budget | actual | available | consumed, with a leading - for descending; without it rows come newest period first. No paging parameters. returns 200 { budgets:[ Budget ] } — every budget in the caller's scope that matches; not paged | 500 { detail:string } guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/budgets | Create a budget. body The BudgetWrite object. { period:string } required ('YYYY-MM' or 'YYYY'); scopeType?:string (default 'global'), scopeValue?:string, amount?:number (default 0), currency?:string, name?:string, description?:string, budgetType?:string (default 'opex'), method?:string (default 'top_down'), startDate?, endDate?, periodKind?:string, authoritativeSource?: invoice | cloud | both (default invoice), tags?:array, glAccountId?:integer, recurrence?:string, recurrenceInterval?:string, warnPct?:number, overPct?:number (fractions, 0 to 1). returns 200 the Budget object | 400 missing period or an invalid value | 403 a scope outside the caller's authority (audited) | 500 guard the owner is stamped by the server; a scoped maker owns what they create; logs budget_created. Repeat: not deduplicated: every call creates another budget, also for the same scope and period | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| PATCH | /parser/finance/budgets/:budget_id | Update a budget. body The BudgetWrite object, all keys optional; only the keys sent are applied: scopeType, scopeValue, period, amount, currency, name, description, budgetType, method, startDate, endDate, periodKind, authoritativeSource, tags, glAccountId, recurrence, recurrenceInterval, warnPct, overPct. budget_id is an integer in the path. returns 200 the Budget object | 404 budget not found | 403 not your budget (audited) | 409 the budget is locked (approved) — reforecast it to get an editable version | 400 | 500 guard a scoped maker can edit only their own budget; editing an approved budget that was unlocked sends it back for approval; logs budget_updated | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| DELETE | /parser/finance/budgets/:budget_id | Delete a budget. body No body. budget_id path param (int). returns 200 {success:true} | 403 not your budget | 404 budget not found | 409 an approved budget cannot be deleted guard logs budget_deleted. Repeat: a second call answers 404 'Budget not found' | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/summary | Organisation-wide budget summary. body Query: period? returns 200 { departments:[], totals } — departments ordered by budget, largest first. Not paged. | 500 { detail:string } | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed) |
| GET | /parser/finance/budgets/export.csv | Budget, actual and committed figures as CSV. returns 200 text/csv: every budget the caller may see, in one file; no query parameters are read guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/approvals-inbox | Budget approvals inbox for the caller. body No query parameters. returns 200 { budgets:[ Budget ], transfers:[ BudgetTransfer ], count:integer } — submitted budgets the caller may approve and pending transfers the caller may act on; count is their sum. Not paged: budgets holds every one; transfers holds at most 200. | 500 { detail:string } | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/alerts | Budget alerts. body Query: includeAck?:boolean (default false). returns 200 { alerts:[ BudgetAlert ] } — newest first, at most 100; no paging parameters guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/budgets/alerts/sweep | Run the budget alert check now. body No body. returns 200 an AlertSweepResult { evaluated:integer, fired:integer } | 500 { detail:string } guard Repeat: safe to repeat: an alert is raised only when a budget has moved to a higher tier since its last alert, so a second sweep with unchanged figures answers fired 0 | Session + manage_finance | manage_all_finance_budgets | view_finance_consolidated (licensed) |
| POST | /parser/finance/budgets/alerts/:alert_id/ack | Acknowledge a budget alert. body No body. returns 200 { success:true } | 404 'Alert not found' guard Repeat: a second call answers 200 and records the newer acknowledgement | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/audit | Audit trail of budget actions. body Query: limit (default 500, clamped to 1–2000). returns 200 { entries:[{ id:integer, docId:integer|null, action:string, actorId:integer|null, actorName:string|null, detail:string|null, at:string (date-time) }] }; limit is the only paging control, there is no offset | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed) |
| GET | /parser/finance/budgets/audit.csv | Audit trail of budget actions as CSV. returns 200 text/csv, attachment 'budget-audit-evidence.csv'. No query parameters are read. The file is capped at 10,000 entries, oldest first: on a longer trail the newest entries are the ones left out, so read those with GET /finance/budgets/audit | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed) |
| POST | /parser/finance/budgets/:budget_id/submit | Submit a budget for approval. body No body. returns 200 { id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Draft or Rejected (what a repeat gets), 409 for a historical version or a locked budget, 403 for a budget that is not the caller's guard Repeat: a second call answers 409, because the budget is no longer Draft or Rejected | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/approve | Approve a submitted budget. body No body. returns 200 { id:integer, status:string, locked:boolean } | the lifecycle error status with its message — 409 unless the budget is Submitted (what a repeat gets) guard Repeat: a second call answers 409, because the budget is no longer Submitted | Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/reject | Reject a submitted budget. body The BudgetReject object: { reason:string } required. returns 200 { id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Submitted, 400 when no reason is given, 403 when the caller is not an eligible approver guard Repeat: a second call answers 409, because the budget is no longer Submitted | Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/reforecast | Create a new editable version of an approved budget. body No body. returns 200 { id:integer, versionNo:integer, status:string } — the new version | 409 for a historical version | 403 for a budget that is not the caller's guard Repeat: every call creates a further version and makes it the current one | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/lock | Lock or unlock a budget. body The BudgetLock object: { locked?:boolean }, default true. A call with no body locks. returns 200 { id:integer, locked:boolean } | the lifecycle error status with its message guard Repeat: the same lock state is stored again, the answer is 200 and another budget_locked or budget_unlocked audit entry is written | Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | manage_finance_policy (licensed) |
| POST | /parser/finance/budgets/:budget_id/freeze | Freeze or release a budget. An invoice approval against a frozen budget is refused with 409, and a budget override does not lift it. body The BudgetFreeze object: { frozen?:boolean (default true), reason?:string }. A call with no body freezes. returns 200 { id:integer, frozen:boolean } guard the budget must be inside the caller's scope. Repeat: a second freeze answers 200 and stores the newer reason, time and person; lifting a freeze that is already lifted answers 200 | Session + manage_finance | manage_all_finance_budgets | close_finance (licensed) |
| POST | /parser/finance/budgets/:budget_id/copy | Copy a budget forward into another period. body The BudgetCopy object: { period:string } required ('YYYY-MM' or 'YYYY'). returns 200 the new Budget object, a Draft at version 1 with the lines of the source | 400 period is required guard a scoped maker owns the copy. Repeat: not deduplicated: every call creates another copy for the period | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/:budget_id/actuals | Actual spend behind a budget. body No query parameters. returns 200 { invoices:[], commitments:[], cloud, invoiceCount, invoiceTruncated, invoiceSum, commitmentsTracked } — invoices are newest first and capped; invoiceTruncated is true when invoiceCount is larger than the list returned guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/:budget_id/versions | Version history of a budget. returns 200 { versions:[{ id:integer, versionNo:integer, versionLabel:string|null, amount:number, status:string, isCurrent:boolean, locked:boolean, approvedBy:integer|null, approvedByEmail:string|null, approvedAt:string (date-time)|null }] } — newest version first. Every version, not paged guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/:budget_id/forecast-accuracy | Forecast against actual, per version. returns 200 { history } — [{ period, forecast, actual, … }], oldest period first; not paged guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/:budget_id/approval-records | Approval records of a budget. returns 200 { records } | 404 'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget | close_finance (licensed) |
| GET | /parser/finance/budgets/:budget_id/lines | Line items of a budget. returns 200 { lines:[ BudgetLine ], method:string }; every line, not paged guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/lines | Add a line to a budget. body The BudgetLineWrite object: { label?:string, amount?:number } or driver-based { driverKey?:string, driverQty?:number, unitCost?:number }; glAccountId?:integer, category?:string, costCenter?:string, justification?:string, sortOrder?:integer. returns 200 the BudgetLine | 404 budget not found | 403 not your budget | 409 the budget is locked guard editing the lines of an approved budget sends it back for approval. Repeat: not deduplicated: every call adds another line | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| PATCH | /parser/finance/budgets/:budget_id/lines/:line_id | Change a budget line. body The BudgetLineWrite object; only keys present are applied and the line amount is recalculated. returns 200 the BudgetLine | 404 'Line not found' — also when the line belongs to another budget | 403 not your budget | 409 the budget is locked | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| DELETE | /parser/finance/budgets/:budget_id/lines/:line_id | Delete a budget line. body No body. budget_id and line_id are integers in the path. returns 200 { success:true } | 404 'Line not found' | 403 not your budget | 409 the budget is locked guard Repeat: a second call answers 404 'Line not found' | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/budgets/:budget_id/comments | Comments on a budget. returns 200 { comments:[ BudgetComment ] }; every comment, not paged guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/budgets/:budget_id/comments | Comment on a budget. body The BudgetCommentCreate object: { comment:string } required. returns 200 the BudgetComment | 400 comment is required guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope. Repeat: not deduplicated: every call adds another comment | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/transfers | Budget transfers the caller may see. body No query parameters. returns 200 { transfers:[ BudgetTransfer ] } — newest first, at most 200 | Session + transfer_finance_budget | manage_all_finance_budgets (licensed) |
| POST | /parser/finance/transfers | Request a transfer between two budgets. body The BudgetTransferCreate object: { fromBudgetId:integer, toBudgetId:integer, amount:number } required; currency?:string, reason?:string. returns 200 the BudgetTransfer | the transfer error status with its message — 400 when the two budgets are the same or the amount is not positive, 404 when a budget is not found guard Repeat: not deduplicated: every call records another pending transfer | Session + transfer_finance_budget | manage_all_finance_budgets (licensed) |
| POST | /parser/finance/transfers/:transfer_id/approve | Approve a budget transfer. body No body. returns 200 the BudgetTransfer (applied once its status is Approved) | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 409 'You have already approved this transfer.' (what a repeat by the same approver gets), 403 for the person who requested it, 409 when the source budget has insufficient funds or either budget is locked guard Repeat: a second approve by the same person answers 409 'You have already approved this transfer.', and any approve after the transfer has left Pending answers 409 | Session + transfer_finance_budget | manage_all_finance_budgets (licensed) |
| POST | /parser/finance/transfers/:transfer_id/reject | Reject a budget transfer. body The TransferReject object: { reason?:string }. returns 200 the BudgetTransfer | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 403 for the person who requested it guard Repeat: a second call answers 409, because the transfer is no longer Pending | Session + transfer_finance_budget | manage_all_finance_budgets (licensed) |
| POST | /parser/finance/approvals/bulk | Approve or reject many budgets and transfers in one call. body The BudgetApprovalsBulk object: { action: approve | reject, budgetIds?:integer[], transferIds?:integer[], reason?:string }. At least 1 and at most 500 items in total. A reject needs reason for each budget: without it each budget is reported in results as failed; a transfer can be rejected without one. returns 200 a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { kind: budget | transfer, id, ok:boolean, error?:string } | 400 unknown action, ids that are not lists, nothing to act on, or more than 500 items guard each budget needs the budget-approval permissions and each transfer the transfer permissions, checked per item; an item the caller may not decide is reported in results. Repeat: each item gets what a repeat of its single route gets (409 once decided), reported in results as failed | Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | transfer_finance_budget (licensed) |
| GET | /parser/finance/spend-requests | Spend requests the caller may see. body Query: includeClosed?:boolean (default false). returns 200 { requests:[ SpendRequest ] } — newest first, at most 200; no paging parameters | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/spend-requests | Request spend against a budget. body The SpendRequestCreate object: { budgetId:integer, amount:number } required; currency?:string, description?:string, vendor?:string. returns 200 the SpendRequest | the request error status with its message — 400 when the amount is not positive, 404 or 403 for a budget outside the caller's scope guard Repeat: not deduplicated: every call records another pending request | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/spend-requests/:request_id/:action | Approve, reject, fulfil or cancel a spend request. body The SpendRequestDecision object. action is in the path: approve | reject | fulfill | cancel. reject and cancel read { reason?:string }; approve and fulfill read no body. returns 200 the SpendRequest | 404 'Unknown action' | the request error status with its message — 409 when the request has already been decided (what a repeated approve, reject or cancel gets); fulfill answers 409 unless the request is approved; 403 for the requester approving their own request guard approve and reject need manage_finance | manage_all_finance_budgets | approve_finance_budget; fulfill needs manage_finance | manage_all_finance_budgets; cancel needs any budget-read permission, and the service then decides who may cancel which request. Repeat: a second approve, reject or cancel answers 409, because the request has already been decided | Session + per-action permission, see guard (licensed) |
| GET | /parser/finance/systems | Systems and subscriptions linked to budgets. returns 200 { systems:[ OwnedSystem ] } — most recently linked first; not paged guard scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/systems/providers | Providers a system can be linked from. returns 200 { providers:[{ externalProviderId:integer, providerType:string, name:string, metering: live | derived | discovery, hasBilling:boolean }] }; not paged | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
| POST | /parser/finance/systems | Link a system to a budget. body The OwnedSystemClaim object: { budgetId?:integer, providerType?:string, externalProviderId?:integer, systemRef?:string, label?:string, kind?:string, ownerEmail?:string }. returns 200 the OwnedSystem | 403 | 404 with the reason guard for a scoped manager the owner is forced to the caller. Repeat: not deduplicated: every call links the system again as a new record | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| DELETE | /parser/finance/systems/:system_id | Unlink a system from its budget. body No body. system_id is an integer in the path. returns 200 { success:true } | 403 | 404 'System not found' guard Repeat: a second call answers 404 'System not found' | Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed) |
| GET | /parser/finance/cloud/anomalies | Month-over-month cloud cost spikes on linked systems. returns 200 { anomalies:[{ system:string, provider:string, budgetId:integer|null, currentMonth:number, previousMonth:number, changePct:number, delta:number, severity: high | medium }] }; not paged | Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed) |
Local AI
ai-service
A local LLM (Ollama) running on your instance. Prompts and candidate data are not sent to an outside AI service, and there is no third-party AI API key in this path. One small model, sized to run on the instance CPU, serves every AI feature: an administrator picks it in Settings from a fixed allow-list of two — qwen3:0.6b (the default) or the lighter qwen2.5:0.5b — and a model outside the allow-list is refused with 400. No larger model ships, so treat the output as a drafting and triage aid that a person reviews, not as a decision.
Worked example: Is the local AI engine ready
Any signed-in session. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/ai/health
# Reply
200 // always 200; read "status"
{
"status": "healthy",
"ollama": "up",
"model": "qwen3:0.6b",
"modelPresent": true,
"installedModels": 2,
"engine": "Ollama",
"lastModel": "qwen3:0.6b",
"auditWrite": { "failures": 0, "lastAt": null, "lastAction": null, "recentFailures": false }
}
// When degraded: "status": "degraded", "ollama": "down", "modelPresent": false, plus a "reason" string.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /ai/score-candidate | Score a candidate against a job description. body { resumeText } required; jobTitle, jobDescription, requirements[], competencies optional. resumeText truncated to 3000 chars, jobDescription to 1000. returns 200 { success:true, matchScore:int 0-100 (clamped), aiSummary } | 400 { success:false, error:'No resume text provided' } | 500 { success:false, error:'AI processing failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M x-internal-service-token required or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/score-candidate-stream | Same, streamed incrementally into the UI. body { resumeText } required; jobTitle, jobDescription, requirements[] optional. returns 200 text/plain chunked stream (first line 'SCORE: <n>', then SUMMARY/STRENGTHS/GAPS/VERDICT) | 400 { success:false, error:'No resume text provided' } | 500 { success:false, error:'AI processing failed' } guard requirePermission(['view_recruitment','manage_recruitment']); aborts Ollama generation on client disconnect; M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/generate-competencies | Generate scorecard competencies for a role. body { jobTitle } required; jobDescription optional (truncated 800 chars). returns 200 { competencies: string[] } (deduped, sliced to 12) | 400 { error:'jobTitle is required' } | 500 { error:'AI processing failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/generate-interview-questions | Generate structured interview questions. body { jobTitle } required; department, jobDescription, competencies[], requirements[], seniority, minExperience, employmentType optional. returns 200 { questions:[{ questionText, category }] } | 400 { error:'jobTitle is required' } | 500 { error:'Interview question generation failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/generate-offer-template | Draft an offer template. body No required field. jobTitle, department, location, salary, salaryRange, currency, managerName, jobDescription, competencies[], employmentType, seniority, workMode all optional. returns 200 { body } (square-bracket fill-ins stripped/normalized; only {{Curly}} merge fields kept) | 500 { error:'Offer template generation failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/calibrate-scorecards | Calibrate scoring across interviewers. body { scorecards: [] } required and non-empty; candidateName, jobTitle optional. returns 200 { executiveSummary, consensusLevel, conflicts:[{topic,description}], biasFlags:string[], recommendation } | 400 { error:'No scorecards provided' } | 500 { error:'Calibration analysis failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/pipeline-insights | Summarise pipeline health. body { summary } (numeric pipeline snapshot object). Missing summary short-circuits to empty. returns 200 { insights:[{ title, desc, severity }] } — FAILS OPEN: empty summary or any error returns 200 { insights:[] }, no 4xx/5xx guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/chat | General recruitment assistant chat. body { query } required (branches to template-mode on keywords like 'template'/'offer letter'); context optional (PII-redacted before prompt). returns 200 { response } (or safe refusal string if output looks like an injection-echo) | 500 { error:'AI engine unreachable' } guard requirePermission(['view_recruitment','manage_recruitment']); context PII-redacted (SSN/card/IBAN/email/phone); injection-echo output firewall; M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/refine-text | Rewrite or refine drafted text. body { text } required. returns 200 { refinedText } | 400 { error:'No text provided' } | 500 { error:'Refinement failed' } guard requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401 | Session + view_recruitment | manage_recruitment |
| POST | /ai/analyze-report | Narrative analysis of a compliance/reporting dataset. body { reportData } required (object: metadata, users[], groups[], workflows[], tasks[], logs[], frameworkDetails). Minimized server-side before prompt. returns 200 text/plain chunked stream (<think>...</think> then JSON: executiveSummary, riskLevel, metrics, anomalies[], recommendedActions[]) | 400 { success:false, error:'No report data provided' }. On AI error FAILS OPEN: streams a fallback JSON with 200 guard requirePermission('view_reporting'); aborts Ollama generation on client disconnect; M2M token or 401 | Session + view_reporting |
| POST | /ai/draft-justification | Draft a compliance justification. body { controlId, controlTitle, rawData, notes } — all optional (no server-side required-field validation). returns 200 { justification } | 500 { error:'Failed to draft justification note' } guard requirePermission('manage_compliance'); M2M token or 401 | Session + manage_compliance |
| POST | /ai/draft-workflow | Draft workflow steps from a plain-English description (the Workflow Builder's "Generate from description"). A suggestion only: it pre-fills the canvas as an unsaved draft that still goes through save and approval. body { description } required (non-empty string); workflowType, palette[] optional returns 200 a list of steps drawn only from the offered palette | 400 missing description / 'No licensed step types to draft from.' guard step types are constrained to the palette, and types whose module is switched off on the licence are removed server-side; the builder does not offer account, hardware or app provisioning steps to the model | Session + manage_workflows |
| POST | /ai/extract-resume-fields | Extract structured, filterable fields from resume text for recruiter-editable tags. Not a decision input. body { resumeText } required returns 200 { skills (up to 40), titles (up to 20), employers (up to 20), education (up to 15), totalYears (number or null), seniority (string or null) } | 400 resumeText is required | 500 AI processing failed | Session + view_recruitment | manage_recruitment |
| POST | /ai/generate-job-description | Draft a job description. body { jobTitle } required; department?, seniority?, employmentType?, workMode?, location?, competencies? returns 200 { description } | 400 jobTitle is required | 422 the model produced no usable description | 500 AI processing failed | Session + view_recruitment | manage_recruitment |
| POST | /ai/draft-candidate-message | Draft an email to a candidate. body { candidateName, jobTitle, stage, purpose, companyName } — all optional; a missing field is replaced by a neutral default in the prompt returns 200 { subject (at most 200 characters), body } — either may be an empty string when the model returns nothing usable | 500 { error:'AI processing failed' } guard writes nothing, so it is safe to repeat (each call drafts again and may word it differently) | Session + manage_recruitment |
| POST | /ai/chat-stream | The recruitment assistant chat, streamed. body { query } required (non-empty string); context? returns 200 application/x-ndjson, one JSON object per line: { phase:'thinking' }, then { token } pieces, ending with { done:true }; { refusal:true } or { error } can replace the ending | 400 when query is missing or empty | 500 when the model cannot be reached before the stream starts | Session + view_recruitment | manage_recruitment |
| POST | /ai/parse-log-query | Turn a plain-English question into audit-log filters. body { query } required (non-empty string; the first 500 characters are used); actions? (the action names the filter may pick from) returns 200 { level, userEmail, daysAgo, includeActions, excludeActions, q } — unset filters are omitted | 400 when query is missing or empty | 500 AI processing failed | Session + view_logs |
| POST | /ai/parse-cloud-query | Turn a plain-English question into a cloud-map search. body { query } required (non-empty string; the first 500 characters are used); types? (the resource types the filter may pick from) returns 200 { types, minSeverity (critical | high | medium | low), toxic, hasAttackPath, text } — unset filters are omitted | 400 when query is missing or empty | 500 AI processing failed | Session + view_cloud_infrastructure |
| POST | /ai/detect-toxic-combos | Flag risky permission combinations in cloud findings. body { items:[] (the first 40 are used), existingPaths?:[] (the first 25 are used) } returns 200 { combos:[{ title, severity (critical | high | medium | low), refs, why, remediation }] } — at most 8, and only combinations whose refs point at items that were sent; an empty items list answers { combos:[] } without calling the model | 500 AI processing failed | Session + view_cloud_infrastructure |
| POST | /ai/prioritize-findings | Suggest an order to work cloud findings in. body { items:[] (the first 40 are used), counts? } returns 200 { summary, topFocus:[{ ref, why }] } — at most 3 entries in topFocus | 500 AI processing failed | Session + view_cloud_infrastructure |
| POST | /ai/explain-finding | Explain a Security Lab finding in plain language. body { title, severity, target, evidence, mitigation, cvssScore } — a title or evidence is required returns 200 { explanation, businessImpact, steps:[] (up to 8) } | 400 when both title and evidence are missing | 500 AI processing failed | Session + view_security_findings | view_cloud_infrastructure |
| GET | /ai/model-status | Whether the selected model is pulled and warm (used by the Settings model switch). body Query: ?model= required (must be on the allow-list); ?previous= optional (the model being switched away from) returns 200 an AiModelStatus (typed under Response Objects) { model, pulled, ready, state:'ready'|'loading'|'pulling'|'engine_unreachable', reachable, previousResident (boolean or null), consumers } | 400 'model query param is required' | 400 'model is not on the allowlist' guard read-only, except that it loads an already-downloaded model into memory so the next request is fast | Session + manage_security |
| GET | /ai/model-capability | The instance's cores and memory, and which allow-listed models it can run. body No query parameters. returns 200 an AiModelCapability (typed under Response Objects) { box:{ cores, ramGB, gpu, cpuParamCap }, models:[{ id, label, sizeB, ramGB, fits:boolean, reason }], defaultModel } | 500 { error:'capability check failed' } | Session + manage_security |
| GET | /ai/health | AI engine status and loaded model. body No query parameters. returns 200 always: { status:'healthy'|'degraded', ollama:'up'|'down'|'unknown', model:string|null, modelPresent:boolean, installedModels:integer (absent when the engine is down), reason?:string (present when degraded), engine:'Ollama', lastModel:string, auditWrite:{ failures:integer, lastAt:string|null, lastAction:string|null, recentFailures:boolean }|null } guard read status, not the HTTP code: an unreachable engine still answers 200 with status 'degraded' | Session |
Audit Logs & Device Inventory
logging-service, device-service
The audit log and the manual device catalogue, reached through the /user-management prefix. Fleet management routes are in the Device Management group.
Worked example: The newest audit entry of the last 7 days
API token holding view_logs. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/user-management/logs?limit=1&page=0&daysAgo=7"
# Reply
200
{
"total": 5120,
"logs": [
{
"id": 88231,
"level": "info",
"action": "USER_CREATED",
"details": "...",
"payload": { ... },
"userEmail": "dana.levi@example.com",
"chainHash": "<64 hex characters>",
"prevHash": "<64 hex characters>",
"chainSeq": 88230,
"createdAt": "2026-10-10T08:14:51.402Z",
"updatedAt": "2026-10-10T08:14:51.402Z"
}
],
"counts": { "total": 5120, "info": 4980, "warning": 131, "error": 9 },
"windowDays": 7,
"retentionDays": 365,
"limit": 1,
"page": 0
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/logs | Query the central audit log (filter by action, level, actor, date). body Query: ?page (zero-based, default 0), ?limit (default 25, max 500), ?level (info | warning | error | debug; warn and critical are accepted as synonyms), ?userEmail (substring), ?includeActions and ?excludeActions (an action name or family, repeatable), ?daysAgo (the look-back window in days; default 30), ?correlationId (exact), ?q (substring of details; add ?deep=1 to search the payload too). No sort parameter: newest first returns 200 an AuditLogPage: { total:integer (rows matching, including ?level), logs:[ AuditLog ] (typed under Response Objects), counts:{ total, info, warning, error } (integers, for the same filters without ?level), windowDays:integer, retentionDays:integer|null, limit:integer, page:integer } — typed under Response Objects as AuditLogPage guard paged by page and limit, not offset: row n starts at page × limit | Session + view_logs |
| GET | /user-management/logs/verify | Verify the integrity of the hash-chained audit log — a one-click green/red result an auditor can run. Detects tampering; it does not prevent it. body No body. returns 200 an AuditChainVerification: { ok:boolean, chained:integer, unchained:integer, firstBrokenId:integer|null, brokenType:'content'|'linkage'|'anchor'|null, anchor:{ present, headId?, at?, ok? } } — typed under Response Objects as AuditChainVerification guard recomputes the chain IN SQL with the SAME audit_row_hash() the insert trigger uses (verify and compute cannot drift); three checks — content (each row's stored hash matches its recomputed content), linkage (each row's prevHash matches the prior row's hash — catches a deleted/reordered row), and the off-box external anchor (catches an internally-consistent full rewrite); pre-chain NULL-hash history is reported, not treated as a break | Session + view_logs |
| GET | /user-management/logs/export | Export the audit log as CSV or JSON for an auditor. body Query: level?, userEmail?, includeActions?, excludeActions?, daysAgo?, format? ('csv'|'json'; default follows Accept header). No JSON body. returns 200 CSV attachment (id,createdAt,level,action,userEmail,details,payload; RFC-4180 quoted) or JSON { exportedAt, count, truncated, windowDays, logs[] }; capped at a max row count with a 'truncated' flag guard view_logs; the export itself is recorded as an AUDIT_LOG_EXPORTED (warning) entry with the filters used — best-effort, never blocks the download | Session + view_logs |
| DELETE | /user-management/logs | Always refused: audit logs are append-only. body No body (any body ignored). Audit logs are append-only; deletion by id or purgeAll is not a capability. returns Always 403 { error } — refused as append-only; logs an AUDIT_LOG_DELETE_BLOCKED warning. The retention job is the only pruner; its period is Security Settings → Audit Log Retention (auditLogRetentionDays, 90 to 3650 days, default 90). guard requires manage_logs, but the endpoint hard-refuses all ad-hoc deletion (single-actor evidence-destruction block); DB trigger also rejects UPDATE/DELETE except the retention job | Session + manage_logs |
| GET | /user-management/devices | List hardware inventory. body Query (default view): ?limit (default 200, max 1000), ?offset (default 0), ?enrolled=1 (agent-enrolled devices only), ?pending=1 (include enrolments awaiting approval), ?retired=1 (include retired devices); ordered by id ascending. With ?view=list the fleet list is returned instead: ?page (from 1), ?pageSize (default 50, max 500), ?sort, ?dir (asc | desc), ?q (at most 200 characters) and the filters platform, status, updates, risk, assign, lifecycle, compliance, issue, silentDays returns 200 [ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated } guard two paging conventions on one path: limit/offset for the default view, page/pageSize for ?view=list | Session + view_devices |
| POST | /user-management/devices | Register a device in inventory. body A DeviceCreate: name (the Device model does not allow it to be empty), type, serialNumber, status, manufacturer, userId, assetTag, department, location, costCenter. Any other fields dropped. returns 201 the Device record as created (JSON). Writes a DEVICE_CREATED audit entry with actor from x-user header. guard requires manage_devices; mass-assignment prevented by pickDevice() column allowlist; not deduplicated by this route: serialNumber is unique, so a repeat carrying the same serial fails on that constraint, and a repeat without one creates a second record | Session + manage_devices |
Zonitix Agent (Endpoints)
api-gateway → device-service
The endpoints the agent calls at the /agent prefix. These carry NO session — the agent authenticates each machine with a per-device P-256 key (on a Mac, a Secure Enclave key where the hardware has one and the build is signed for it, otherwise a software key; a root-only keyfile on Linux; an admin-protected file under ProgramData on Windows). Enroll is a one-time trust bootstrap; every heartbeat is signed and replay-protected (±5-minute timestamp window + one-time nonce). The macOS, Linux and Windows agents share enroll and heartbeat; the installer downloads, recovery-key escrow and MDM enrolment-code redeem below complete the allow-list — nothing else under /agent is reachable.
Worked example: Download the macOS agent and check its hash
Public and rate-limited: no session or token. The values shown are made up; the field names and the shape are what the route sends.
curl -sS -D - -o zonitix-agent.pkg https://<your-instance>-api.zonitix.com/api/v1/agent/pkg
shasum -a 256 zonitix-agent.pkg # must equal the X-Zx-Pkg-Sha256 header
# Reply
200
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="<the stored file name>"
X-Zx-Pkg-Sha256: <64 hex characters>
404 // nothing is hosted yet
{ "error": "No macos agent is hosted" }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /agent/enroll | First-run enrolment: the agent sends its device report, an enrolment token and its public key; the instance pins the key and returns a device ID (one record per physical machine, keyed by serial). body An AgentEnrollRequest (typed under Response Objects): { enrollmentToken, serialNumber (string), publicKey (P-256 SPKI PEM / base64 DER / raw X9.63 point) } required; optional inventory scalars (hostname, osVersion, osBuild, architecture, chip, modelName, cpuCores, memoryBytes, diskTotalBytes, diskFreeBytes, osUpdateAvailable, agentVersion, loggedInUser, platform) + inventory object blob returns 201 an AgentEnrollReply: { deviceId } | 400 missing field, invalid/non-P-256 key, or a serialNumber over 255 characters or containing a NUL | 401 invalid/expired/exhausted token | 403 the serial belongs to a retired device (an administrator must return it to service first) | 409 already enrolled with a different signing key (needs admin reEnrollAllowed) guard no session — enrollment token only: vendor-signed token verified OFFLINE against committed keys (fail-closed if API_DOMAIN unset) OR legacy hashed single-use token atomically reserved; identity-rotation guard refuses re-pinning a different key on an enrolled device without admin approval; upsert by unique serialNumber | Enrolment token |
| POST | /agent/heartbeat | Signed check-in carrying inventory + security posture; the instance verifies the signature, timestamp and nonce, then returns fleet policy, any pending command, and self-update instructions. body An AgentHeartbeatRequest (typed under Response Objects). No key is required — auth is by signed headers (x-zx-device-id, x-zx-timestamp, x-zx-nonce, x-zx-signature). Body carries inventory scalars (same set as enroll incl. platform, agentVersion, loggedInUser) + free-form inventory object; signature is over the exact raw bytes. returns 200 an AgentHeartbeatReply (typed under Response Objects, with AgentPolicy, AgentUpdate and AgentCommand): { ok:true, deviceId, command, trustBundle, policy:{allowUserQuit, checkInIntervalMinutes, manageUpdates, update*/compliance* (null unless managed)}, policyHash, update:{version,url,sha256,sig,kid}|null (null unless a strictly newer agent version is targeted) }, plus commands[] and commandResultsAccepted for agents that declare commands_v2; the full contract is on Fleet Policy & Agent Signing | 401 missing headers / stale timestamp / unknown device / bad signature / replayed nonce guard P-256 ECDSA/SHA-256 signature over canonical(zx-agent-v1\nMETHOD\npath\nts\nnonce\nbodySha256) using the pinned device key; ±300s timestamp window; one-time nonce enforced by unique (deviceId,nonce) index (duplicate insert = replay 401); anti-rollback semver.gt gate on offered self-update | Device signature (P-256) |
| GET | /agent/pkg | Download the macOS agent installer package (?v= selects a version). body Query: ?v= optional version; omitted, the current hosted package returns 200 the package bytes (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No macos agent … is hosted' } guard the package carries no secret; the agent verifies the vendor release signature before it updates itself | Public (rate-limited) |
| GET | /agent/linux/binary | Download the Linux agent binary. body Query: ?v= optional version returns 200 the binary (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No linux agent … is hosted' } | Public (rate-limited) |
| GET | /agent/linux/install | The one-line Linux install script. body Query: ?v= optional version; an unknown version is ignored and the current one is used returns 200 a shell script (text/x-shellscript). When the instance has no API host, enrolment token or Linux binary yet, still 200: a script that prints what is missing and exits 1 guard the enrolment token baked into the script is scoped to your instance and reusable | Public (rate-limited) |
| GET | /agent/windows/binary | Download the Windows agent MSI. body Query: ?v= optional version returns 200 the MSI (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No windows agent … is hosted' } | Public (rate-limited) |
| GET | /agent/windows/install | The elevated-PowerShell Windows install one-liner. body Query: ?v= optional version; an unknown version is ignored and the current one is used returns 200 a PowerShell script (text/plain). When the instance has no API host, enrolment token or Windows MSI yet, still 200: a script that writes the error and exits 1 | Public (rate-limited) |
| POST | /agent/escrow/recovery-key | An enrolled device escrows its disk-encryption recovery key (BitLocker on Windows; FileVault recovery-key escrow is part of the preview, not yet validated end-to-end). body An AgentRecoveryKeyEscrow: { keyType:'bitlocker'|'filevault', recoveryKey } required; volumeId optional (at most 255 characters). Signed with the same x-zx-* headers as the heartbeat returns 200 { ok:true, escrowed:true } | 400 { error:'keyType (bitlocker|filevault) and recoveryKey are required' } | 401 signature, timestamp or nonce rejected guard bound to the enrolled device by its signature over the body; the key is stored encrypted at rest; safe to repeat: one key is kept per device and key type, and a later call replaces it (audited as a rotation) | Device signature (P-256) |
| POST | /agent/mdm-enroll-redeem | Redeem an MDM enrolment code from your instance's /enroll page and receive the enrolment profile. body An AgentMdmEnrollRedeem: { code } required — at least 6 letters or digits; body limited to 4 KB returns 200 the enrolment profile (application/x-apple-aspen-config, attachment zonitix-enroll.mobileconfig) | 400 { error } the code is too short, did not work, or MDM is not set up | 502 { error } the enrolment service is not reachable guard the code is redeemed with your instance's own MDM key, and the returned profile only enrols into your instance; rate-limited separately from the other agent routes | Enrolment code |
| GET | /user-management/devices | View the fleet the agents populate (same records as the manual catalog). body Query (default view): ?limit (default 200, max 1000), ?offset (default 0), ?enrolled=1 (agent-enrolled devices only), ?pending=1 (include enrolments awaiting approval), ?retired=1 (include retired devices); ordered by id ascending. With ?view=list the fleet list is returned instead: ?page (from 1), ?pageSize (default 50, max 500), ?sort, ?dir (asc | desc), ?q (at most 200 characters) and the filters platform, status, updates, risk, assign, lifecycle, compliance, issue, silentDays returns 200 [ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated } guard two paging conventions on one path: limit/offset for the default view, page/pageSize for ?view=list | Session + view_devices |
Device Management
device-service
Fleet management behind the Devices pages: enrolment, policy, commands, groups, configuration profiles, Apple MDM (preview), software inventory, recovery keys and agent rollouts. Reads need view_devices and changes need manage_devices; the destructive actions (wipe, delete, recovery-key reveal) need the separate manage_device_destructive permission plus sudo. Errors are { error } with a sentence for the person; a machine code is named on the row where one exists. This table is a subset of the device routes; the device read routes are also in your instance's generated reference at /api/docs.
Worked example: Is Apple MDM set up
API token holding view_devices. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/devices/mdm/status
# Reply
200 // Apple MDM has not been set up
{ "configured": false }
// Once it is set up the reply is { "configured": true, connected, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter }.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/devices | The device list. Query: ?limit (default 200, 1–1000) and ?offset; ?enrolled=1 for enrolled machines only; ?pending=1 and ?retired=1 to include those; ordered by id ascending. ?view=list switches to the filtered, sorted, paged list the Inventory page uses. body Query string, not a JSON body, with ?view=list: ?page (default 1), ?pageSize (default 50, 1–500), ?sort = last (default), host, disk, platform, os, agent, owner, serial or issues, ?dir = asc or desc, ?q (searched text, at most 200 characters), ?platform = macos, linux or windows, ?status = online, offline, stale, d1_7, d7_30 or d30plus, ?updates = avail, os, agent or uptodate, ?risk = risk or ok, ?assign = assigned or unassigned, ?lifecycle = in_use, available, pending, retired or reserved, ?compliance = compliant, noncompliant or unknown, ?issue, ?silentDays (1–365), ?retired=1, ?enrolled=0. A value outside these lists is ignored, not refused returns 200 Device[] (typed under Response Objects), each with Owner { id, firstName, lastName, email } | with ?view=list: 200 { items, total, page, pageSize, kpis, staleDays, truncated } — truncated is true when the fleet is larger than the 20,000 devices one request scans | Session + view_devices |
| POST | /user-management/devices | Create a device record by hand. Repeating the call creates another record unless it carries a serialNumber: that column is unique, and the handler has no specific answer for a duplicate, so it surfaces as a 500 rather than a 409. body A DeviceCreate: { name (required), type, serialNumber, status, manufacturer, userId, assetTag, department, location, costCenter } — other fields are ignored returns 201 the Device record as created guard Repeat: creates another device record. serialNumber is unique, so a serial number already on a record is not accepted a second time | Session + manage_devices |
| GET | /user-management/devices/:id | One device (CSV of the filtered list at /devices/export.csv). returns 200 the Device with Owner { id, firstName, lastName, email }; the agent's public key is never included | 404 unknown device. The CSV export takes the ?view=list filters and is audited as DEVICE_EXPORT | Session + view_devices |
| GET | /user-management/devices/lite | A short device list for pickers: id, names, serial number, platform, owner and signed-in user only. Query: ?q searches hostname, name, serial number, asset tag and signed-in user (the first 100 characters are used); ?limit (default 25, 1–100); ?enrolled=1 keeps agent-enrolled devices only; or ?ids= (one comma-separated list of at most 100 device ids) to fetch exactly those, in which case q, limit and enrolled are ignored. Retired devices and devices still waiting for enrolment approval are never included, in either mode. Ordered by hostname; no offset parameter, so narrow with ?q rather than paging. body Query string, not a JSON body: ?q, ?limit, ?enrolled, ?ids. returns 200 { items: DeviceLite[], total:integer } (typed under Response Objects) — each item { id, hostname, name, serialNumber, platform, ownerName, loggedInUser }; total counts every match, not only the items sent | 400 { error } when ids is repeated instead of comma-separated, holds a value that is not a number, or names more than 100 ids guard read-only and safe to repeat | Session + view_devices |
| GET | /user-management/devices/summary | Fleet totals for the Overview page. No parameters. returns 200 { generatedAt, total, pending, byPlatform, stale, updates, coverage, belowMinOs, compliant, complianceUnknown, attention, retiredReappeared, osVersions, agentVersions, alerts, staleDays } | Session + view_devices |
| GET | /user-management/devices/:id/effective-policy | The effective policy for one device (its Profiles & policy tab). Its groups are at /devices/:id/groups. returns 200 { platform, baseline, profiles: [{ id, name, payloadKeys }], effective, profileEnforcementOn, rollout: { target, frozen } } | 404 for an unknown device | Session + view_devices |
| GET | /user-management/devices/:id/activity | The device's audit history (its Activity tab). Query: ?limit (default 50, 1–200). Newest first. returns 200 DeviceActivityEntry[] (typed under Response Objects) { id, action, level, userEmail, createdAt, details }; no offset parameter | Session + view_devices |
| GET | /user-management/devices/trends | Daily fleet snapshots for the trend chart. Query: ?days (default 30, at most 365). returns [{ day, metrics }], oldest first | Session + view_devices |
| GET | /user-management/devices/compliance/coverage | Which compliance requirements your profiles cover (Profiles → Compliance coverage). Query: ?framework = SOC2, ISO27001, HIPAA, PCI, NIST, HITRUST or all. returns 200 { framework, onlineDays, kpis, requirements } | 400 for an unknown framework | Session + view_devices |
| GET | /user-management/devices/settings/alerts | Device alert settings. returns { pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured } | Session + view_devices |
| PUT | /user-management/devices/settings/alerts | Change device alert settings; only the fields sent are changed. Audited. body A DeviceAlertSettingsUpdate: pendingEnrollment, noncompliant, encryptionOff: true or false; silentDays: 1–365 or null (off), default 7; enrollTokenDays: 1–90 or null (off), default 7; pushCertDays: up to 6 whole numbers, each 1–365, default [30, 14, 7, 1]; recipients cannot be changed from manage_devices returns 200 the full settings | 400 with the reason for a value out of range guard only the keys sent are changed. Repeat: safe; the same values are saved again and the audit entry records no change | Session + manage_devices |
| GET | /user-management/devices/alerts/recent | Alerts that fired or cleared recently. Query: ?limit (default 50, 1–200). Newest first. returns 200 an array of alert events: the stored row (subjectType, subjectId, state, sentAt, createdAt and so on) plus a readable label | Session + view_devices |
| GET | /user-management/devices/settings/stale | Days without a check-in before a device counts as stale. returns { staleDays, deviceStaleDays, default: 7, min: 1, max: 365 } | Session + view_devices |
| PUT | /user-management/devices/settings/stale | Set the stale threshold. Audited with the old and new value. Safe to repeat: saving the value it already has changes nothing. body A DeviceStaleSettingUpdate: { staleDays } — a whole number from 1 to 365 returns 200 the same shape as the GET | 400 when out of range guard Repeat: safe; saving the value it already has changes nothing | Session + manage_devices |
| GET | /user-management/devices/custom-fields | Your own device fields. returns { fields } guard the whole field list in one reply (no paging) | Session + view_devices |
| POST | /user-management/devices/custom-fields | Replace the set of custom device fields. Audited. A replace-style write, safe to repeat. body A DeviceCustomFieldsWrite: { fields } — at most 20; each is { key, label (cut to 60 characters), type: text | select | number | date, options (a select keeps up to 50) }; a malformed entry is skipped, not refused returns { fields } guard replaces the whole list. Repeat: safe; the same list is stored again (each call is audited) | Session + manage_devices |
| POST | /user-management/devices/install-invites | Email the macOS install link to chosen people. The server builds the link itself. body An InstallInvitesSend: { userIds } — the users to email returns { sent, skipped, failed } | 400 when no user is selected, no agent package is hosted, or the download address cannot be built guard sends real email. Repeat: every user named is emailed again | Session + manage_devices |
| POST | /user-management/devices/agent-pkg | Upload an agent installer for the instance to host. body The file itself as the raw request body, Content-Type application/octet-stream, at most 150 MB: a signed .zxagent bundle, or a raw installer. Query: ?platform= (macos | linux | windows; anything else is read as macos), ?version= and ?filename= (a bundle's own version wins over ?version). returns 201 an AgentPackageUpload (typed under Response Objects) | 400 { error } for an empty body, a raw installer where the instance requires a signed bundle, a bundle whose SHA-256 does not match its header, or a bundle whose release signature is not verified guard Replaces the hosted installer of the same platform and version; with no version, every hosted installer of that platform. Audited as DEVICE_AGENT_PKG_UPLOADED. | Session + manage_devices |
| GET | /user-management/devices/agent-pkg | Whether an installer is hosted for a platform, and which one is newest. body Query: ?platform= (macos | linux | windows; default macos). returns 200 { exists:false, platform } when none is hosted, else an AgentPackage (typed under Response Objects) with exists:true and platform | Session + view_devices |
| GET | /user-management/devices/agent-pkgs | Every hosted version of a platform's installer. body Query: ?platform= (macos | linux | windows; default macos). returns 200 { platform, versions: AgentPackage[] } (typed under Response Objects: each { version, filename, sha256, sizeBytes, createdAt }), newest first, one entry per version; an installer stored without a version is left out | Session + view_devices |
| GET | /user-management/devices/agent-pkg/download | Download the newest hosted installer of a platform. body Query: ?platform= (macos | linux | windows; default macos). returns 200 the file (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No <platform> agent is hosted' } | Session + view_devices |
| GET | /user-management/devices/setup-state | Which platforms are in use on the instance: an installer hosted, or devices enrolled. body No query parameters. returns 200 a DeviceSetupState (typed under Response Objects) | Session + view_devices |
| GET | /user-management/devices/owner | Data-subject export: every device owned by one person. Query: ?userId. returns 200 { devices: Device[] } — empty, never an error, for a missing or unknown userId guard every device of that person in one reply, id ascending (no paging) | Session + manage_devices or manage_security |
| DELETE | /user-management/devices/owner | Data-subject erasure for one person's devices: clears loggedInUser, hostname and lastCheckinIp and unassigns the owner on each, keeping the asset record. Safe to repeat. Audited as DEVICE_OWNER_ERASED. Query: ?userId. body No body. Query: ?userId, the person whose devices are cleared returns 200 { erased } — the number of devices changed; { erased: 0 } for a missing or non-numeric userId guard Repeat: safe; a second call finds no device of that person, answers { erased: 0 } | Session + manage_devices or manage_security |
| GET | /user-management/devices/reservations/suggestions | New-hire computers: enrolled, unowned devices that may be a reserved computer, with the reservations they could match. returns { suggestions, open } | Session + view_devices |
| POST | /user-management/devices/:id/link-reservation | Confirm that an enrolled device is a reserved new-hire computer; the new hire becomes its owner. body A ReservationLink: { reservationId } returns { id, linked, userId } | 400 no reservationId or device not enrolled | 404 unknown device | 409 reservation no longer open guard Repeat: 409; the reservation record is removed once it is linked, so it is no longer open | Session + manage_devices |
| POST | /user-management/devices/:id/dismiss-reservation-suggestion | Mark a device as not a new hire's, so it stops being suggested. Audited. body No body. returns { id, dismissed } | 404 unknown device guard Repeat: safe; the device stays dismissed | Session + manage_devices |
| GET | /user-management/devices/enroll-tokens | List enrolment tokens: the newest 200, with status and use count, never the token value. No query parameters. returns 200 EnrollmentToken[] (typed under Response Objects) { id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status }: the 200 newest, newest first, with no paging parameter | Session + view_devices |
| POST | /user-management/devices/enroll-tokens | Issue an extra enrolment token. The install commands in Devices → Settings already carry the instance's reusable token, so this is only for a separate short-lived one. The token value is returned once; only its hash is stored. Repeating the call issues another token. body An EnrollmentTokenCreate: { label (cut to 120 characters), ttlHours (default 24, clamped 1–720), maxUses (default 1, clamped 1–500) } returns 201 an EnrollmentTokenIssued { id, token, label, expiresAt, maxUses } (typed under Response Objects); token is shown only here guard Repeat: issues another token; two calls are never treated as one | Session + manage_devices |
| POST | /user-management/devices/enroll-tokens/:id/revoke | Revoke an enrolment token. No body. Safe to repeat: a second call returns revoked: false. body No body. returns 200 { revoked } — true only when this call revoked it guard Repeat: safe; a second call answers { revoked: false } | Session + manage_devices |
| POST | /user-management/devices/:id/approve-enrollment | Approve a device that is waiting for enrolment approval (reject at /devices/:id/reject-enrollment; several at /devices/batch/approve). No body. Repeating it leaves the device approved. body No body. returns 200 { id, enrollmentApproved: true } | 404 | 409 when the device is retired guard Repeat: safe; an approved device stays approved (each call is audited) | Session + manage_devices |
| POST | /user-management/devices/:id/reject-enrollment | Reject a device that is waiting for enrolment approval. body An EnrollmentReject: { reason } — optional, at most 234 characters returns 200 { id, rejected: true, outcome } | 400 reason too long | 404 'Device not found' | 409 unless the enrolment is still pending guard Repeat: 409; after the first call the enrolment is no longer pending | Session + manage_devices |
| POST | /user-management/devices/batch/approve | Approve several waiting devices in one call. body A DeviceIdsRequest: { ids } — 1 to 500 device ids returns 200 a DeviceBatchOutcome { ok: [{ deviceId, enrollmentApproved: true }], failed: [{ deviceId, error }] } | 400 'ids must be a non-empty list of device ids' or 'At most 500 devices per request' guard each device is tried on its own: one failure does not stop the rest, so read failed[] even on a 200. Repeat: safe; an approved device is approved again and listed in ok | Session + manage_devices |
| POST | /user-management/devices/:id/allow-reenroll | Let an already-enrolled device enrol again with a new signing key. No body. Audited as DEVICE_REENROLL_ALLOWED. body No body. returns 200 { id, reEnrollAllowed: true } | 404 guard Repeat: safe; the approval stays set until the device next enrols, which uses it up (each call is audited) | Session + manage_devices |
| GET | /user-management/devices/policy | Read the fleet device policy (the write is PUT on the same path, the next row). returns 200 { macos, linux, windows, profilesOnly } — one policy view per platform. profilesOnly: true means the hardening and software-update settings are managed in Profiles and can no longer be changed through the policy write guard one object, not a list | Session + view_devices |
| PUT | /user-management/devices/policy | Change the fleet device policy of one platform. Only the fields sent are changed. Audited as DEVICE_POLICY_UPDATED with the old and new values. body A DevicePolicyUpdate: { platform: macos (default) | linux | windows, checkInIntervalMinutes (whole number, held to 5–1440), allowUserQuit (boolean), minOsVersion (dotted number such as 14.5, 24.04 or 10.0.22631; '' or null clears it), minAgentVersion (such as 1.1.0; '' or null clears it; not above the adopted agent version) }. Example: { "platform": "macos", "checkInIntervalMinutes": 15, "minOsVersion": "14.5" } returns 200 the saved policy of that platform | 400 { error } for a minOsVersion or minAgentVersion that is not a version, or a minAgentVersion above the adopted version | 409 { error, profilesOnly: true } when GET /devices/policy reports profilesOnly: true and the body changes a hardening or software-update setting (manageCompliance, the compliance* switches, complianceScreenLockMinutes, manageUpdates or the update* switches): change those in a profile instead guard in profilesOnly mode a hardening or update field sent with its current value is ignored, so an old client that echoes the whole policy still saves; safe to repeat | Session + manage_devices |
| POST | /user-management/devices/:id/assign | Assign a device to a person (several at /devices/batch/assign). A replace-style write, safe to repeat. The owner is written on every call: a body without userId removes the owner, so send the current owner's id when you only want to change the asset fields. body A DeviceAssign: { userId (a user id; null or left out unassigns the owner), assetTag, department, location, costCenter (each cut to 255 characters; an empty string clears it; one left out keeps its value), customFields } returns 200 { id, userId, assetTag, department, location, costCenter } | 404 guard Repeat: safe; a replace-style write (each call is audited as DEVICE_ASSIGNED) | Session + manage_devices |
| POST | /user-management/devices/batch/assign | Assign several devices to one person, or unassign them, in one call. body A DeviceBatchAssign: { ids — 1 to 500 device ids, userId (required: a user id, or null to unassign) } returns 200 a DeviceBatchOutcome { ok: [{ deviceId, userId }], failed: [{ deviceId, error }] } | 400 'userId is required (a user id, or null to unassign)' / 'userId must be a user id or null (unassign)' / no ids or more than 500 guard each device is tried on its own, so read failed[] even on a 200; a replace-style write, safe to repeat | Session + manage_devices |
| GET | /user-management/devices/commands/catalog | The commands the agent can run: lock, uninstall, restart, update_os and rename. returns 200 { types: DeviceCommandType[] } (typed under Response Objects), each { type, label, platforms, destructive, args } guard a fixed list, not paged | Session + view_devices |
| GET | /user-management/devices/:id/commands | A device's command queue and history. Query: ?limit (default 50, 1–200). Newest first. returns 200 DeviceCommand[] (typed under Response Objects) { id, deviceId, type, args, status, requestedBy, reason, batchId, result, deliveredAt, completedAt, createdAt, updatedAt } | 404 unknown device | Session + view_devices |
| POST | /user-management/devices/:id/commands | Queue a command for a device (several devices at /devices/batch/commands; cancel with DELETE /devices/:id/commands/:commandId). Safe to repeat: when the same command with the same arguments is already queued, the call returns that command's id with noop: true and queues nothing new. body A DeviceCommandCreate: { type: lock | uninstall | restart | update_os | rename (required), args ({ name } for rename: letters, digits and hyphens, at most 63 characters, 15 on Windows), reason (required, at least 3 characters, for uninstall; cut to 500) }. Batch: the same plus ids — 1 to 500 device ids returns 200 a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [] } — noop is present, as true, only when nothing new was queued | 400 { error } unknown command or invalid arguments | 404 | 409 with failed[].code no_agent, a retired device, an uninstall already queued, or the same command queued with different arguments | 422 with failed[].code unsupported for this platform — a 404, 409 or 422 answers { ok: [], failed, error, pendingCommand } | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status (uninstall only). Batch: 200 { ok, failed: [{ deviceId, error, pendingCommand }], batchId }. Cancel: 200 { id, deviceId, status: 'cancelled', pendingCommand } | 404 | 409 unless still queued guard a destructive command (uninstall) additionally needs manage_device_destructive and a fresh sudo before it is queued. Repeat: while the first is still queued, the same command with the same arguments is not queued twice: the call answers 200 with the first command's id and noop: true. Once it has been delivered, a repeat queues a new command | Session + manage_devices |
| POST | /user-management/devices/batch/commands | Queue one command for several devices. body A DeviceBatchCommandCreate: { ids — 1 to 500 device ids, type: lock | uninstall | restart | update_os | rename (required), args, reason (required for uninstall) } — the same type, args and reason rules as POST /devices/:id/commands returns 200 a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [{ deviceId, error, pendingCommand? }], batchId } | 400 { error } for an unknown command, invalid arguments, no ids or more than 500 | 400 { error: 'SUDO_REQUIRED' } for uninstall without a fresh step-up (the service answers 401; the gateway returns it as 400) guard each device is tried on its own, so read failed[] even on a 200. Repeat: a device that still has the same command queued with the same arguments is listed in ok with noop: true and nothing new is queued for it | Session + manage_devices (uninstall also needs manage_device_destructive and a fresh step-up) |
| DELETE | /user-management/devices/:id/commands/:commandId | Cancel a command that is still queued. body No body. returns 200 { id, deviceId, status: 'cancelled', pendingCommand } | 404 'Device not found' or 'Command not found' | 409 when the command is no longer queued, or was delivered before it could be cancelled guard Repeat: 409; only a command that is still queued can be cancelled | Session + manage_devices |
| POST | /user-management/devices/retire | Retire devices (bring back with /devices/unretire). Safe to repeat: an already retired device is reported in ok and is not audited again. The record, its owner and its escrowed recovery keys are kept; queued commands are cancelled. body A DeviceRetire: { ids — 1 to 500 device ids, reason (required, 3–255 characters) }. Unretire: { ids } returns 200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids, more than 500, or a missing or over-long reason guard each device is tried on its own, so read failed[] even on a 200. Repeat: safe; a device that is already retired is listed in ok and nothing about it is changed | Session + manage_devices |
| POST | /user-management/devices/unretire | Bring retired devices back into service. body A DeviceIdsRequest: { ids } — 1 to 500 device ids returns 200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids or more than 500 guard safe to repeat: a device that is not retired is reported in ok and nothing is changed | Session + manage_devices |
| DELETE | /user-management/devices/:id | Delete a device record. A repeat answers 404. body { reason } in the body, or ?reason — required, 3–255 characters returns 200 { id, deleted: true } | 400 | 404 | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: 404; the record is gone | Session + manage_devices and manage_device_destructive + sudo |
| GET | /user-management/devices/groups | Device groups (members at /devices/groups/:id/devices). No query parameters; sorted by name, not paged. returns 200 { groups: DeviceGroup[], fields, ops, fieldOps, complianceValues } (DeviceGroup is typed under Response Objects: { id, name, description, kind, platform, criteria, createdBy, createdAt, memberCount, enforceableCount }). Members: 200 { group, devices, memberCount, enforceableCount } | 404 | Session + view_devices |
| POST | /user-management/devices/groups | Create a device group (PUT or DELETE /devices/groups/:id; add members at /devices/groups/:id/members). Repeating the POST creates another group: names are not unique. Adding members is safe to repeat; a device already in the group is not added twice. body A DeviceGroupCreate: { name (required), description, kind: 'static' (default) or 'smart', platform: macos | linux | windows (optional), criteria (smart groups), deviceIds (static groups) }. PUT: { name, description, criteria (smart groups only; ignored on a static group) }. Members: { deviceIds } returns 201 { id } | 400 no name or invalid criteria. PUT: 200 { ok: true, memberCount, enforceableCount } | 404. DELETE (?force=1 to strip it from the scopes that use it): 200 { ok: true, strippedFrom } | 404 | 409 with usedBy when a profile or agent rollout still uses it. Members: 200 { added } | 400 for a smart group, no deviceIds or a device of another platform | 404 unknown group guard Repeat: creates a second group; group names are not required to be unique | Session + manage_devices |
| POST | /user-management/devices/groups/preview | Count the devices a smart group's criteria would match, without saving anything. body A DeviceGroupPreview: { criteria } (kind, when sent, must be 'smart') returns 200 { count, sample, memberCount } | 400 { error } for a static group or invalid criteria guard Repeat: safe; nothing is stored | Session + view_devices |
| PUT | /user-management/devices/groups/:id | Rename a device group, change its description, or change a smart group's criteria. Audited as DEVICE_GROUP_UPDATED. body A DeviceGroupUpdate: { name, description, criteria (smart groups only; ignored on a static group) } — all optional returns 200 { ok: true, memberCount, enforceableCount } | 400 invalid criteria | 404 'group not found' guard Repeat: safe; a replace-style write (each call is audited) | Session + manage_devices |
| DELETE | /user-management/devices/groups/:id | Delete a device group. Audited as DEVICE_GROUP_DELETED. body Query: ?force=1 (or true) also removes the group from every profile and agent-rollout scope that uses it returns 200 { ok: true, strippedFrom: { profiles, rollouts } } | 404 'group not found' | 409 { error, usedBy } when a profile or agent rollout still uses it and force is not set guard check GET /devices/groups/:id/usage first; a repeat answers 404 | Session + manage_devices |
| GET | /user-management/devices/groups/:id/usage | The profiles and agent rollouts whose scope uses this group. returns 200 { groupId, usedBy: { profiles, rollouts }, count } | 404 'group not found' | Session + view_devices |
| GET | /user-management/devices/groups/:id/devices | The devices in a group. returns 200 { group: { id, name, kind, platform, criteria, description }, devices, memberCount, enforceableCount } | 404 'group not found' guard every member in one reply (no paging) | Session + view_devices |
| POST | /user-management/devices/groups/:id/members | Add devices to a static group. Audited as DEVICE_GROUP_MEMBERS_ADDED. body A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids returns 200 { added } — the number newly added | 400 for a smart group, no deviceIds, or a device of another platform in a platform-bound group | 404 'group not found' guard safe to repeat: a device already in the group is not added twice | Session + manage_devices |
| DELETE | /user-management/devices/groups/:id/members/:deviceId | Remove one device from a group. body No body. returns 200 { removed } — 1, or 0 when the device was not a member guard safe to repeat | Session + manage_devices |
| POST | /user-management/devices/groups/:id/members/bulk-remove | Remove several devices from a group. body A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids returns 200 { removed } — the number removed | 400 'deviceIds[] required' guard safe to repeat | Session + manage_devices |
| GET | /user-management/devices/profiles | Configuration profiles (catalogue at /devices/profiles/catalog, per-device status at /devices/:id/profile-status). No query parameters; sorted by name, not paged. returns 200 { profiles: [{ id, name, description, platform, mixed, payloads, scope, scopeLabel, scopeCount, scopeTotal, statusTracked, enabled, version, coversRequirements, createdBy, ... }] } | Session + view_devices |
| POST | /user-management/devices/profiles | Create a configuration profile (PUT or DELETE /devices/profiles/:id). Repeating the POST creates another profile: names are not unique. body A DeviceProfileCreate: { name (required), platform (required: macos, linux or windows; cannot be changed later), payloads, scope: { type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }, description, enabled }. PUT takes the same fields, all optional returns 201 { id } | 400 no name, no or unknown platform, or a payload the platform does not support. PUT: 200 { ok: true, version } | 400 | 404 | 409 while the profile is rolling out in stages. DELETE: 200 { ok: true, mdmRemoval, warning } | 404 guard Repeat: creates a second profile; profile names are not required to be unique | Session + manage_devices |
| PUT | /user-management/devices/profiles/:id | Change a configuration profile. body A DeviceProfileUpdate: the fields of the create call, all optional; platform cannot be changed. Add rollout: { rings, autoPromote, promoteAfterHours } to save the change and start a staged rollout of it in one call returns 200 { ok: true, version }, plus rollout and mdm when rollout was sent | 400, including a rollout asked for when nothing changed on a profile that is already on | 404 | 409 while the profile is rolling out in stages guard Repeat: safe; a call that changes nothing keeps the version number (each call is audited) | Session + manage_devices |
| DELETE | /user-management/devices/profiles/:id | Delete a configuration profile. body No body. returns 200 { ok: true, mdmRemoval, warning } | 404 guard also ends a staged rollout of the profile that is still in progress. Repeat: 404 | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/deploy | Deploy a profile (several at /devices/profiles/bulk-deploy). No body. body No body. returns 200 { deployed, devicesTargeted, inScope, agentPending, enforcementOn, notPushable, heldByRollout, note } | 400 when the profile is disabled, or it has only MDM payloads and MDM is not connected | 404 | Session + manage_devices |
| POST | /user-management/devices/profiles/bulk-deploy | Deploy several profiles in one call. body A ProfileIdsRequest: { ids } — 1 to 100 profile ids; a repeated id is deployed once returns 200 { succeeded: [{ id, note }], failed: [{ id, error }] } | 400 'Select at least one profile.' / 'Too many profiles in one action (max 100).' guard each profile is tried on its own, so read failed[] even on a 200 | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/rollout | Start a phased rollout of a profile; then /rollout/promote, /pause, /resume or /rollback. A second start answers 409 while a rollout is in progress. body A ProfileRolloutStart: { rings (2 to 5; each is { type: 'groups', groupIds } or { type: 'percent', percent } with 1–99 and each share larger than the one before, plus an optional label of up to 40 characters; the last ring must be { type: 'all' }), autoPromote (default true), promoteAfterHours (a whole number, 1–720, default 24) } returns 200 { rollout: ProfileRollout, mdm } | 400 invalid rings or hours | 404 unknown profile | 409 when the profile is already applied to its whole scope, or is already rolling out. Pause, resume: 200 { rollout }. Promote: 200 { rollout, mdm }. Rollback: 200 { rollout, version, mdm }. Each of the four: 404 no rollout in progress | 409 the rollout has ended guard Repeat: 409; starting the rollout turns the profile on, and a profile that is on cannot be started this way again | Session + manage_devices |
| GET | /user-management/devices/profiles/:id/rollout | The current (or most recent) phased rollout of a profile, with per-ring device counts. returns 200 { rollout: ProfileRollout, rings, statusTracked, profile } — ProfileRollout is typed under Response Objects; rings carries device counts per ring. { rollout: null } alone when the profile has never been rolled out in stages | 400 'invalid profile id' | 404 unknown profile | Session + view_devices |
| GET | /user-management/devices/profiles/rollouts | The latest rollout of every profile, keyed by profile id. returns 200 { rollouts: { [profileId]: { id, state, currentRing, rings, toVersion, holdReason, label, … } } } guard one object, not paged; built from the 500 most recent rollout records | Session + view_devices |
| PUT | /user-management/devices/profiles/:id/rollout | Change a running rollout's automatic promotion. Audited as DEVICE_PROFILE_ROLLOUT_UPDATED. body A ProfileRolloutSettings: { autoPromote (boolean), promoteAfterHours (whole number, 1–720) } — either or both returns 200 { rollout: ProfileRollout } | 400 'Promote after 1–720 hours.' | 404 unknown profile, or no rollout in progress guard Repeat: safe; a replace-style write (each call is audited) | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/rollout/promote | Move a rollout on to its next ring now. body No body. returns 200 { rollout: ProfileRollout, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended guard Repeat: not safe; each call moves the rollout on one more ring. Entering the last ring completes it, and a call after that answers 404 | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/rollout/pause | Pause a rollout. body No body. returns 200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended guard Repeat: safe; a paused rollout stays paused (each call is audited) | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/rollout/resume | Resume a paused rollout. body No body. returns 200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended guard Repeat: the rollout stays running, but each call restarts the wait before automatic promotion | Session + manage_devices |
| POST | /user-management/devices/profiles/:id/rollout/rollback | Roll a rollout back to the version the profile had before it. body No body. returns 200 { rollout: ProfileRollout, version, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended guard Repeat: 404; the rollout has ended, so none is in progress | Session + manage_devices |
| GET | /user-management/devices/mdm/status | Apple MDM connection status (preview). Enrolled Macs at /devices/mdm/devices. returns 200 an MdmStatus (typed under Response Objects): { configured: false }, or { configured: true, connected:boolean, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter } | Session + view_devices |
| POST | /user-management/devices/mdm/enroll-code | Create an MDM enrolment code for a Mac. Audited as DEVICE_MDM_ENROLL_CODE. body An MdmEnrollCodeCreate: { reusable } — true for a reusable code; otherwise single-use returns the MDM control plane's own status and body | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer guard Repeat: asks the MDM control plane for another code | Session + manage_devices |
| POST | /user-management/devices/mdm/pushcert | Upload your Apple push (APNs) certificate (start from GET /devices/mdm/pushcert/request). Audited as DEVICE_MDM_PUSHCERT_UPLOAD. body An MdmPushCertUpload: { certPem, keyPem } or { p12, passphrase } returns the MDM control plane's own status and body | 400 when the certificate is missing or the .p12 cannot be opened | 413 over 200,000 characters | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer — after a 424 reload the status before retrying, because the certificate may or may not have been installed guard Repeat: sends the certificate to the MDM control plane again; after a 424 reload the status first | Session + manage_devices |
| POST | /user-management/devices/mdm/:udid/lock | Remote-lock a Mac through MDM. Audited as DEVICE_MDM_LOCK. body An MdmLockRequest: { message } — optional returns the MDM control plane's own status and body | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer, or answered but could not queue the lock guard an unanswered request may or may not have been queued: check the device before sending it again. Repeat: each call sends the MDM control plane another lock request | Session + manage_devices |
| POST | /user-management/devices/mdm/:udid/wipe | Remote-wipe a Mac through MDM. Audited as DEVICE_MDM_WIPE at critical level, including when the call fails. body A DestructiveReason: { reason } — required, at least 3 characters, cut to 500 returns the MDM control plane's own status and body | 400 no reason | 400 when Apple MDM is not configured | 424 when the control plane answered but could not queue the wipe | 502 when it did not answer | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard an unanswered wipe may or may not have been queued: check the device before sending it again. Repeat: each call sends the MDM control plane another wipe request | Session + manage_device_destructive + sudo |
| GET | /user-management/devices/software | Software inventory across the fleet (CSV at /devices/software/export.csv). Query: ?q (name search, at most 200 characters), ?page (default 1), ?pageSize (default 50, 1–500). Most-installed first, then by name. returns 200 a SoftwarePage (typed under Response Objects) { items: [{ name, publisher, deviceCount, platforms, versions: [{ version, count }] }], total, page, pageSize } | Session + view_devices |
| GET | /user-management/devices/:id/recovery-keys | Which recovery keys are escrowed for a device — never the key itself. FileVault recovery-key escrow is part of the preview, not yet validated end-to-end. returns 200 { keys: [{ keyType, volumeId, escrowedAt }] } guard every escrowed key record of the device in one reply (no paging) | Session + view_devices |
| POST | /user-management/devices/:id/recovery-key/:type/reveal | Reveal an escrowed recovery key. Audited as DEVICE_RECOVERY_KEY_REVEALED at critical level, with the reason. body A DestructiveReason: { reason } — required, at least 3 characters returns 200 { keyType, recoveryKey, volumeId, rotationScheduled } | 400 no reason | 404 no key escrowed for this device | 500 when the stored key cannot be decrypted | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: reveals the key again; every reveal is audited at critical level | Session + manage_device_destructive + sudo |
| POST | /user-management/devices/:id/filevault/rotate | Ask a Mac to replace its FileVault recovery key at the next login (preview). A second request while one is waiting answers 409. body A DestructiveReason: { reason } — required, at least 3 characters, cut to 500 returns 200 { requestId, reason: 'manual', requestedAt } | 400 | 404 | 409 when a new key is already requested, or this Mac cannot be re-keyed now | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: 409 while the new key asked for by the first call is still outstanding | Session + manage_device_destructive + sudo |
| GET | /user-management/devices/agent-rollouts | Agent version rollouts in progress. Query: ?platform = macos (default), linux or windows. Newest first, not paged. returns 200 { platform, rollouts: AgentRollout[] } (AgentRollout is typed under Response Objects: { id, platform, version, enabled, label, scope, scopeLabel, deviceCount, onTarget, createdBy, createdAt }) | Session + view_devices |
| POST | /user-management/devices/agent-rollouts/:id/hold | Hold an agent rollout (DELETE /devices/agent-rollouts/:id cancels it). The body sets the state, so the call is safe to repeat. body An AgentRolloutHold: { enabled } — false (or left out) holds the rollout, true resumes it returns 200 { ok: true, id, enabled } | 404. DELETE: 200 { ok: true } | 404 guard Repeat: safe; a replace-style write (each call is audited) | Session + manage_devices |
Personal API Tokens
api-gateway → identity-service
Mint and revoke the zx_ bearer tokens that scripts use. These routes are for a signed-in person: every one of them refuses a call that is itself authenticated with an API token, so a token can never create or revoke tokens. Limits: 20 active tokens per user, expiry 90 days by default and 365 days at most, 300 requests per minute per token.
Worked example: Which permissions a new token of yours could carry
Session cookie only: a token cannot manage tokens (403 code SESSION_REQUIRED). The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' https://<your-instance>-api.zonitix.com/api/v1/identity-admin/api-tokens/scopes
# Reply
200
{
"scopes": [
{ "name": "view_users", "module": "...", "description": "..." }
],
"defaultExpiryDays": 90,
"maxExpiryDays": 365
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /identity-admin/api-tokens | List your own tokens (metadata only). No query parameters: the whole list comes back, newest first. returns 200 ApiToken[], each { id, userId, owner, name, tokenPrefix, scopes[], createdAt, expiresAt, lastUsedAt, lastUsedIp, revokedAt, revokedBy, revokeReason, status } (field types under Response Objects as ApiToken) — status is active, expired, revoked or invalidated (the owner signed out everywhere or changed password after the token was made) | 403 code SESSION_REQUIRED when the caller is an API token | Session + create_api_tokens |
| GET | /identity-admin/api-tokens/scopes | The permissions you may give a token: your own current permissions, minus create_api_tokens. returns 200 { scopes: [{ name:string, module, description }] sorted by name, defaultExpiryDays:integer (90), maxExpiryDays:integer (365) } | 403 code SESSION_REQUIRED when the caller is an API token | Session + create_api_tokens |
| POST | /identity-admin/api-tokens | Create a token. The raw value is returned once and stored only as a hash. Repeating the call creates another token each time; nothing deduplicates by name. body An ApiTokenCreate: { name (required, cut to 120 characters), scopes (required, a non-empty array of permission names), expiresInDays (optional whole number 1–365, default 90) } returns 201 an ApiTokenCreated: the token record (ApiToken) plus token:string (the raw value, shown only here: zx_ followed by 43 characters from A-Z, a-z, 0-9, - and _, 46 characters in all) | 400 no name, expiry out of range or no scopes | 400 code SCOPE_EXCEEDS_OWNER when a scope is a permission you do not hold, or is create_api_tokens (the one permission no token can carry: 'cannot be granted to a token') | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status | 403 code MFA_REQUIRED_FOR_API_TOKENS; 403 when the account is not active | 409 when you already have 20 active tokens | 403 code SESSION_REQUIRED when the caller is an API token guard scopes must be a subset of the creator's own current permissions, re-derived from the database; on every later call the token carries only the scopes its owner still holds. A refused creation is audited as API_TOKEN_CREATE_DENIED. Repeat: creates a second token, even under the same name; 409 only once you hold 20 active tokens | Session + create_api_tokens + sudo |
| POST | /identity-admin/api-tokens/:id/revoke | Revoke one of your tokens. No body. Safe to repeat: a token that is already revoked returns the same record. body No body. returns 200 the ApiToken record | 404 for an unknown id or a token that is not yours | 403 code SESSION_REQUIRED when the caller is an API token guard Repeat: safe; a token that is already revoked answers 200 with its record unchanged | Session + create_api_tokens |
| GET | /identity-admin/api-tokens/all | Every user's tokens: owner, prefix, scopes, last used (never the token or its hash). No query parameters; newest first, capped at the newest 2000. returns 200 ApiToken[]: the same record, with owner set to the owner's e-mail | 403 code SESSION_REQUIRED when the caller is an API token | Session + manage_security |
| POST | /identity-admin/api-tokens/:id/admin-revoke | Revoke any user's token. Safe to repeat, as for the owner's revoke. body An ApiTokenAdminRevoke: { reason } — optional, cut to 200 characters; stored as revokeReason returns 200 the ApiToken record | 404 unknown id | 403 code SESSION_REQUIRED when the caller is an API token guard Repeat: safe; a token that is already revoked answers 200 with its record unchanged, and the first reason is kept | Session + manage_security |
Single Sign-On (OIDC & SAML)
api-gateway → identity-service
The browser sign-in redirects under /auth/sso carry no session and are rate-limited; the configuration routes under /identity-admin are administrative.
Worked example: Is single sign-on switched on
Public: the sign-in page calls it before anyone is signed in. The values shown are made up; the field names and the shape are what the route sends.
curl https://<your-instance>-api.zonitix.com/api/v1/auth/sso/status
# Reply
200 // switched on
{ "enabled": true, "protocol": "saml", "preset": "okta", "smtpConfigured": true }
200 // switched off: there is no "preset" key
{ "enabled": false, "protocol": "oidc", "smtpConfigured": true }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /auth/sso/status | Whether SSO is enabled, for the sign-in screen. returns 200 an SsoStatus { enabled:boolean, protocol: 'oidc' | 'saml', preset?:string (only while enabled: okta, entra, google or other), smtpConfigured:boolean } — { enabled: false } when the identity service cannot be reached | Public |
| GET | /auth/sso/login | Start a sign-in with whichever protocol is configured (redirects to your identity provider). No parameters. returns 302 to the identity provider, with a signed sso_flow cookie valid for 10 minutes | 302 to /login?sso_error=config when SSO is off or the provider's login address cannot be built | Public (rate-limited) |
| GET | /auth/sso/callback | OIDC redirect target that completes the sign-in. body Query string, not a JSON body: ?code and ?state, both sent by the identity provider; state must match the one bound to the browser's sso_flow cookie returns 302 to / with the session cookie set | 302 to /login?sso=continue when MFA or terms acceptance is still owed | 302 to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied | Public (rate-limited) |
| GET | /auth/sso/pending | The sign-in page calls this after an SSO sign-in was redirected to /login?sso=continue: it hands back the MFA or terms step still owed. body No query parameters. returns 200 an SsoPendingStep (typed under Response Objects): the fields of the step still owed, from { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods, termsAcceptanceRequired, termsToken, termsVersion } | 404 { error:'No pending SSO sign-in.' } when the cookie is absent | 410 { error:'Your SSO sign-in expired. Please sign in again.' } when the pending sign-in is expired or already redeemed guard single use: the cookie is cleared on every call, whatever the outcome; the pending sign-in lives for 5 minutes | Public (reads the httpOnly sso_pending cookie set by the SSO redirect) |
| GET | /auth/sso/saml/login | Start a SAML sign-in. The same handler as /auth/sso/login. returns as /auth/sso/login | Public (rate-limited) |
| POST | /auth/sso/saml/acs | SAML assertion consumer service — your identity provider posts the response here. RelayState is ignored. body form-encoded SAMLResponse (required), at most 100 kB returns 302 to / with the session cookie set | 302 to /login?sso=continue when MFA or terms acceptance is still owed | 302 to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied guard the response must answer the request id bound to this browser's sso_flow cookie | Public (rate-limited) |
| GET | /auth/sso/saml/metadata | Service-provider metadata to give your identity provider. returns 200 application/samlmetadata+xml | 502 { error } when the metadata cannot be built | Public (rate-limited) |
| GET | /identity-admin/sso/config | Read the SSO configuration (secrets masked). returns 200 an SsoConfig (typed under Response Objects) { enabled, protocol, preset, issuer, clientId, clientSecretSet, saml: { idpEntityId, idpSsoUrl, certs: [{ subject, notAfter, fingerprint256, expired }] }, jit: { enabled, allowedDomains[], defaultRoleId, defaultGroupId }, mfaAcrValues[] } | 403 | Session + manage_security |
| POST | /identity-admin/sso/config | Save the SSO configuration. A replace-style write: sending the same body again stores the same configuration. Audited as SSO_CONFIG_UPDATED. body { enabled, protocol: 'oidc' | 'saml', preset: okta | entra | google | other, issuer (an https URL), clientId, clientSecret, saml: { idpEntityId, idpSsoUrl, idpCerts }, jit: { enabled, allowedDomains, defaultRoleId, defaultGroupId }, mfaAcrValues (up to 20 strings) } — a field left out keeps its stored value, except enabled, which is saved as false unless sent as true returns 200 the masked configuration, as the GET | 400 { error } with the reason: unknown protocol or preset, issuer not https, client ID or client secret missing when turning OIDC on, SAML settings that fail validation, an invalid JIT domain, or JIT turned on with no allowed domain | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status | Session + manage_security + sudo |
| POST | /identity-admin/sso/saml/parse-metadata | Parse identity-provider metadata into the form fields. Saves nothing. body { metadataUrl } (an https address the server fetches) or { metadataXmlB64 } (the XML, base64-encoded, at most 700 kB of base64) returns 200 { idpEntityId, idpSsoUrl, idpCerts[], certs: [{ subject, notAfter, fingerprint256, expired }] } | 400 { error } when neither is sent, the XML cannot be parsed or it carries no signing certificate | Session + manage_security |
SCIM 2.0 Provisioning
api-gateway → user-management-service
Inbound SCIM for Okta or Entra ID. The /scim/v2 endpoints carry no session: your identity provider authenticates with the SCIM bearer token (it begins zscim_) you issue under /user-management/scim-provisioning. At most 10 tokens can be active. Only the resource paths listed here are reachable under /scim. Every /scim/v2 answer is application/scim+json, errors included. Filters, paging and error format are on the SSO & Passwordless page.
Worked example: Does this user exist (the lookup an identity provider makes before it creates one)
The SCIM bearer token from Security Settings, not a personal API token. The values shown are made up; the field names and the shape are what the route sends.
curl -G -H "Authorization: Bearer $SCIM_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/scim/v2/Users \
--data-urlencode 'filter=userName eq "new.person@example.com"'
# Reply
200
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": 0,
"startIndex": 1,
"itemsPerPage": 0,
"Resources": []
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /scim/v2/ServiceProviderConfig | SCIM discovery document. /scim/v2/ResourceTypes and /scim/v2/Schemas are served too. returns 200 | 401 SCIM error for a missing, malformed, unknown or revoked token | 404 SCIM error for any path outside the listed resources | SCIM bearer token |
| GET | /scim/v2/Users | List or filter users. Query: ?filter on userName, externalId, emails.value or id; ?startIndex (1-based, default 1) and ?count (default 100, at most 200; 0 returns only the total); ordered by id ascending. returns 200 a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects) | 400 scimType invalidFilter for an attribute that cannot be filtered | SCIM bearer token |
| POST | /scim/v2/Users | Create a user. Repeating the same create answers 409; a user this connection deleted earlier is brought back by the POST instead of duplicated. body a SCIM User resource (the attributes read are listed as ScimUserWrite under Response Objects; JSON, at most 1 MB); userName, or a primary e-mail, must be an e-mail address returns 201 the ScimUser, with a Location header | 400 scimType invalidValue or invalidSyntax | 409 scimType uniqueness when a live user already has that userName or externalId | SCIM bearer token |
| GET | /scim/v2/Users/:id | Read one user. PUT replaces, PATCH updates, DELETE removes. PUT is applied as a merge: an attribute the request leaves out is not cleared. DELETE deactivates the account through the leaver path and marks it deleted; it is never a hard delete. returns 200 the ScimUser (GET, PUT, PATCH) | 204 (DELETE) | 404 unknown or deleted user | 403 for the primary administrator, a user owned by another directory, or a record another SCIM connection owns | 409 for the support account; 409 scimType uniqueness when a new userName is taken | 400 scimType mutability when the sign-in e-mail of an account with elevated access would change | SCIM bearer token |
| GET | /scim/v2/Groups | List groups. POST creates one. Query: ?filter on displayName, externalId or id; ?excludedAttributes; ?startIndex (1-based, default 1) and ?count (default 100, at most 200; 0 returns only the total); ordered by id ascending. returns 200 a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects) | 400 scimType invalidFilter for an attribute that cannot be filtered. POST: 201 the group, with a Location header | 400 scimType invalidValue without displayName | 403 when group creation by SCIM is turned off | 409 scimType uniqueness when the name exists | SCIM bearer token |
| GET | /scim/v2/Groups/:id | Read one group. PUT replaces, PATCH updates, DELETE removes. returns 200 the ScimGroup (GET, PUT, PATCH) | 204 (DELETE) | 404 for an unknown group or one that is not SCIM-managed | 403 when deleting a Zonitix-owned group that was opted in to SCIM, or adding a member to a group that grants privileged access the administrator has not allowed | 409 scimType uniqueness on a rename to an existing name | SCIM bearer token |
| GET | /user-management/scim-provisioning/tokens | List SCIM tokens (metadata only). No query parameters: every token, newest first. returns 200 ScimToken[] (typed under Response Objects) { id, name, tokenPrefix, createdBy, createdAt, lastUsedAt, revokedAt, revokedBy, familyId, active, owns: { users, groups } or null } guard bare array, every token (revoked ones included) in one reply, newest first; no paging | Session + manage_security |
| POST | /user-management/scim-provisioning/tokens | Issue a SCIM token. Repeating the call issues another token each time. Audited as SCIM_TOKEN_ISSUED. body A ScimTokenIssue: { name (required, cut to 120 characters), rotateFromId (optional: the id of the token this one replaces, so it keeps that connection's users and groups) } returns 201 the ScimToken record plus token (the raw zscim_ value, shown only here) | 400 no name | 404 rotateFromId not found | 409 when 10 tokens are already active guard Repeat: issues another token; 409 only once 10 tokens are active | Session + manage_security |
| POST | /user-management/scim-provisioning/tokens/:id/revoke | Revoke a SCIM token. No body. Safe to repeat: an already revoked token returns the same record. body No body. returns 200 the ScimToken record, without owns | 404 unknown id guard Repeat: safe; a token that is already revoked answers 200 with its record unchanged | Session + manage_security |
| POST | /user-management/scim-provisioning/tokens/:id/rebind | Move the users and groups owned by another token's connection (body fromTokenId), or the records with no owning connection (body unowned: true), to this token's connection. Safe to repeat: a second call finds nothing left to move and returns zeros. body A ScimTokenRebind: { fromTokenId } or { unowned: true } returns 200 { users, groups } — how many were moved | 400 when neither is sent, or both tokens already belong to the same connection | 404 target or source token not found | 409 when the target token is revoked | Session + manage_security |
| GET | /user-management/scim-provisioning/ownership | Count of SCIM-provisioned users and groups that have no owning connection. returns 200 { unowned: { users, groups } } | Session + manage_security |
| GET | /user-management/scim-provisioning/settings | Read the SCIM settings (PUT the same path to change them). The PUT is a replace-style write and is audited as SCIM_POLICY_UPDATED. body The PUT's JSON body (the GET takes none): any of onboardOnCreate, allowGroupCreate (true or false), managedGroupIds, privilegedAllowedGroupIds (arrays of group ids); a field left out keeps its value, and only local groups are kept in managedGroupIds returns GET 200 ScimSettings (typed under Response Objects) { policy: { onboardOnCreate, allowGroupCreate, managedGroupIds[], privilegedAllowedGroupIds[] }, groups: [{ id, name, source, scimCreated, eligible, managed, privileged, confers[], privilegedAllowed }] } | PUT 200 the saved policy | Session + manage_security |
Access Reviews, Access Requests & Departments
user-management-service
Periodic access reviews (certifications), self-service access requests, and the governed department list. Errors from these routes are { error } with a sentence written for the person; they carry no machine code.
Worked example: The department names
API token holding view_users (or any other permission the row lists). The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/departments
# Reply
200 // a bare array of names, sorted
["Engineering", "Finance", "People"]
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/access-reviews | List access-review campaigns (one at /access-reviews/:id, CSV at /access-reviews/:id/export). Query: ?archived=1 returns the archived campaigns instead of the current ones. The list is not paged; newest first. returns 200 AccessReviewCampaign[] (typed under Response Objects), every campaign of the kind asked for in one reply (no paging). GET /:id takes ?limit (default 200, 1–500) and ?offset and returns an AccessReviewCampaignDetail { campaign, items: AccessReviewItem[], total, limit, offset }, items ordered by person then access type | 404 unknown campaign. /:id/export returns text/csv | Session + view_access_reviews | manage_access_reviews |
| POST | /user-management/access-reviews | Start an access-review campaign. Repeating the call starts another campaign; nothing deduplicates by name. body An AccessReviewCampaignCreate: { name (required), description, scopeType: 'all' (default) or 'department', scopeValue (the department; required for a department scope), dueDate, externalSource: none | persona (default) | actual | both } returns 201 the AccessReviewCampaign | 400 no name, or a department scope with no department guard Repeat: starts a second review with its own items; names are not checked for duplicates | Session + manage_access_reviews |
| POST | /user-management/access-reviews/:id/close | Close a campaign (also /archive and /restore; DELETE removes it). No body. Each is guarded by the campaign's state, so a repeat answers 409 instead of acting twice. body No body, for close, archive, restore and delete alike. returns 200 the AccessReviewCampaign | 404 | 409 when it is not active (close), already archived (archive) or not archived (restore). DELETE: 200 { deleted: true, id } | 409 when any item has been decided — archive it instead guard Repeat: 409; a closed review is no longer active, an archived one is already archived, and a restored one is no longer archived | Session + manage_access_reviews |
| GET | /user-management/access-reviews/my-queue | The review items waiting for your decision, from active campaigns only. Query: ?limit (default 50, 1–200), ?offset (default 0), ?filter = all (default), attention or expected. returns 200 an AccessReviewQueue (typed under Response Objects) { items: AccessReviewItem[], total, limit, offset, filter, stats: { all, attention, expected, privileged, drift, people } } | Session (any signed-in user; only items assigned to you are returned) |
| GET | /user-management/access-reviews/reviewers | The people an item can be reassigned to: every active holder of certify_access or manage_access_reviews. No query parameters; not paged, sorted by email. returns 200 an array of { email:string } | Session + manage_access_reviews |
| GET | /user-management/access-reviews/grant-detail | The permissions behind one role or group, for the expandable row of a review item. Query: ?accessType = role or group, ?accessId (the role or group id). body Query: ?accessType and ?accessId; accessId is required for a role or a group. returns 200 an AccessGrantDetail (typed under Response Objects): { accessType, accessId, accessLabel, external:false, permissions:string[] (sorted), permissionCount, privileged } for a role or group; for any other accessType { accessType, accessId, external:true, permissions:null, permissionCount:null, privileged:false } | 400 { error:'accessId is required.' } | 403 when you hold none of the three permissions and no item for that grant is assigned to you | 503 when your review assignments could not be checked; try again guard read-only and safe to repeat | Session + certify_access | view_access_reviews | manage_access_reviews, or the reviewer an item for that grant is assigned to |
| POST | /user-management/access-reviews/items/:id/decide | Keep or revoke one reviewed grant (several at once at /access-reviews/items/bulk-decide). A second decision on the same item answers 409. body An AccessReviewDecision: { decision: 'keep' | 'revoke' (required), note }. Bulk: { ids (1–500), decision, note } returns 200 the AccessReviewItem | 400 | 403 for your own access, an item not assigned to you (without manage_access_reviews), or the primary administrator's access | 404 | 409 already decided, campaign no longer active, or a revoke of baseline access. Bulk: 200 { decision, ok, failed, results: [{ id, ok, error }] }; items that cannot be decided are skipped, not fatal guard Repeat: 409; an item can be decided once | Session + certify_access | manage_access_reviews, or the reviewer the item is assigned to |
| POST | /user-management/access-reviews/items/:id/reassign | Hand a review item to another reviewer (several at /access-reviews/items/bulk-reassign). Safe to repeat: reassigning to the current reviewer changes nothing. body An AccessReviewReassign: { reviewerEmail }. Bulk: { ids (1–500), reviewerEmail } returns 200 the AccessReviewItem | 400 no reviewerEmail, the person whose access it is, or an inactive user | 404 item or user not found | 409 already decided. Bulk: 200 { ok, failed, results } guard Repeat: safe while the item is still pending; the reviewer is set again | Session + manage_access_reviews |
| GET | /user-management/access-reviews/schedules | Recurring review schedules. Not paged; newest first. returns 200 AccessReviewSchedule[] (typed under Response Objects), every schedule in one reply | Session + view_access_reviews | manage_access_reviews |
| POST | /user-management/access-reviews/schedules | Create a recurring review (PATCH or DELETE /schedules/:id; run now at /schedules/:id/run; pause or resume at /schedules/:id/status). Repeating the POST creates another schedule. body An AccessReviewScheduleWrite: { name (required), cadence: monthly | quarterly (default) | semiannual | annual | custom, intervalMonths (custom only, at least 1), scopeType: all | department, scopeValue, externalSource: none | persona | actual | both (default), dueDays (1–365, default 14), remind (default true), autoClose (default true), skipIfOpen (default false), firstRunAt (a date; default now) }. PATCH takes the same fields, all optional. Status: { status: 'active' | 'paused' } returns 201 the AccessReviewSchedule | 400 no name. PATCH: 200 | 400 a firstRunAt that is not a date | 404. Run: 201 the launched campaign | 409 when the previous run is still open and skipIfOpen is on. DELETE: 200 { deleted: true, id } guard Repeat: creates a second schedule; names are not checked for duplicates. Run: 409 only when skipIfOpen is on and the previous run is still open, otherwise each call launches another review | Session + manage_access_reviews |
| GET | /user-management/access-reviews/live | Current access as the reviews see it, computed on request. Query: ?scopeType (all or department), ?scopeValue, ?externalSource (none, persona, actual or both; default both), ?driftOnly=1. Not paged: at most 2000 items. returns 200 a LiveAccessSnapshot (typed under Response Objects) { generatedAt, scopeType, scopeValue, externalSource, userCount, total, driftCount, privilegedCount, providersActive, externalScanned, capped, items } — capped is true when the list was cut at 2000 | Session + view_access_reviews | manage_access_reviews |
| POST | /user-management/access-reviews/live/revoke | Revoke a grant outside a campaign. body A LiveAccessRevoke: { userId, accessType, accessLabel (all required), accessId (required for a role or a group), providerType } returns 200 { revoked: true, runId } | 400 | 403 for the primary administrator's access | 404 user not found | 409 for baseline access, which is restored automatically | Session + manage_access_reviews |
| POST | /user-management/access-requests | Request access. Repeating the call files another pending request; nothing deduplicates. body An AccessRequestCreate: { itemLabel (required), itemKind (default 'app'), providerType, justification, customFields (only keys in the custom-field list are kept, 500 characters each), userId (honoured only for an approver or manage_users; otherwise the request is for you) } returns 201 the AccessRequest, status pending | 400 no itemLabel, or your account could not be resolved | 404 user not found guard Repeat: files a second pending request; an earlier request for the same thing does not block it | Session + request_access | approve_access_request | manage_users |
| GET | /user-management/access-requests/mine | Your own requests. No query parameters; not paged, newest first. returns 200 AccessRequest[] (typed under Response Objects), every request of yours in one reply | Session + request_access | approve_access_request | manage_users |
| GET | /user-management/access-requests | All requests, for approvers. Query: ?status to keep one status (for example pending). Not paged; newest first. returns 200 AccessRequest[] (typed under Response Objects), every matching request in one reply | Session + approve_access_request | manage_users |
| POST | /user-management/access-requests/:id/decide | Approve or deny a request (several at /access-requests/bulk-decide). A request that is already decided answers 409; one whose provisioning run could not be created comes back with status failed and can be decided again. body An AccessRequestDecision: { approve (true approves; anything else rejects), comment }. Bulk: { ids (1–500), approve, comment } returns 200 the AccessRequest | 403 | 404 | 409 already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] } guard a non-administrator cannot approve a request they submitted or one for their own access (403); rejecting it is allowed. Repeat: 409; a request that is already decided cannot be decided again | Session + approve_access_request | manage_users |
| GET | /user-management/access-requests/custom-fields | The organisation's extra fields on the request form. returns 200 { fields: [{ key, label, type: text | select | number | date, options }] } guard the whole field list in one reply (no paging) | Session + request_access | approve_access_request | manage_users |
| POST | /user-management/access-requests/custom-fields | Replace the extra request fields. A replace-style write, safe to repeat. Audited as ACCESS_REQUEST_CUSTOM_FIELDS_SET. body An AccessRequestFieldsWrite: { fields } — at most 20 fields; a label is cut to 60 characters and a select keeps up to 50 options; a malformed entry is skipped, not refused returns 200 { fields } as stored guard replaces the whole list. Repeat: safe; the same list is stored again | Session + manage_users |
| GET | /user-management/departments | The department list used by intake, user and job forms. No query parameters; sorted by name, not paged. returns 200 an array of strings (the names, sorted). /departments/detailed (view_users | manage_users | manage_security) returns [{ name, memberCount, governed, variants }] | Session + view_users | manage_users | manage_security | manage_workflows | view_recruitment | manage_recruitment | source_candidates | conduct_interview | view_employee_records | manage_employee_records | manage_devices |
| POST | /user-management/departments | Add a department (also /departments/rename and /departments/merge; DELETE /departments/:name). A repeat of the same add answers 409. body A DepartmentCreate: { name } — required, at most 100 characters. Rename: { from, to }. Merge: { from, into } returns 201 { name, departments, detailed } | 400 | 409 when the name already exists, ignoring case. Rename: 200 { from, to, moved, references, detailed } | 404 | 409 when the target exists or a connected HR system owns the department field. Merge: 200 { from, into, moved, references, detailed } | 400 into itself | 404 | 409. DELETE: 200 { name, detailed } | 404 not a governed department | 409 while any user is in it guard Repeat: 409; the name then already exists | Session + manage_users | manage_personas |
User Import, Invites & Bulk Actions
user-management-service
Bulk onboarding for a company with no directory, the set-password invite actions, forced password changes, role changes through a persona, and the administrator's data-subject export.
Worked example: The import template
API token holding manage_users. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/users/import/template
# Reply
200 // text/csv, attachment zonitix-user-import-template.csv
email,firstName,lastName,department,title,managerEmail,groups
jane.doe@example.com,Jane,Doe,Engineering,Software Engineer,john.smith@example.com,Engineering
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/users/:id/export | Export another user's personal data by id, including a former employee who can no longer export their own. Audited as PEOPLE_DATA_EXPORTED. returns 200 a JSON document; it carries the 200 most recent audit entries for that e-mail | 404 user not found | Session + manage_users |
| GET | /user-management/users/import/template | Download the CSV import template. returns 200 text/csv attachment with the header email, firstName, lastName, department, title, managerEmail, groups and one example row | Session + manage_users |
| POST | /user-management/users/import/preview | Validate a CSV and preview what would be created. Writes nothing. body { csv (the file text; required, at most 1,048,576 characters and 1000 rows), options: { existing: 'skip' (default) | 'update', sendInvites, addNewValues } } returns 200 { rows, summary: { total, create, update, skip, error }, newDepartments, newTitles, options } | 400 no csv, an empty file, no email column or more than 1000 rows | 413 file too large | Session + manage_users |
| POST | /user-management/users/import/commit | Create the users from the CSV. The file is validated again on the server; the preview is never trusted. With existing: 'skip' (the default) a row whose e-mail already has an account is skipped, so sending the same file again does not create the users twice. Audited as USERS_IMPORTED. body the same body as the preview returns 200 { rows, summary: { total, created, updated, skipped, errors, invitesSent, invitesNotEmailed }, links: [{ email, link, expiresAt }], options } — links holds a one-time set-password link for each invite that could not be e-mailed | 400 | 413 | Session + manage_users |
| POST | /user-management/users/:id/invite/resend | Resend the set-password invite. The previous link stops working. Each call issues a new 72-hour link. body No body. returns 200 { sent: true, expiresAt } | 200 { sent: false, reason, link, expiresAt } when the e-mail could not be sent: the link is shown once | 200 { sent: false, reason, expiresAt, linkWithheld: true } when the account holds access you could not grant yourself | 404 | 409 when this account cannot be invited guard Repeat: each call makes a new set-password link, which replaces the one before it, and sends the e-mail again | Session + manage_users |
| POST | /user-management/users/:id/invite/revoke | Revoke the invite link. A second call answers 409, because no invitation is pending any more. body No body. returns 200 { revoked: true } | 404 | 409 no pending invitation guard Repeat: 409; no invitation is pending any more | Session + manage_users |
| POST | /user-management/users/bulk | Change manager, department, persona, status, group or role on up to 500 users at once. body A UserBulkUpdate: { userIds (1–500), action: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole, value } — setStatus accepts only active, inactive or disabled returns 200 { updated, skipped: [{ id, reason }] } | 400 no users, more than 500, an unknown action, or a value that does not exist | 403 | 409 when a connected HR system owns the manager or department field guard role changes additionally need manage_roles and group changes manage_groups; offboarding, deletion, password resets and MFA resets cannot be done in bulk | Session + manage_users |
| POST | /user-management/users/:id/expire-password | Force a new password at the next sign-in and end every live session for the account. Local accounts only. No body. body No body. returns 200 { expired: true, id, email } | 404 guard refuses the primary administrator and the support account (403), your own account (400) and directory-sourced accounts (409). Repeat: safe for the account, but each call ends its sessions again and writes another USER_PASSWORD_EXPIRED audit entry | Session + manage_users |
| POST | /user-management/users/expire-password | The same for up to 500 users. Accounts that would be refused are skipped with a reason. body A PasswordExpiryBatch: { userIds } — 1 to 500 ids returns 200 { expired: [{ id, email }], skipped: [{ id, reason }] } | 400 no users or more than 500 guard each user is tried on its own, so read skipped[] even on a 200. Repeat: safe for the accounts, but each call ends their sessions again and audits each one again | Session + manage_users |
| GET | /user-management/users/identity-availability | Whether an email or account name is free, with suggestions that follow the naming convention. Query: ?email, ?username, ?firstName, ?lastName, ?excludeUserId. returns 200 { available, usernameAvailable, suggestions (up to 3), usernameSuggestions, onConvention, usernameOnConvention, convention, emailDomain, strategy } — available is null, with error: 'lookup_unavailable', when the lookup itself fails | Session + execute_workflows | manage_users | view_users | view_dashboard |
| POST | /user-management/users/:id/onboard | Start the onboarding flow for a user. Safe to repeat: while a run is in progress the call answers { idempotent: true, message } and creates no second run. body A UserOnboard: { personaId, startDate, managerId } — all optional returns 201 the run | 202 { accountPrepared, runCreated: false, warning, rehire } when the account was prepared but no run could be created | 403 when the persona grants access you do not hold | Session + manage_users |
| POST | /auth/security/users/:userId/invite | Invite a user: activates an account that is still in preboarding and emails a sign-in link. The account is activated only after the e-mail has gone out. body No body. returns 200 { sent: true, activated, status } | 404 | 409 when the account is blocked from signing in, no sign-in address is configured, or e-mail (SMTP) is not configured | 424 when the e-mail could not be sent: the account is left unchanged guard sends real e-mail. Repeat: each call sends another e-mail with a new single-use sign-in link | Session + manage_users |
| POST | /auth/invite/inspect | The set-password page checks an invitation token (body: token). 410 when the link is used, revoked or expired. body { token } — required string, at most 128 characters returns 200 an InviteInspection { valid: true, email, firstName, expiresAt, policy } | 400 | 410 guard Repeat: safe; reading an invitation does not use it up | Public (rate-limited) |
| POST | /auth/invite/accept | Set the first password from an invitation (body: token, password). Creates no session; the person then signs in normally. A second call with the same token answers 410. body An InviteAcceptRequest: { token (required, at most 128 characters), password (required, at most 1024 characters) } returns 200 { success: true, email } | 400 { error } when the password fails the policy | 410 guard the password must pass the password policy (400 otherwise); the token works once. Repeat: 410; the first accepted call uses the token up | Public (rate-limited) |
| POST | /user-management/personas/preview/mover-plan | Preview a role change (body: userId, newPersonaId): what the new persona grants and what would be revoked. Writes nothing. body { userId, newPersonaId } — both required returns 200 the plan | 400 a field is missing, or the user already has that role | 404 user or role not found | Session + manage_users | manage_personas | manage_workflows |
| POST | /user-management/personas/mover | Start a role-change run (body: userId, newPersonaId, optional keep and revokeGraceDays). Returns 201 with the run. body { userId, newPersonaId (both required), keep, revokeGraceDays } returns 201 the run | 400 a required field is missing | 403 guard 403 when the new persona grants access the caller does not hold | Session + manage_users |
Log Forwarding & Support Access
logging-service, user-management-service
Administrator-only operational settings. Because they require the administrator role itself rather than a permission, a personal API token cannot call the administrator-only rows.
Worked example: Send a test event to a log-forwarding destination
Session cookie of an administrator, plus an Origin header because it is a write. The values shown are made up; the field names and the shape are what the route sends.
curl -X POST --cookie '__Host-token=<session-jwt>' -H 'Origin: https://<your-instance>.zonitix.com' \
https://<your-instance>-api.zonitix.com/api/v1/user-management/logs/forwarding/3/test
# Reply
200 // delivered: "status" is what your receiver answered
{ "ok": true, "status": 204 }
200 // not delivered: still 200, read "ok"
{ "ok": false, "status": 401, "error": "HTTP 401: credentials rejected — check the token / key / signing secret" }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/logs/forwarding | List the log-forwarding (SIEM) destinations. No query parameters; ordered by id, never more than 10. returns 200 { destinations: LogForwardDestination[], types, datadogSites, health: LogForwardingHealth } (typed under Response Objects), every destination in one reply, id ascending (no paging) — a destination is { id, name, type, typeLabel, enabled, url, port, config, secretSet, cursorSeq, lastAckAt, lastAttemptAt, lastError, lastErrorAt, consecutiveFailures, nextAttemptAt, totalSent, skippedCount, lastSkip, gapCount, lagEvents, lagSeconds, createdBy, updatedBy, createdAt, updatedAt }; the secret is never returned | Session + administrator role |
| POST | /user-management/logs/forwarding | Add a destination (PUT or DELETE /logs/forwarding/:id; reset its cursor at /:id/reset-cursor). Repeating the POST adds another destination, up to the limit of 10. body { type: webhook | splunk_hec | datadog | syslog_tls (required; cannot be changed later), name (required, at most 80 characters), enabled (default true), url (an https URL for webhook and splunk_hec; a bare host name for syslog_tls), port (syslog_tls only, 1–65535, default 6514), secret (required for webhook, splunk_hec and datadog; a webhook signing secret is at least 16 characters), config: { index, sourcetype } for Splunk, { site, tags } for Datadog, { appName, caPem } for syslog, startFrom: 'now' (default) | 'all' }. PUT takes the same fields; a blank secret keeps the stored one. Reset: { to: 'now' (default) | 'all' } returns 201 the LogForwardDestination | 400 { error } for an invalid field, a refused address, or an eleventh destination. PUT: 200 the destination | 400 | 404. DELETE: 200 { ok: true } | 404. Reset: 200 { from, to, skippedEvents, destination } | 404. Each of these four writes: 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: adds another destination with the same settings; names are not required to be unique. 400 once 10 destinations exist | Session + administrator role + sudo |
| POST | /user-management/logs/forwarding/:id/test | Send a test event to a destination. It carries a synthetic event, not your audit data, and is audited as LOG_FORWARD_TEST_SENT. body No body. returns 200 { ok, status, error } — ok is false, with the reason, when delivery failed | 404 unknown destination guard Repeat: each call sends the destination another test event and writes another LOG_FORWARD_TEST_SENT audit entry | Session + administrator role |
| GET | /user-management/support/access | Whether Zonitix support currently has a sign-in to your app. returns 200 a SupportAccessStatus (typed under Response Objects) { active, grant (a SupportAccessGrant or null), account: { email, status, mfaEnrolled, invitePending, lastActiveAt } or null, history, limits: { minHours: 1, maxHours: 72 }, readOnlyPermissions, supportMailbox } | Session + manage_security |
| POST | /user-management/support/access | Grant Zonitix support a sign-in to your app. A second grant while one is active answers 409: revoke first. body A SupportAccessGrantRequest: { hours (a whole number, 1–72), email (the support address the invitation goes to), reason (5–500 characters, recorded in the audit log), scope: 'read_only' (default) | 'role', roleName (required for scope role), acknowledgeAdministrator: true (required when roleName is administrator) } returns 201 { grant, invite } | 400 { error } for an invalid field or an unknown role | 400 code ADMINISTRATOR_ACK_REQUIRED | 403 for a role you cannot grant | 409 when access is already active, or the address belongs to a regular account | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: 409 while the access granted by the first call is still active | Session + administrator role + sudo |
| POST | /user-management/support/access/revoke | Revoke support access. No body. A second call answers 409, because nothing is active. body No body. returns 200 { revoked: true, grant } | 409 no active support access, or it had already ended | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status guard Repeat: 409; there is then no active support access to revoke | Session + administrator role + sudo |
| PUT | /user-management/support/mailbox | Set or clear the default address the Grant support access window is pre-filled with. body A SupportMailboxUpdate: { email } — a valid address, or empty to clear it returns 200 { supportMailbox } | 400 not a valid e-mail address guard audited as SUPPORT_MAILBOX_UPDATED; no step-up, because the grant itself still needs one. Repeat: safe; the same address is stored again (each call is audited) | Session + administrator role |
| GET | /user-management/support/diagnostics | Build the diagnostics bundle you can send to support. Audited as DIAGNOSTICS_BUNDLE_GENERATED. returns 200 a JSON attachment named zonitix-diagnostics-<timestamp>.json | 401 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo) | 503 when the bundle could not be built | Session + manage_security + sudo |
Report Engine & Scheduled Reports
compliance-service
Generating, scheduling, sealing and sharing reports. These routes answer 403 when the Reporting module is explicitly switched off on your licence. Scheduled reports replaced the retired compliance digest. Every route that builds, saves or schedules a report also checks the report kind against your permissions and answers 403 "A '<kind>' report requires one of: ..." when you may not see that kind or one of its data sets.
Worked example: The newest sealed run
API token holding view_reporting. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/runs?limit=1"
# Reply
200 // a bare array, newest first; file bytes are never in a row
[
{
"id": 418,
"scheduleId": 3,
"scheduleName": "Weekly SOC 2",
"kind": "compliance",
"framework": "SOC 2",
"trigger": "scheduled",
"triggeredBy": null,
"status": "ok",
"error": null,
"readinessScore": 82,
"passCount": 41,
"failCount": 6,
"manualCount": 3,
"totalControls": 52,
"naCount": 2,
"applicable": 50,
"delivered": true,
"deliveryError": null,
"deliveryAttempts": 1,
"durationMs": 5120,
"artifactHash": "9f2c...e1a7",
"hasPdf": true,
"hasCsv": true,
"hasXlsx": false,
"sealed": true,
"sealMissing": false,
"legalHold": false,
"retainUntil": "2027-10-04T06:00:05.000Z",
"verifyUrl": "<the address printed on the report>",
"createdAt": "2026-10-04T06:00:05.000Z"
}
]
// Shortened: the full field list is ReportRun under Response Objects. The framework name is whatever your catalogue calls it.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /compliance/reports/generate | Build a report on demand. body { kind? ('compliance' default | overview | query | lifecycle | device-posture | budget | crosswalk | type2), framework (required for compliance and type2; must be in the catalogue), frameworks[]? (crosswalk: none or at least two), queries[]?, columns?, groupBy?, variant? (budget), filters? (object), window? { from, to } (required for type2), lifecycleType? ('leaver' | 'joiner'), sections[]? (summary | controls | tables), formats[]?, seal? (true only), reportKey? } returns 200 the report as JSON; raw rows are capped at 250 for the preview. A compliance run adds seal { status: 'queued' | 'not_sealed' }; a type2 run adds archive { status: 'sealing', runId } | { status: 'not_sealed' } | { status: 'failed' }. 400 with the reason for any unknown kind, framework, data set, section or a bad window. guard Only seal: true writes to the evidence vault. Repeat: a second sealing run of the same definition shortly after is joined onto the first (coalesced), not sealed twice. | Session + view_reporting (licensed) |
| POST | /compliance/reports/download | Download a report file. body Query ?format= pdf (default) | csv | xlsx | pptx | zip. Body: the same definition as /generate, plus name? used in the file name. returns 200 the file as an attachment. format=zip archives the report to the vault first and returns the auditor pack built from the stored bytes, with header X-Report-Run-Id. 400 "Unknown format" or a rejected definition | 409 when a stored file fails its integrity check. guard Repeat: every zip download archives a new vault run. | Session + view_reporting (licensed) |
| GET | /compliance/reports/schedules | List scheduled reports. returns 200 { schedules: ReportSchedule[], smtpConfigured: boolean, frameworks: string[] } (ReportSchedule is typed under Response Objects). Everything, newest first; no paging parameters are read. Each schedule carries webhookUrl masked to host and last four characters, webhookConfigured, webhookSigned and owner. | Session + view_reporting (licensed) |
| POST | /compliance/reports/schedules | Create a scheduled report (PUT or DELETE /schedules/:id). body { name (required, up to 120 characters), everyHours (required, whole number 1 to 2160), kind?, framework (required for compliance and type2), recipients[]? (email addresses, at most 50, checked against the recipient policy), formats[]? (pdf | csv | xlsx | pptx; default ['pdf']), webhookUrl?, queries[]?, columns?, groupBy?, variant?, filters?, window?, sections[]?, lifecycleType?, enabled? }. PUT takes any subset; a blank webhookUrl leaves the stored one, null removes it. returns 201 a ReportSchedule (typed under Response Objects); when a webhook is set, webhookSigningSecret: string is included once and never again. PUT 200 the ReportSchedule. DELETE 200 { ok: true }; the run history is kept. 400 with the reason | 404 "That scheduled report no longer exists." guard Repeat: POST is not deduplicated, so sending it twice creates two schedules. PUT replaces the fields you send. | Session + manage_compliance (licensed) |
| POST | /compliance/reports/schedules/:id/run | Run a scheduled report now. body No body. returns 202 { runId: integer, status: 'running' }. The run continues in the background; poll GET /runs or /runs/:id/status. 404 when the schedule is gone | 409 "Not started" with the reason when a run of this schedule is already live. guard Sends real email to the schedule's recipients. Repeat: refused with 409 while the first run is still going. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/runs | The sealed run history (status at /runs/:id/status, the file at /runs/:id/artifact). body Query, run history: limit (default 50, 1 to 200), scheduleId. Query, evidence vault (?vault=1): framework, trigger (scheduled | manual), scheduleId, from, to (yyyy-mm-dd or ISO; to includes the whole day), limit (default 50, 1 to 100), cursor. returns 200 ReportRun[] for the run history: a bare array, newest first. Vault: { runs: ReportRun[], nextCursor: string | null }; pass nextCursor back as cursor, null on the last page. ReportRun is typed under Response Objects. Rows never carry file bytes: they carry hasPdf, hasCsv, hasXlsx, the hashes, legalHold, retainUntil, status, error and verifyUrl. Runs you may not open are left out. 400 for a bad scheduleId, date, trigger, framework or cursor. guard /runs/:id/artifact takes ?format= pdf (default) | csv | xlsx and answers with headers X-Artifact-SHA256 and X-Artifact-Integrity; 404 when no file is stored, 409 when the tamper check fails. | Session + view_reporting (licensed) |
| POST | /compliance/reports/runs/:id/hold | Put a run on hold, or release it. body A ReportRunHold: { legalHold: true | false }. Without the field the hold is flipped. returns 200 { id: integer, legalHold: boolean } | 200 { id, legalHold, unchanged: true } when it was already in that state | 400 "legalHold must be true or false." | 404 "That run no longer exists." guard Repeat: safe when you send legalHold; a call with no body flips the hold each time. | Session + manage_compliance (licensed) |
| POST | /compliance/reports/runs/:id/share | Create a share link to a sealed run (list with GET; revoke with DELETE /runs/share/:linkId). body A ReportShareCreate: { format? ('pdf' default | 'csv'), confirmCsv (must be true for csv), ttlHours? (default 168; 1 to 2160), recipientEmail?, recipientLabel? (up to 80 characters) } returns 201 a ReportShareLink (typed under Response Objects) plus token: string. The token is returned this once; lists show tokenHint only. GET 200 { links: ReportShareLink[] }, newest first; the GET list needs only view_reporting. DELETE 200 { ok: boolean }. 400 CSV_CONFIRM_REQUIRED, or when the run has no stored file in that format or is not sealed | 404 when the run or link does not exist. guard Repeat: each POST mints another link. Revoking an already revoked link answers { ok: true } again. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/runs/:id/share | List the share links of one sealed run. body No query parameters. returns 200 { links: ReportShareLink[] } (typed under Response Objects); each carries tokenHint, never the token | 404 "That evidence run does not exist." | Session + view_reporting (licensed) |
| DELETE | /compliance/reports/runs/share/:linkId | Revoke one share link. body No body. returns 200 { ok: boolean } | 404 "That share link does not exist." guard A revocation is audited as REPORT_SHARE_LINK_REVOKED. Repeat: safe; revoking a link that is already revoked answers 200 again. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/runs/share/:linkId/views | Recent public accesses of one share link, served and refused, read back from the audit log. body No query parameters. returns 200 { linkId, views[] } | 404 "That share link does not exist." | Session + view_reporting (licensed) |
| GET | /compliance/reports/saved | Saved report definitions (POST to save one, DELETE /saved/:id). body POST: { name (required, up to 120 characters), format? ('table' | 'pdf') } plus the same definition fields as /generate. returns 200 SavedReport[] (typed under Response Objects) for the GET: a bare array, most recently updated first, at most 200; your own, or everyone's with manage_compliance. POST 201 the SavedReport. DELETE 200 { ok: true } | 403 "That saved report belongs to someone else." | 404. guard Repeat: POST is not deduplicated. | Session + view_reporting (licensed) |
| POST | /compliance/reports/vault/archive | Archive a report into the vault now. body The same definition as /generate; name? defaults to 'ad-hoc evidence'. returns 201 { id: integer, framework: string | null, readinessScore: integer | null, artifactHash: string | null, coalesced?: true } | 400 with the reason when the report could not be generated. guard Nothing is emailed. Repeat: a double click seals once; the second answer carries the same run id and coalesced: true. | Session + view_reporting (licensed) |
Compliance Hub: Policies, Risks, Vendors & Auditors
compliance-service
The registers and auditor-facing surfaces of the Compliance Hub. All are under /compliance/reports/compliance and answer 403 when the Compliance module is explicitly switched off on your licence. The policy rows are the exception: they also answer when Training is on your plan (see Policy Signing & People Evidence). The three registers return everything in one answer: none of them reads a paging parameter.
Worked example: Add a risk to the register
API token holding manage_compliance. A token call needs no Origin header. The values shown are made up; the field names and the shape are what the route sends.
curl -X POST -H "Authorization: Bearer $ZX_TOKEN" -H 'Content-Type: application/json' \
https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/compliance/risks \
-d '{ "title": "Laptop loss without disk encryption", "likelihood": 2, "impact": 4, "owner": "dana.levi@example.com" }'
# Reply
201
{
"id": "5b0c9e41a2d7f386",
"title": "Laptop loss without disk encryption",
"description": "",
"category": "General",
"likelihood": 2,
"impact": 4,
"score": 8,
"status": "open",
"treatment": "",
"owner": "dana.levi@example.com",
"controlIds": [],
"createdAt": "2026-10-10T08:15:02.117Z",
"updatedAt": "2026-10-10T08:15:02.117Z",
"createdBy": "dana.levi@example.com"
}
400 // no title
// The body carries the message "A risk title is required."
// An owner that is an email address is sent an email. Sending the call twice adds two risks.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /compliance/reports/compliance/policies | The policy register. returns 200 { policies: Policy[] } (typed under Response Objects), sorted by name, without the policy text. The register holds at most 200 policies. | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/policies | Add a policy (PUT or DELETE /policies/:id). body { name (required, up to 140 characters), version? (default '1.0', up to 20), owner? (up to 120), link? (http or https, up to 500), description? (up to 500), frameworks[]? (up to 8), requireAck?, audience?, signSections?, nextReviewDue? (yyyy-mm-dd), controlIds[]?, body? (the policy text, up to 40,000 characters) } returns 201 the Policy (typed under Response Objects), created as a draft | PUT 200 the Policy | DELETE 200 { deleted: true }. 400 "A policy name is required.", a link that is not a web address, or a full register | 404 "Policy not found". guard A new policy counts as evidence only after it is approved. Editing the name, version, text, link or mapped controls of an approved policy returns it to draft. Repeat: POST is not deduplicated. | Session + manage_compliance (licensed) |
| POST | /compliance/reports/compliance/policies/:id/ack | Sign a policy as yourself (your own list at /policies/ack/mine). See Policy Signing & People Evidence for the signing flow. body { signedName: string (2 characters or more), contentHash: string (the hash of the text you read), sections: string[] (the hash of every section, as confirmed) } returns 201 { policyId: string, name: string, version: string, contentHash: string, sections: integer, signedName: string, acknowledgedAt: timestamp, alreadyAcknowledged: boolean, renewal: boolean, kind?: 'first' | 'renewal' | 'text-changed', nameMatches?: boolean | null }. sections is the number of sections in the text you signed. kind and nameMatches are present only on a new signature; nameMatches is recorded, never enforced, and is null when no name is on file for you. 400 "Type your full name to sign." | 400 "Confirm every section before you sign ..." | 401 "No authenticated user." | 404 "That policy is not available for signing." | 409 "This policy changed while you were reading it. Reload and read the current text." guard Repeat: signing again while your signature is current answers 201 with alreadyAcknowledged: true and writes no new audit entry or email. A new signature is written to the audit log and confirmed to you by email. | Session (licensed) |
| GET | /compliance/reports/compliance/risks | The risk register. returns 200 { risks: Risk[] } (typed under Response Objects), highest score first, then most recently updated. guard Everything in one answer; no paging parameters are read. | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/risks | Add a risk (PUT or DELETE /risks/:id). body { title (required, up to 140 characters), description? (up to 1,000), category? (default 'General', up to 60), likelihood?, impact? (1 to 5, default 3; out-of-range values are clamped), status? (open | treating | accepted | closed), treatment? (up to 1,000), owner? (up to 120), controlIds[]? (catalogue ids, at most 40) } returns 201 the Risk (typed under Response Objects), with score = likelihood x impact | PUT 200 the Risk | DELETE 200 { deleted: true }. 400 "A risk title is required." or when the register already holds 500 risks | 404 "Risk not found". guard An owner that is an email address is told by email when first named. Repeat: POST is not deduplicated; PUT overwrites the fields you send. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/vendors | The vendor register. returns 200 { vendors: Vendor[] } (typed under Response Objects), sorted by name. Each carries its review state (reviewStatus, reviewer, riskDecision, nextReviewDue, documents) and its reviews[] history, at most 20 entries. | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/vendors | Add a vendor (PUT or DELETE /vendors/:id; reviews at /vendors/:id/request-review and /complete-review). body { name (required, up to 140 characters), category? (default 'SaaS'), tier? (low | medium | high | critical; default medium), owner? (up to 120), notes? (up to 500), soc2OnFile?, dataShared?, reviewedAt?, nextReviewDue? (yyyy-mm-dd; null clears) }. request-review: { reviewer?, dueDate? }. complete-review: { decision (approved | conditional | rejected, required), documents? { soc2, iso, pentest, dpa, questionnaire }, riskRating?, summary? (up to 1,000), nextReviewDue?, reviewer? } returns 201 the Vendor (typed under Response Objects) | PUT and both review calls 200 the Vendor | DELETE 200 { deleted: true }. 400 "A vendor name is required.", a missing decision, or a register that already holds 500 vendors | 404 "Vendor not found". guard A PUT that carries an updatedAt different from the stored one keeps the stored review fields (reviewStatus, reviewedAt, reviewer, riskDecision, nextReviewDue, documents), so a stale edit cannot undo a completed review. Repeat: POST is not deduplicated; each review call appends a history entry. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/soa | The Statement of Applicability. body Query ?framework= (required, a catalogue framework). returns 400 "A valid framework is required." without it. | Session + view_reporting | manage_compliance (licensed) |
| GET | /compliance/reports/compliance/type-two | How each control of one framework held over a period, counted from the automatic snapshots taken in that period. body Query ?framework= (required, a catalogue framework); ?days= (default 90, 1 to 400), or ?from= and ?to= together (YYYY-MM-DD), which take the place of ?days. returns 200 a TypeTwoReport (typed under Response Objects) | 400 "A valid framework is required." | Session + view_reporting | manage_compliance (licensed) |
| GET | /compliance/reports/compliance/environment | The environment profile: the systems your organisation has declared it uses. body No query parameters. returns 200 an EnvironmentProfile (typed under Response Objects); empty lists and null stamps before the first save. | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/environment | Replace the environment profile. body { selected?: string[] (duplicates dropped, at most 300 kept), custom?: [{ name (required, cut to 80 characters), id? (defaults to a slug of the name), category? (default 'Other', cut to 40 characters) }] (at most 100 kept) }. A list left out is saved as empty. returns 200 the EnvironmentProfile as saved guard Replaces the whole profile; safe to repeat. Audited as COMPLIANCE_ENVIRONMENT_UPDATED. | Session + manage_compliance (licensed) |
| POST | /compliance/reports/compliance/soa/seal | Seal the current Statement of Applicability. body { framework } (or ?framework=) returns 201 { runId, framework, sealed, generatedAt } | 400 "A valid framework is required." guard Download the sealed file through /compliance/reports/runs/:runId/artifact. Repeat: each call seals a new run. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/evidence | Evidence attached to controls. body Query ?controlId= (without it the list is empty), framework? returns 200 { items: EvidenceItem[] } (typed under Response Objects), newest first, at most 100 | 403 "You can only view evidence for a control assigned to you." | Session + view_reporting | manage_compliance | view_assigned_compliance (licensed) |
| POST | /compliance/reports/compliance/evidence | Attach evidence to a control (DELETE /evidence/:id removes it). body An EvidenceItemCreate: { controlId, url (both required), kind? ('file' for a file already uploaded to this server; anything else is a link, which must be http or https), label?, framework? } returns 201 the EvidenceItem (typed under Response Objects) | DELETE 200 { ok }. 400 for a missing control or url, a link that is not http(s), or a file this server did not store | 403 when the control is not assigned to you, or on DELETE when you did not attach it. guard A file's SHA-256 is computed by the server from the stored bytes; a value sent in the body is ignored. Repeat: POST is not deduplicated. | Session + manage_compliance | view_assigned_compliance (licensed) |
| GET | /compliance/reports/compliance/trust-center | Read the trust-center settings. returns 200 TrustCenterSettings (typed under Response Objects): { published: boolean, headline: string, intro: string, frameworks: string[], showReadiness: boolean, practices: [{ title: string, detail: string }], autoPractices: boolean, contactEmail: string, updatedAt: timestamp | null, updatedBy: string | null } | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/trust-center | Change the trust-center settings, including whether it is published. body { published, headline (up to 140 characters), intro (up to 1,000), frameworks[] (catalogue names, up to 8), showReadiness, practices[{ title (up to 80), detail (up to 240) }] (up to 20), autoPractices, contactEmail (up to 120) } returns 200 the TrustCenterSettings as saved. guard The body replaces the whole configuration: a field you leave out is saved as empty or false, and published is true only when sent as true. Repeat: safe. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/findings/:controlId/affected | The records behind one failing control: which accounts or audit-log entries its automated checks flagged. Query: ?framework (required, a framework of the catalogue), ?q (search, the first 200 characters are used), ?limit (default 50, 1–200) and ?cursor (send back nextCursor from the reply before to get the next page). body Query string, not a JSON body: ?framework, ?q, ?limit, ?cursor. returns 200 { framework, controlId, control (its title), datasets:string[], rows[], total:integer (rows matching the search), limit:integer, nextCursor, exactCount:integer, populationRows:integer, truncated:boolean, issues, fixLink?, asOf?, note? } — a control with no record-level check answers rows:[] with a note saying so and without fixLink and asOf; note is also present when truncated is true | 400 { error } for an unknown framework or a control id that is not 1 to 64 letters, digits, dots, hyphens or underscores | 403 { error } naming the permission needed, when the control's checks read user accounts or audit-log entries your permissions do not cover | 404 { error } when the control is not part of that framework guard read-only and safe to repeat; the list behind it is recomputed at most every 2 minutes per control, so two calls inside that window page the same list | Session + view_reporting | manage_compliance (licensed) |
| GET | /compliance/reports/compliance/findings/:controlId/affected.csv | The same list as a CSV file: every row that matches the search, not one page. Query: ?framework (required) and ?q. body Query string, not a JSON body: ?framework, ?q. returns 200 text/csv as an attachment, with the columns Identity, Type, Why it is affected, Detail, Severity, Status, When, Where to fix; a closing Note line is added when the list is a sample of a larger population | 400, 403 and 404 as the paged read guard not paged: every matching row in one file. Each download is written to the audit log as COMPLIANCE_FINDING_AFFECTED_EXPORTED | Session + view_reporting | manage_compliance (licensed) |
| POST | /compliance/reports/compliance/auditor-shares | Create an auditor workspace link (list with GET; revoke with DELETE /auditor-shares/:id). body { framework (required, a catalogue framework), ttlDays? (default 30; 1 to 180), label? (up to 80 characters) } returns 200 an AuditorShare (typed under Response Objects) plus token: string. The token is returned this once. GET 200 { shares: AuditorShare[] }, newest first; the GET list needs view_reporting or manage_compliance. DELETE 200 { revoked: true } | 404 "Share not found". 400 "A valid framework is required." guard Repeat: each POST mints another link; revoking twice answers the same. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/auditor-requests | Evidence requests auditors have raised (resolve at /auditor-requests/:id/resolve, manage_compliance). body Query status? (open | resolved), framework?. Resolve: { response? (up to 500 characters) } returns 200 { requests: AuditorRequest[] } (typed under Response Objects), newest first. Resolve 200 the AuditorRequest | 404 "Request not found" | 409 "This request was already resolved." with respondedBy and respondedAt. guard Repeat: the first answer stands; a second resolve changes nothing and answers 409. The list is not paged: every request matching the status and framework filters comes back in one answer. | Session + view_reporting | manage_compliance (licensed) |
Public Share Links
api-gateway → compliance-service
Links you hand to someone outside the company. They carry no session: the token in the path is the credential, and the routes are rate-limited. A token must be 64 lowercase hex characters; anything else answers 404 at the gateway. Each view of a report link or an auditor link, and each refusal of a well-formed token, is written to the audit log.
Worked example: Check that a report file is the sealed one
Public: no session or token. The run id is the one printed on the report; sha256 is the hash of the file you hold. The values shown are made up; the field names and the shape are what the route sends.
curl "https://<your-instance>-api.zonitix.com/api/v1/shared/verify/418?sha256=<64-hex-sha256-of-the-file>"
# Reply
200 // the file matches
{
"match": true,
"runId": 418,
"framework": "SOC 2",
"observationWindow": { "from": null, "to": null },
"generatedAt": "2026-10-04T06:00:05.000Z"
}
// A PDF that matches also carries "signature".
200 // no match: an altered file and a wrong run id answer the same
{ "match": false, "runId": 418, "message": "This file does not match a sealed record for that run. It may have been altered, or the run id is wrong." }
400 // sha256 missing or not 64 hex characters
{ "error": "Give the run id printed on the report and the file's SHA-256 (64 hex characters)." }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /shared/trust | Your public trust center. Answers 404 until you publish it. returns 200 an HTML page, sent with Cache-Control: no-store | 404 "No trust center is published." | Public |
| GET | /shared/:token | A sealed report, by share link. body No query is read. The link serves the one format chosen when it was created (pdf or csv). returns 200 the sealed file inline, with headers X-Artifact-SHA256 and X-Artifact-Integrity (verified | unsealed) | 404 "This link is not valid." | 410 "This link has expired or been revoked." | 409 when the stored file fails its integrity check | 503 when the link's creator cannot be checked right now. guard The answer never says whether a link expired or was revoked. A link to a run that needs a permission stops working (410) once the person who created it no longer holds that permission. | Share-link token |
| GET | /shared/verify/:runId | Verify that a sealed report is genuine. body Query ?sha256= (required: the file's SHA-256, 64 hex characters). returns 200 { match: true, runId: integer, framework: string | null, observationWindow: { from: timestamp | null, to: timestamp | null }, generatedAt: timestamp, signature?: object (PDF only) } | 200 { match: false, runId: integer, message: string } | 400 without a valid sha256 | 404 for a run id that is not a number. guard A wrong run id and an altered file give the same match: false answer. | Public (rate-limited) |
| GET | /shared/auditor/:token | The auditor workspace, by link. returns 200 a read-only HTML page for the link's one framework | 404 "This link is not valid." | 410 "This link has expired or been revoked." | Auditor-link token |
| POST | /shared/auditor/:token/requests | An auditor raises an evidence request. body An AuditorEvidenceRequestCreate: { controlId (required, a control of the link's framework), note (required, up to 500 characters) } returns 201 { ok: true, id: string } | 400 "Unknown control for this workspace." or "A note is required." | 404 | 410 | 429 "Too many open requests on this link." guard Repeat: not deduplicated; each call opens another request until the link's open-request limit answers 429. | Auditor-link token |
Backups, Restore & Release Channel
compliance-service
The backup inventory and the two-person restore and delete flows. Reads need view_system; these routes are never licence-gated. Updates, database restores, backup deletions and file restores share one approval slot: while one request is waiting or running, another is refused with 409.
Worked example: List the backups
API token holding view_system. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/backups
# Reply
200 // a bare array
[
{
"filename": "zonitix_<timestamp>.sql.gz",
"type": "dump",
"restorable": true,
"sizeBytes": 48211934,
"modifiedAt": "2026-10-10T02:00:41.000Z",
"pgVersion": "<version the dump was taken from>",
"serverPgMajor": 18,
"versionCompatible": true,
"local": true,
"offbox": true,
"offboxMissing": false,
"previousDestination": false,
"destinationKey": "<object key in your storage>",
"offboxInfo": { "provider": "<storage provider>", "bucket": "<your bucket>", "region": "<region>", "key": "<object key in your storage>" }
}
]
// offbox: false with offboxInfo: null means the only copy is on this server.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /compliance/reports/backups | List backups. body Query ?recheck=1 re-checks off-server copies instead of using the cached answer. returns 200 BackupPoint[] (typed under Response Objects): a bare array, newest first; no paging. fetch: true marks a dump that is copied back from your storage before it is restored. | Session + view_system |
| GET | /compliance/reports/backup-audit | The backup audit trail. returns 200 BackupAuditEntry[] (typed under Response Objects): a bare array of the newest 40 entries. No paging parameters are read. | Session + view_system |
| GET | /compliance/reports/wal-archive | Write-ahead-log archive status. body Query ?list=1 for the segments, with limit (default 50, maximum 200) and offset (default 0). returns 200 a WalArchiveSummary (typed under Response Objects) for the summary. With list=1: 200 { count: integer, segments: WalSegment[] }, newest upload first. | Session + view_system |
| POST | /compliance/reports/restore-request | Request a database restore. A different approver must approve it at /restore-approve. body { target } required: a backup file name of the form zonitix_*.sql.gz, with no path. /restore-approve takes { target? } to name the request you are looking at. returns 202 { status: 'pending_approval', request { mode: 'restore', target, requestedBy, requestedAt } } | 400 for a bad file name or a backup taken on a different PostgreSQL major version | 409 "A request is already awaiting approval." or "An operation is already queued or in progress." | 503 when the request could not be queued. Approve: 200 { approved: true, request } | 403 when you are the requester | 404 "No request is awaiting approval." | 409 when the waiting request is not a restore, or is for a different target. guard Repeat: a second request is refused with 409 until the first is approved, rejected or cancelled. | Session + approve_system_updates |
| POST | /compliance/reports/delete-request | Request deletion of a backup. A different approver must approve it at /delete-approve. body { target } required: zonitix_*.sql.gz or zonitix_*.tar.gz, with no path. /delete-approve takes { target? }. returns 202 { status: 'pending_approval', request { mode: 'delete-backup', target, requestedBy, requestedAt } } | 400 | 409 | 503 as for a restore request. Approve: 200 { approved: true, request, offbox { deletedObject, key, deleteFailed?, error? } } | 403 when you are the requester | 404 | 409. guard offbox.deleteFailed: true means the copy on your own storage is still there although the local file was removed. Repeat: refused with 409 while a request is waiting. | Session + approve_system_updates |
| POST | /compliance/reports/restore-drill-schedule | Set the restore-drill schedule. body { enabled?, everyHours? }. A field you leave out keeps its stored value. returns 200 the saved { enabled, everyHours } together with the current restore-test status. guard Written to the audit log. Repeat: safe; the call sets values. | Session + approve_system_updates |
| POST | /compliance/reports/update-cancel | Cancel a pending update request. body No body. returns 200 { cancelled: true, request } | 403 "Only the administrator who requested this can cancel it" | 404 "No request is awaiting approval." guard No body. Repeat: the second call answers 404, because nothing is waiting any more. | Session + request_system_updates | approve_system_updates |
| GET | /compliance/reports/release-channel | Read the release channel. returns 200 { channel: 'stable' | 'main', channels: string[], default: string, ... }. | Session + view_system |
| PUT | /compliance/reports/release-channel | Change the release channel. body { channel: 'stable' | 'main' } returns 200 the object the GET answers plus changed: boolean and previous: string | 400 "Release channel must be one of: stable, main." | 503 when the update control directory is not writable. guard Needs a fresh re-authentication. Repeat: safe; setting the channel it already has answers changed: false and writes no audit entry. | Session + approve_system_updates + sudo |
Company Data Export
compliance-service
A copy of everything on the instance, built in the background as one ZIP and held encrypted on the instance. Every route needs the administrator role itself, not a permission, so a personal API token cannot call any of them. The on-screen steps are in the guide Export all your company data. Not plan-gated.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /compliance/data-export/jobs | List the most recent export jobs, with the limits in force. body No query parameters. returns 200 { jobs: TenantExportJob[], limits: { maxPerDay: 3, artifactTtlHours: 24, linkTtlMinutes: 15 } } — TenantExportJob is typed under Response Objects; newest first, at most 20 | Session + administrator role |
| GET | /compliance/data-export/jobs/:id | One job, for polling its progress. body No query parameters. :id is a whole number. returns 200 a TenantExportJob | 404 { error:'Export not found', code:null } | Session + administrator role |
| POST | /compliance/data-export/jobs | Start an export. It runs in the background. body No body. returns 202 a TenantExportJob, newly created with status queued | 409 code EXPORT_IN_PROGRESS when one is queued or running | 429 code EXPORT_RATE_LIMITED after 3 in 24 hours | 503 code NO_ENCRYPTION_KEY when the instance has no key to encrypt the file with guard One at a time. Failed jobs do not count toward the 3 in 24 hours. Audited as TENANT_EXPORT_REQUESTED, then TENANT_EXPORT_READY or TENANT_EXPORT_FAILED. Not safe to repeat blindly: each accepted call builds another export. | Session + administrator role + sudo |
| POST | /compliance/data-export/jobs/:id/link | Mint a single-use download link for a ready export. body No body. returns 200 { token: string (64 hex characters), linkExpiresAt: string (date-time), path: string (/compliance/data-export/download/<token>) } | 404 'Export not found' | 410 code EXPORT_EXPIRED when the job is not ready or its file has expired guard The link lasts 15 minutes, or until the file expires if that is sooner, works once, and only for the administrator who minted it. Minting again replaces the previous link. Audited as TENANT_EXPORT_LINK_ISSUED. | Session + administrator role + sudo |
| GET | /compliance/data-export/download/:token | Download the ZIP. The link is used up by the call. body No query parameters. :token is the 64 hex characters from the link call. returns 200 the ZIP (application/zip, Content-Disposition attachment, Content-Length, X-Content-SHA256 = the SHA-256 of the ZIP, Cache-Control no-store) | 410 code LINK_USED_OR_EXPIRED when the link was already used, has expired or was minted by someone else | 410 code EXPORT_EXPIRED when the file is no longer on the server guard Single use: a second call with the same token answers 410. Audited as TENANT_EXPORT_DOWNLOADED; a refused call as TENANT_EXPORT_DOWNLOAD_DENIED. If the stream is cut short the download is incomplete: compare the file's SHA-256 with the header. | Session + administrator role |
Security Lab: Schedules, Issues, Exceptions & Attack Paths
security-lab-service
The rest of the Security Lab surface. Each row names the permissions its route guard accepts: holding either one is enough. Rows marked (licensed) answer 403 unless the Security Lab entitlement is on; the other rows answer 403 only when the entitlement is explicitly switched off on your licence.
Worked example: Count the open issues by severity
Session cookie of a user holding view_security_findings or manage_compliance. The values shown are made up; the field names and the shape are what the route sends.
curl --cookie '__Host-token=<session-jwt>' "https://<your-instance>-api.zonitix.com/api/v1/auth/security-lab/issues/summary?status=open"
# Reply
200
{ "total": 14, "kevOpen": 1, "bySeverity": { "Critical": 1, "High": 4, "Medium": 6, "Low": 3 } }
// A severity with no issues is left out of bySeverity, not sent as 0.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /auth/security-lab/scan-schedule | Read the recurring scan schedule. returns 200 a ScanSchedule (typed under Response Objects). nextDueAt is null when the schedule is off, has never run, or is already due. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/scan-schedule | Set the recurring scan schedule. body Any of { enabled, intervalHours (whole number 1 to 168), reportDeliver, reportRecipients (comma-separated), externalEnabled, externalIntervalHours (1 to 168), trivyImages, slaAlertEnabled, slaAlertPermission }; at least one is required. returns 200 the same shape as the GET | 400 "Provide at least one schedule field to update." or an interval outside 1 to 168. guard Audit-logged. Repeat: safe; only the fields you send are set. | Session + manage_security_scopes | manage_compliance (licensed) |
| GET | /auth/security-lab/probe-catalog | The probes the Lab can run. returns 200 { probes[{ id, category, intensity, remoteOk, localOk, heavy, pathBound, endpoint, cwe[], budgetMs }] } guard The whole catalogue in one answer; no paging. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/scans/active | The scan that is queued or running now, if any. returns 200 { scans[] }: empty, or the one live scan with currentProbe and stopping. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/scans/:id/verify | Re-check a finished scan's sealed report against its stored hash and audit-log entry. returns 200 the verification result when the scan has a sealed report | 404 with the same result object (sealed: false) when it has none. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/trends | Posture over time (the Trends view). returns 200 { postureHistory[{ at, score, crit, high }], mttr { avgDays, medianDays, fixedCount }, openBySeverity, openTotal, slaOverdue }. postureHistory is the newest 120 completed scans, oldest first; no parameters are read. | Session + view_security_findings | manage_compliance |
| PATCH | /auth/security-lab/scopes/:id | Edit a scan scope; needs a fresh re-authentication (401 SUDO_REQUIRED), and changing the hosts clears its ownership proof. DELETE disables it; POST /scopes/:id/verify runs its verification. body { name?, description?, maxProfile?, ownerAttestation?, enabled?, allowedCidrs?, allowedHosts?, roe?, testEndpoints? }: only the fields sent are changed. roe with a scan-window start needs the end too, both as HH:MM (24-hour). returns 200 the scope | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up | 404 "Scope not found". | Session + manage_security_scopes | manage_compliance + sudo |
| DELETE | /auth/security-lab/scopes/:id | Disable a scan scope. body No body. returns 200 the scope | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up (this route passes the status through, so it stays 401) | 404 "Scope not found". guard Needs a fresh step-up; written to the audit log as SECURITY_SCOPE_DISABLED. | Session + manage_security_scopes | manage_compliance + sudo |
| POST | /auth/security-lab/scopes/:id/verify | Run the ownership check for a scope's hosts. body No body. returns 200 { ok: true, verifiedHosts, unprovenHosts, ... } | 400 with the same result object when ok is false. | Session + manage_security_scopes | manage_compliance |
| GET | /auth/security-lab/issues | Findings grouped into issues (totals at /issues/summary, one at /issues/:id). body Query: severity, status, kev, target ('remote' = everything not local), search (title, CVE id or package name; first 200 characters), includeArchived (1 | true), limit (default 50, maximum 200), offset (default 0), sort (severity | cvss | risk | epss | sla | age | status | title | target | owner), dir (asc; anything else is descending). returns 200 { issues: SecurityIssue[], total: integer, limit: integer, offset: integer } (typed under Response Objects). Default order: CVSS score highest first, then most recently seen. /issues/summary takes the same filters and answers { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. /issues/:id answers { issue: SecurityIssue } | 404 "Issue not found". | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/issues/summary | Issue totals for the same filters as GET /auth/security-lab/issues. body Query: the filters of GET /auth/security-lab/issues (severity, status, kev, target, search, includeArchived). returns 200 { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. One object, not a list. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/issues/:id | One issue. returns 200 { issue: SecurityIssue } (typed under Response Objects) | 404 "Issue not found". | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/findings/:id/issues | The issues one finding was grouped into. returns 200 { findingId, issues: SecurityIssue[], total: integer } | 404 "Finding not found". Everything in one answer; no paging parameters are read. | Session + view_security_findings | manage_compliance |
| PATCH | /auth/security-lab/issues/:id | Update an issue. body Any of { status (open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive), statusNote, owner, ticketRef, acceptedUntil } returns 200 the issue; when the change opened a risk exception, exception { id, status, acceptedUntil, requestedBy } is added | 400 "Invalid status" (verified_fixed is reached only by a clean re-scan) or JUSTIFICATION_REQUIRED when accepting risk or marking a false positive without a statusNote | 400 EXPIRY_REQUIRED when accepting risk without a future acceptedUntil | 404 "Issue not found" | 409 EXCEPTION_PENDING when a risk acceptance for it is already waiting for a second approver. guard status: risk_accepted opens a risk exception under the same rules as POST /findings/:id/exception. | Session + manage_security_findings | manage_compliance |
| POST | /auth/security-lab/findings/:id/exception | Accept the risk of a finding, with an expiry. Body: justification, acceptedUntil (a future date). 201 with the exception: active at once for Low and Medium, pending for Critical and High. 400 EXPIRY_REQUIRED or JUSTIFICATION_REQUIRED; 409 EXCEPTION_EXISTS if one is already active or pending. body A SecurityExceptionRequest: { justification (required), acceptedUntil (required: a date in the future) } returns 201 { id: uuid, findingId: uuid | null, issueId: uuid | null, probe: string, target: string, severity: string, status: 'active' | 'pending', justification: string, requestedBy: string | null, approvedBy: string | null, acceptedAt: timestamp | null, acceptedUntil: timestamp, history: [{ at: timestamp, action: string, actor: string | null, from: string | null, to: string, note: string }] }. status is 'pending' and acceptedAt is null for a Critical or High finding until someone else approves it. Errors are { error: string, code?: string }: 400 JUSTIFICATION_REQUIRED | 400 EXPIRY_REQUIRED (missing, not a date, or not in the future) | 404 "Finding not found." | 409 EXCEPTION_EXISTS. The record also carries expiredAt, revokedAt and revokedBy, which are unset on a new exception. guard Repeat: 409 code EXCEPTION_EXISTS; a finding with a pending or active exception cannot get a second one until that one is revoked | Session + manage_security_findings | manage_compliance (licensed) |
| GET | /auth/security-lab/exceptions | List risk exceptions (one finding's full timeline at /findings/:id/exceptions). body Query findingId?, issueId?, includeArchived? (1 | true: include revoked ones). returns 200 { exceptions[], ungoverned: { issues: integer, findings: integer } | null }. Each exception carries subject: { kind: 'issue', id, title, cveId, status } or { kind: 'finding', id }; title, cveId and status are null when the issue could not be loaded. ungoverned counts older risk acceptances that have no expiry and no approver, and is null when the count could not be made. No paging parameters are read. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/findings/:id/exceptions | Every risk exception ever opened for one finding (its timeline). returns 200 { exceptions[] }. Everything in one answer; no paging parameters are read. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/exceptions/:id/approve | Approve a pending Critical or High exception; 403 SOD_SELF_APPROVAL if you requested it. Revoke at /exceptions/:id/revoke: the finding reopens. body No body. Revoke takes { note? } returns 200 the exception, for both calls. Errors are { error: string, code?: string }: 404 "Exception not found." | 409 when the exception is not pending (approve) or not active or pending (revoke) | approve only: 403 SOD_SELF_APPROVAL | 409 SUBJECT_NOT_OPEN when the finding or issue is already verified fixed or marked a false positive. guard Repeat: 409; once approved, the exception is no longer pending | Session + manage_security_findings | manage_compliance (licensed) |
| POST | /auth/security-lab/exceptions/:id/revoke | Revoke a risk exception; the finding reopens. body A SecurityExceptionRevoke: { note? } returns 200 the exception | 404 "Exception not found." | 409 when the exception is not active or pending. Errors are { error: string, code?: string }. guard Repeat: 409; a revoked exception is neither active nor pending | Session + manage_security_findings | manage_compliance (licensed) |
| POST | /auth/security-lab/findings/archive | Archive findings and their issues; nothing is deleted. Optional scope: all (default), remote or local. Needs a fresh re-authentication (401 SUDO_REQUIRED). Audit-logged. body A FindingsArchive: { scope? } (or ?scope=): all (default), remote or local returns 200 { archived, archivedIssues, scope } | 400 for any other scope value | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up. | Session + manage_security_findings | manage_compliance + sudo |
| DELETE | /auth/security-lab/findings | Retired: always answers 410 Gone. Use POST /findings/archive. body No body. returns 410 { error: "Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept." }. Nothing is changed. guard Repeat: every call answers the same 410 and changes nothing | Session + manage_security_findings | manage_compliance |
| POST | /auth/security-lab/host-scan | Match the software inventory of your enrolled devices against the CVE database. Writes host findings; audit-logged. body No body. returns 200 a summary: ok, devices, scanned, vulnerable, totalCves, kevHits. guard Repeat: each call scans the fleet again and writes another SECURITY_HOST_SCAN audit entry | Session + manage_security_findings | manage_compliance (licensed) |
| POST | /auth/security-lab/sbom-scan | Scan an uploaded software bill of materials. body { sbom } as a JSON string or object in CycloneDX or SPDX form, or the document itself as the whole body. returns 200 { label, kind, components, cveCount, severity, kevHits, ... } | 400 with the reason when no document was sent or it could not be read. | Session + manage_security_findings | manage_compliance (licensed) |
| GET | /auth/security-lab/cve-intel | Vulnerability intelligence status (refresh at /cve-intel/refresh). returns 200 { total: integer, kevListed: integer, lastRefreshedAt: timestamp | null }. lastRefreshedAt is null when it has never been refreshed. POST /cve-intel/refresh (manage_security_findings | manage_compliance, licensed) answers 200 with a refresh summary. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/cve-intel/refresh | Refresh the vulnerability intelligence now. The call waits for the refresh to finish. body No body. returns 200 a refresh summary object: ok, plus kev, epss and enrichment, one entry per source refreshed. guard Audit-logged as SECURITY_CVE_INTEL_REFRESH, at warning level when the summary's ok is false. | Session + manage_security_findings | manage_compliance (licensed) |
| GET | /auth/security-lab/intel-status | Freshness of the vulnerability intelligence (what the Intel fresh / Intel stale chip shows). returns 200 { source: string, stampedAt: timestamp | null, importedAt: timestamp | null, ageHours: number | null, staleHours: integer, stale: boolean, total: integer, kevCount: integer, epssCount: integer, trivyDbVersion: string | null, trivyDbAt: timestamp | null, trivyDbAgeHours: number | null, dbUpdatedAt: timestamp | null, dbAgeDays: integer | null, kevUpdatedAt: timestamp | null, epssUpdatedAt: timestamp | null, fresh: boolean }. staleHours is 168 unless changed. stale is true when the intelligence is older than staleHours, or when its age is not known; fresh is true only when stale is false. source is 'none' when there is no intelligence at all. This read does not fail: when the status cannot be read it answers stale: true with zero counts. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/intel-import | Load a vulnerability-intelligence bundle on an instance that cannot reach the feeds. body { bundle }: the bundle as an object or string, or { bundle: { path } } naming a file on the server. returns 200 { kevImported, epssImported, asOf, signatureVerified, enrichment, ... } | 400 with the reason when no bundle was sent or its check failed. guard A bundle that fails validation, its checksum or its signature imports nothing; the rejection is written to the audit log. | Session + manage_security_findings | manage_compliance (licensed) |
| GET | /auth/security-lab/analyst-summary | The AI analyst summary on the Findings view: figures computed by code, narrative drafted by the local model. returns 200 { bundle: { generatedAt: timestamp, unit: 'issue' | 'finding', posture: number | null, totalCount: integer, openCount: integer, acceptedCount: integer, countsBySeverity: { [severity]: integer }, kevCount: integer, kev: [{ cveId: string | null, severity: string, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, epssScore: number | null }], slaBreachCount: integer, slaBreaches: [{ ref: string, severity: string, target: string, slaDueAt: timestamp, overdueDays: integer }], topRiskAssets: [{ target: string, issueCount: integer, kevCount: integer, maxSeverity: string, riskScore: integer }], toxicComboCount: integer, toxicCombos: [{ label: string, severity: string, hops: integer | null }] }, prose: string | null, provenance: { aiUsed: boolean, factCount: integer, model: string | null, stamp: string | null, reason: string | null } }. bundle is computed by code and is always present. prose is null when the AI assistant is switched off (reason 'ai_disabled'), when your instance requires use_ai and you do not hold it ('use_ai_required'), when the model returned nothing ('empty_response') or could not be reached ('ai_unavailable'). topRiskAssets holds at most 10. Each read is written to the audit log. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/compliance-evidence | Scan-cadence evidence per scope, as passed to the Compliance Hub. returns 200 { available: boolean, generatedAt: timestamp, pciWindowDays: 90, controls: object[], scopes: [{ scope: string, hosts: string[], verified: boolean, controls: string[], cadence: { withinWindow: boolean, daysSinceLastScan: integer | null, dueBy: timestamp | null, overdue: boolean, lastExternalScan: { scanId: string, timestamp: timestamp, status: string, kevOpen: integer, snapshotHash: string | null, snapshotSealed: boolean, controls: string[] } | null } }], summary: { verifiedScopes: integer, scopesScannedInWindow: integer, scopesOverdue: integer, totalExternalScans: integer } | null }. controls has two entries, CC7.1 and PCI-11.3, each with id, framework, title and note; CC7.1 adds satisfied: boolean and evidenceCount: integer, PCI-11.3 adds quarterlyCadenceMet: boolean, verifiedScopes, scannedInWindow and overdue. When the evidence store cannot be read the answer is still 200, with available: false, reason: 'evidence-store-unavailable', empty scopes and controls, and summary: null. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/kev-exposure | Exposure to known-exploited vulnerabilities. body Query limit (default 100, maximum 200), offset (default 0), includeArchived (1 | true), verdict (exposed | patched | unconfirmed). returns 200 { items: [{ id: uuid, findingId: uuid, cveId: string | null, title: string | null, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, severity: string, cvssScore: number | null, epssScore: number | null, status: string, kevListed: true, confirmed: boolean, confirmationVerdict: 'exposed' | 'patched' | 'unconfirmed', confirmationMethod: 'version-evidence', confirmationLabel: string }], total: integer, limit: integer, offset: integer, counts: { total: integer, exposed: integer, patched: integer, unconfirmed: integer, confirmedExposed: integer } | null, method: 'version-evidence' }. total counts every known-exploited issue before the verdict filter. counts covers the whole set, not the page, and is null when it cannot be read. The verdict compares the installed version with the fixed version; nothing is exploited. guard Ordered by CVSS score, highest first, then most recently seen. The verdict filter is applied after the page is read, so a filtered page can hold fewer rows than limit. | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/attack-paths | Attack paths across your assets (one at /attack-paths/:id). body Query ?includeArchived=true returns 200 { items[], total, counts { toxic, inconclusive } }, highest CVSS first; no paging. /attack-paths/:id adds chain[], issues[] and mitigation | 404 "Attack path not found". | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/attack-paths/:id | One attack path in full. returns 200 the attack path with chain[], issues[] and mitigation | 404 "Attack path not found". | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/unified-assets | The combined asset inventory (one at /unified-assets/:id). returns 200 { assets[], total }, ranked by composite risk; everything in one answer, no paging. /unified-assets/:id answers the asset | 404 "Asset not found". | Session + view_security_findings | manage_compliance |
| GET | /auth/security-lab/unified-assets/:id | One asset from the combined inventory. returns 200 the asset | 404 "Asset not found". | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/assets/discover | Run hostname discovery under your verified domains now (the Discover now button). One discovered asset at GET /assets/:id. body No body. returns 200 { ok: true, ... } with the counts of what the run found. guard Discovery only adds to the inventory; it never authorises a scan. Repeat: each call runs discovery again | Session + manage_security_scopes | manage_compliance (licensed) |
| GET | /auth/security-lab/assets/:id | One discovered asset. returns 200 the asset | 404 "Asset not found". | Session + view_security_findings | manage_compliance |
| PATCH | /auth/security-lab/assets/:id/criticality | Set how critical an asset is; it feeds the risk score. Audit-logged. body An AssetCriticalityUpdate: { criticality: 'low' | 'medium' | 'high' | 'crit' } ('critical' is accepted and stored as 'crit') returns 200 { id, name, criticality } | 400 for any other value | 404 "Asset not found". guard Repeat: safe; the call sets a value. | Session + manage_security_scopes | manage_compliance |
| GET | /auth/security-lab/remediations | Remediation work items. body Query status?, findingId?, target?, limit (default 100, maximum 500), offset (default 0). returns 200 { remediations: SecurityRemediation[], total: integer, limit: integer, offset: integer } (typed under Response Objects), newest first. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/findings/:id/remediation | Open a remediation for a finding (hand off at /remediations/:id/handoff, retest at /remediations/retest). body Propose: none. Handoff: { handoffRef? (up to 255 characters) } returns 201 { proposed: true, reason: 'proposed', remediation } | 200 { proposed: false, reason, remediation? } when nothing new was opened | 404 "Finding not found". Handoff: 200 the remediation | 400 "Cannot hand off" with code | 404. Retest: 200 { ok: true, ... }. guard Repeat: a finding that already has an open remediation gets that one back with 200 (reason 'already_open'), not a second one. | Session + manage_security_findings | manage_compliance (licensed) |
| POST | /auth/security-lab/remediations/:id/handoff | Mark a remediation as handed off, with an optional reference to where the work is tracked. body A RemediationHandoff: { handoffRef? (up to 255 characters) } returns 200 the SecurityRemediation | 400 "Cannot hand off (<reason>)." with the reason as code | 404 "Remediation not found". | Session + manage_security_findings | manage_compliance (licensed) |
| POST | /auth/security-lab/remediations/retest | Re-test the remediations that are waiting for a re-test, now. body No body is read. returns 200 { ok: true, ... } with the counts of the sweep. | Session + manage_security_findings | manage_compliance (licensed) |
| GET | /auth/security-lab/ticketing | Read the ticketing integration settings (POST to change them: manage_security_scopes | manage_compliance). body POST: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off. returns 200 { providerId, projectKey, providers[{ id, name, type, isActive }] }. POST 200 { ok: true }. guard Repeat: safe; POST sets both values. | Session + view_security_findings | manage_compliance |
| POST | /auth/security-lab/ticketing | Change the ticketing integration settings. body A TicketingUpdate: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off. returns 200 { ok: true }. guard Repeat: safe; the call sets both values. | Session + manage_security_scopes | manage_compliance |
Employee Files, Pay History, Documents & Payroll Hand-off
user-management-service
The employee file is on every plan. Rows marked (licensed) need the HR module (the hris entitlement, Team and Advanced) and answer 403 when it is explicitly switched off on your licence. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page. The gateway in front of the service adds answers of its own, each { error:string }: 401 "No token provided" with no session or token, 503 "Service temporarily unavailable" while the service behind it keeps failing, and 504 "Gateway Timeout" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.
Worked example: The first employee file in the directory
API token holding view_employee_records. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" "https://<your-instance>-api.zonitix.com/api/v1/user-management/employees?limit=1&offset=0"
# Reply
200
{
"total": 148,
"employees": [
{
"id": 12,
"firstName": "Dana",
"lastName": "Levi",
"email": "dana.levi@example.com",
"position": "Finance Manager",
"department": "Finance",
"managerId": 4,
"status": "active",
"employeeId": "E-0012",
"photoUrl": null,
"terminationDate": null,
"name": "Dana Levi",
"managerName": "Noa Stern",
"hireDate": "2023-03-01",
"employmentType": "Full-time",
"workLocation": "Tel Aviv",
"level": null,
"reminders": [
{ "kind": "contract", "label": "Contract ends", "date": "2026-11-30", "days": 51, "overdue": false }
]
}
]
}
// total counts every matching file; page with limit (1 to 200) and offset.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/employees | The directory of employee files (one file at /employees/:userId). body Query: search (every word typed must be found in the first name, last name, email, job title or employee id; first 8 words), status, department, limit (default 50, 1 to 200), offset (default 0). returns 200 { total:integer (every match, not the page), employees:[ EmployeeListItem ] }, ordered by last name, then first name. One file (/employees/:userId): the EmployeeFile object { user:EmployeeFileUser, profile:EmployeeProfile, emergencyContacts:[ EmergencyContact ], history:[ EmploymentEvent ] (newest 200 job changes), reminders:[ EmployeeReminder ], authority:HrAuthority, people:object } | 400 "Invalid employee id." | 404 "Employee not found." guard Date of birth and home address are never in a list or a file: only hasDateOfBirth and hasHomeAddress. | Session + view_employee_records | manage_employee_records |
| PUT | /user-management/employees/:userId | Edit a file's employment and personal details. body The EmployeeProfileUpdate object. Any of: preferredName, legalName, nationality, costCenter, legalEntity, noticePeriod (text, up to 255 characters), rightToWorkStatus (verified | pending | expired | not_required), hireDate, originalHireDate, probationEndDate, contractEndDate, rightToWorkVerifiedAt, rightToWorkExpiresAt (YYYY-MM-DD; null clears), dateOfBirth (YYYY-MM-DD), homeAddress (up to 1,000 characters). returns 200 the EmployeeFile object | 400 "Nothing to update.", a date that is not a real calendar day, or an unknown rightToWorkStatus | 404 "Employee not found." guard Job title, department, manager, level, employment type and work location are ignored here: they change only through a job change. A field owned by the connected HR system is refused. Repeat: safe; the call sets values. | Session + manage_employee_records |
| POST | /user-management/employees/:userId/reveal | Reveal one sealed value on a file, such as date of birth or home address. A POST because each reveal is written to the audit log. body The SensitiveFieldReveal object: { field: 'dateOfBirth' | 'homeAddress' } returns 200 { field:string, value:string|null } (value is null when nothing is stored) | 400 for any other field | 404 "Employee not found." guard Repeat: safe; it is a read, and every call is written to the audit log as EMPLOYEE_SENSITIVE_FIELD_VIEWED | Session + view_employee_records | manage_employee_records |
| PUT | /user-management/employees/:userId/emergency-contacts | Replace a person's emergency contacts. body { contacts:[ EmergencyContactInput ] }: each { name:string (required), relationship?:string, phone?:string (up to 40 characters) }, at most 5, kept in the order sent. returns 200 the EmployeeFile object | 400 "contacts must be a list.", "At most 5 emergency contacts." or "Each emergency contact needs a name." guard Repeat: safe; the list sent replaces the stored list. | Session + manage_employee_records |
| POST | /user-management/employees/:userId/job-changes | Record a job change: title, department, manager, level or location (cancel one with DELETE /job-changes/:eventId). body The JobChangeCreate object: { type (required: promotion | transfer | manager_change | title_change | other), effectiveDate? (YYYY-MM-DD; default today), changes (at least one of position, department, managerId, level, employmentType, workLocation), reason? (up to 1,000 characters) } returns 201 the EmployeeFile object | 400 for an unknown type, a manager that does not exist, a change that would make the reporting line circular, or "Nothing changes — every field already has that value." DELETE /job-changes/:eventId: 200 the EmployeeFile object | 400 "Only a scheduled change can be cancelled." | 404 "Job change not found." guard A date in the future is stored as scheduled and applied on that day. Repeat: an immediate change sent twice is refused the second time (400, nothing changes); a scheduled change sent twice is stored twice. | Session + manage_employee_records |
| DELETE | /user-management/employees/:userId/job-changes/:eventId | Cancel a job change that is scheduled for a later day. body No body. eventId is the id of the scheduled change in the employee file. returns 200 the EmployeeFile object | 400 "Only a scheduled change can be cancelled." | 404 "Job change not found." or "Employee not found." guard Repeat: a change that is already cancelled answers 400. The cancellation is written to the audit log. | Session + manage_employee_records |
| GET | /user-management/employees/:userId/departure | The leaving record and HR's exit checklist (save with PUT, manage_employee_records). body PUT: any of { lastDay (YYYY-MM-DD), departureType (voluntary | involuntary | end_of_contract | null), reasonCode (one of the keys in reasons[], or null), rehireEligible (true | false | null), checklist { <key>: true | false } } returns 200 { departure:DepartureRecord|null (null until one is recorded), directReports:integer, types:[{ key:string, label:string }], reasons:[{ key:string, label:string, type:string|null (the departure type the reason belongs to) }] }. PUT answers the same shape | 400 "Set the last day first.", "Unknown departure type.", "Unknown reason." guard Repeat: safe; a person has one open leaving record and PUT updates it in place. | Session + view_employee_records | manage_employee_records |
| GET | /user-management/employees/me | Your own file (a summary at /me/summary). returns 200 { user:{ id:integer, name:string, firstName:string|null, lastName:string|null, email:string, position:string|null, department:string|null, managerName:string|null, employeeId:string|null, phoneNumber:string|null, personalEmail:string|null }, profile:{ preferredName, legalName, nationality, hireDate, originalHireDate, employmentType, workLocation, level, probationEndDate, contractEndDate, noticePeriod (typed as on EmployeeProfile), dateOfBirth:string|null, homeAddress:string|null }, emergencyContacts:[ EmergencyContact ], history:[{ id:integer, type:string, effectiveDate:string (date), status:'applied', changes:object }] (applied changes among the newest 100), equipment:[{ id:integer, name, type, manufacturer, serialNumber, status, platform }]|null (null when the device list cannot be read), directReports:integer, connectedTo:string|null (the connected HR system's name) }. /me/summary answers { directReports:integer }. | Session |
| PUT | /user-management/employees/me | Update your own personal details (your emergency contacts at PUT /me/emergency-contacts). body The MyDetailsUpdate object: any of { phoneNumber?:string (up to 40 characters), personalEmail?:string (an email address other than your work email), preferredName?:string, homeAddress?:string (up to 1,000 characters) }; null clears a value, and a body with none of them answers 400. /me/emergency-contacts takes the same { contacts[] } body as the HR route. returns 200 your file, in the shape of GET /employees/me | 400 "Nothing to update.", "Personal email is not a valid email address." or "Personal email must be different from your work email." guard Any other field in the body is ignored. Repeat: safe. | Session |
| GET | /user-management/employees/my-team | The people below you in the reporting line (one person's reduced file at /my-team/:userId). returns 200 { team:[{ id:integer, name:string, email:string, position:string|null, department:string|null, status:string|null, depth:integer (1 for a direct report), direct:boolean, reportsTo:string|null (null for a direct report), hireDate:string (date)|null, workLocation:string|null, reminders:[ EmployeeReminder ] }] }; everyone below you, no paging. /my-team/:userId answers { user:{ id:integer, name:string, email:string, position, department, status, managerName:string|null }, profile:{ hireDate, employmentType, workLocation, level, probationEndDate, contractEndDate (typed as on EmployeeProfile) }, history[] (as in GET /employees/me), reminders:[ EmployeeReminder ], equipment[] (as in GET /employees/me) } | 404 "This person is not in your team." guard Opening one person's file is written to the audit log. | Session |
| GET | /user-management/employees/hr-settings | Read HR Settings, including which system owns people data. returns 200 the fields of the HrAuthority object, plus providers:[{ id:integer, name:string, type:string, isActive:boolean }] (the connected HR systems, by name), governedFields:[{ key:string, label:string, group:string }], retention:{ months:integer } (0 means off) and retentionLimits:{ min:6, max:240 }. | Session + view_employee_records | manage_employee_records |
| PUT | /user-management/employees/hr-settings | Change HR Settings. body The HrAuthorityWrite object: { mode (required: 'zonitix' | 'external'), providerId (required for external: a connected HR system), fields? { <field key>: 'zonitix' | 'external' } } returns 200 the same shape as the GET | 400 "mode must be 'zonitix' or 'external'." or "Choose a connected HRIS. Add one under Identity Hooks first." guard Written to the audit log at warning level with the before and after. Repeat: safe. | Session + manage_employee_records |
| PUT | /user-management/employees/hr-settings/retention | Set how long the sensitive HR data of people who have left is kept. body The HrRetentionWrite object: { months } required: 0 switches it off, otherwise a whole number from 6 to 240. returns 200 the same shape as GET /employees/hr-settings | 400 'Choose 0 to switch it off, or between 6 and 240 months.' for any other value guard The page offers 12, 24, 36, 60 and 84 months; removal is permanent and runs daily. Repeat: safe; the one stored setting is written again and another HR_RETENTION_CHANGED audit entry is recorded | Session + manage_employee_records |
| GET | /user-management/employees/:userId/compensation | A person's compensation history. Amounts are not included; reveal one at a time. returns 200 { records:[ CompensationRecord ] }, newest effective date first, no paging. The object carries no amount. | Session + view_compensation | manage_compensation (licensed) |
| POST | /user-management/employees/:userId/compensation/:recordId/reveal | Reveal the amount of one compensation record. Each reveal is written to the audit log. body No body. returns 200 { id:integer, amount:number|null (null when the stored value cannot be read) } | 404 "Compensation record not found." guard Every call is written to the audit log as EMPLOYEE_COMPENSATION_VIEWED, without the amount. Repeat: safe; it is a read, and each call adds another audit entry | Session + view_compensation | manage_compensation (licensed) |
| POST | /user-management/employees/:userId/compensation | Add a compensation record to a file. body The CompensationCreate object: { amount:number (required, greater than 0, at most 10,000,000,000), currency:string (required, a 3-letter code), effectiveDate?:string (YYYY-MM-DD; default today), payPeriod? (annual | monthly | hourly; default annual), bonusPct?:number (0 to 999), reason?:string (up to 255 characters) } returns 201 { record:CompensationRecord } (no amount) | 400 with the field at fault | 404 "Employee not found." guard The amount is never written to the audit log. Repeat: not deduplicated; a second call adds a second record, which you then void. | Session + manage_compensation (licensed) |
| POST | /user-management/employees/:userId/compensation/:recordId/void | Void a mistyped record. It is kept, marked, and left out of the payroll hand-off; records are never edited or deleted. body The CompensationVoid object: { reason:string } required, up to 255 characters. returns 200 { record:CompensationRecord } with voidedAt, voidedBy and voidReason set | 404 'Compensation record not found.' | 409 'This record was already voided.' | 400 when the reason is missing or the record came from a signed offer guard Only a record a person typed in can be voided. A void cannot be undone. Repeat: a record that is already voided answers 409 | Session + manage_compensation (licensed) |
| GET | /user-management/employees/:userId/documents | The documents on a person's file (download one at /documents/:docId/download). returns 200 { documents:[ EmployeeDocument ] (newest first; entries whose file was never uploaded are left out), categories:[{ key:string, label:string }], visibilities:[{ key:string, label:string }], maxBytes:integer (10485760) }. Download: the file as an attachment | 404 "Document not found." guard Every download is written to the audit log. | Session + view_employee_records | manage_employee_records (licensed) |
| POST | /user-management/employees/:userId/documents | Create a document entry; its file is then sent with PUT /documents/:docId/content (change the entry with PUT /documents/:docId, remove it with DELETE). body The EmployeeDocumentWrite object: { name:string (required, up to 180 characters), category? (contract | offer | job_change | policy | certificate | review | letter | other; default other), visibility? (hr | employee | manager; default hr), expiresAt?:string (YYYY-MM-DD) }. A person's file holds at most 100 documents. PUT /documents/:docId/content takes the file itself as the raw request body. returns 201 { document:EmployeeDocument } | 400 "A document needs a name.", "Unknown category.", "Unknown visibility." or when the file already holds 100 documents. Content: 200 { document:EmployeeDocument } | 400 "The file is empty." | 409 "This document already has its file." | 413 over 10 MB | 415 unless PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). PUT /documents/:docId: 200 { document:EmployeeDocument }. DELETE: 204. guard The file type is decided from the file's own bytes, not from its name. Repeat: POST is not deduplicated; a second content upload to the same entry is refused with 409. | Session + manage_employee_records (licensed) |
| PUT | /user-management/employees/:userId/documents/:docId/content | Upload the file of a document entry made with POST /documents. body The file itself as the raw request body, at most 10 MB: PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). returns 200 { document:EmployeeDocument } | 400 "The file is empty." | 404 "Document not found." | 409 "This document already has its file. Upload a new document instead." | 413 over 10 MB | 415 for any other file type guard The file type is decided from the file's own bytes. Repeat: a second upload to the same entry answers 409 and stores nothing. | Session + manage_employee_records (licensed) |
| PUT | /user-management/employees/:userId/documents/:docId | Rename a stored document or change its category, visibility or expiry date. body The EmployeeDocumentWrite object, all keys optional: { name?:string, category?, visibility?, expiresAt?:string (YYYY-MM-DD) or null }. Only the keys sent are changed. returns 200 { document:EmployeeDocument } | 400 "Nothing to update.", "A document needs a name.", "Unknown category." or "Unknown visibility." | 404 "Document not found." (also for an entry whose file was never uploaded) guard Repeat: safe; the call sets values. The change is written to the audit log. | Session + manage_employee_records (licensed) |
| DELETE | /user-management/employees/:userId/documents/:docId | Delete a document from an employee file. body No body. returns 204 with no body | 404 "Document not found." guard Permanent. Written to the audit log at warning level. Repeat: a second call answers 404. | Session + manage_employee_records (licensed) |
| GET | /user-management/employees/:userId/documents/:docId/download | Download one document from an employee file. body No query parameters. returns 200 the file as an attachment | 404 "Document not found." guard Every download is written to the audit log. | Session + view_employee_records | manage_employee_records (licensed) |
| GET | /user-management/employees/me/documents | Your own documents that are shared with you (download at /me/documents/:docId/download). returns 200 { documents:[ EmployeeDocument ] }: only entries whose visibility is employee or manager. Not paged: every such document in one reply, newest first (a file holds at most 100 documents). | Session (licensed) |
| GET | /user-management/employees/my-team/:userId/documents | A report's documents that are shared with their manager (download at /documents/:docId/download). returns 200 { documents:[ EmployeeDocument ] }: only entries whose visibility is manager | 404 "This person is not in your team." Not paged: every such document in one reply, newest first. | Session (licensed) |
| GET | /user-management/employees/reports/summary | People Reports: combined figures only. body Query from, to (YYYY-MM-DD). returns 200 { from:string (date), to:string (date), headcount:integer, startHeadcount:integer, joiners:integer, leavers:integer, turnoverPct:number|null, averageTenureYears:number|null, missingStartDate:integer, byDepartment, byLocation, byEmploymentType, leaversByType, leaversByReason: each [{ label:string, count:integer }], tenureBands:[{ label:string, count:integer }] } | 400 "Choose a valid date range." | Session + view_employee_reports (licensed) |
| GET | /user-management/employees/payroll-export | Build the payroll hand-off file for a period (the last one built at /payroll-export/last). body Query from, to (YYYY-MM-DD, both required; at most a year apart). returns 200 { from:string (date), to:string (date), joiners:object[], leavers:object[], payChanges:object[], leave:object[], counts:{ joiners:integer, leavers:integer, payChanges:integer, leave:integer } }. Every row starts with employeeId:string, name:string, email:string. A joiners row adds startDate:string (date), jobTitle:string, department:string, location:string, employmentType:string, level:string, legalEntity:string, costCenter:string and the pay in force on the last day: amount:number|null, currency:string, payPeriod:string, bonusPct:number|null. A leavers row adds lastDay:string (date), departureType:string, reason:string, unusedLeave:{ type:string, days:number }[]. A payChanges row adds effectiveDate:string (date), previousAmount:number|null, previousCurrency:string, amount:number|null, currency:string, payPeriod:string, bonusPct:number|null, reason:string, isFirst:boolean. A leave row adds type:string, paid:boolean|null, startDate:string (date), endDate:string (date), days:number, hours:number|null, startTime:string|null | 400 "Choose the first and last day of the pay period." or "A hand-off period cannot be longer than a year." /payroll-export/last answers { last:object|null }: the stored summary of the last build, null before the first one. Not paged: each of the four lists holds every row of the period; no limit or offset is read. guard A GET that is recorded: every build is written to the audit log at warning level with the period and the counts, never an amount. | Session + export_compensation (licensed) |
Time Off
user-management-service
Requests, approvals, balances and the setup behind them. All of it needs the HR module (the hris entitlement, Team and Advanced). Requesting, cancelling and deciding need no permission: a person acts on their own time off, and the people above them in the reporting line decide it. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page.
Worked example: Request two days off
API token of the person asking; no permission is needed. A token call is a Bearer header, so it needs no Origin header. With the session cookie instead, add -H 'Origin: https://<your-instance>.zonitix.com' (the web app's address, not the API address) because it is a write. The values shown are made up; the field names and the shape are what the route sends.
curl -X POST -H "Authorization: Bearer $ZX_TOKEN" -H "Content-Type: application/json" \
-d '{"leaveTypeId": 1, "startDate": "2026-11-02", "endDate": "2026-11-03", "note": "Family visit"}' \
https://<your-instance>-api.zonitix.com/api/v1/user-management/leave/requests
# Reply
201
{
"request": {
"id": 88, "userId": 12, "leaveTypeId": 1, "startDate": "2026-11-02", "endDate": "2026-11-03",
"startHalf": false, "endHalf": false, "startTime": null, "minutes": null, "days": 2,
"status": "pending", "note": "Family visit", "decidedByEmail": null, "decidedAt": null,
"decisionNote": null, "createdAt": "2026-10-10T08:14:00.000Z", "typeName": "Vacation"
},
"requests": [ { "id": 88, "status": "pending" } ],
"skipped": [],
"replaced": []
}
// requests lists every request the call made, each a full LeaveRequest: one, or one per week of a repeat.
409 // it overlaps time off the person already has waiting or approved
{ "error": "..." }
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/leave/me | Your balances by type and your requests. returns 200 { balances:[ LeaveBalance ] (one per active type), requests:[ LeaveRequest ], canApprove:boolean, today:string (date), hoursPerDay:number, dayStart:string (HH:MM) }. requests is your newest 100 by start date; no paging parameters are read. | Session (licensed) |
| GET | /user-management/leave/preview | How many working days a request would use, before it is sent. body Query leaveTypeId, startDate, endDate, startHalf, endHalf ('true'), minutes, startTime: the same fields as a request. returns 200 { startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startHalf: boolean, endHalf: boolean, minutes: integer | null, startTime: 'HH:MM' | null, calendar: string, hoursPerDay: number, dayStart: string, holidays: [{ date: 'YYYY-MM-DD', name: string }], days: number, cost: number, dayMinutes: integer | null, endTime: 'HH:MM' | null, clashes: LeaveRequest[], replaceable: boolean }. days is cost rounded to two places; they differ only for part of a day. dayMinutes and endTime are null for whole and half days. clashes are your own live requests the asked time collides with, in the shape of a request. A one-day request also carries sameDay: object[], nearby: object[], window: object and team: { teamSize: integer, awayCount: integer, away: object[] } | null (null when you have no manager on record). clashes, replaceable and the one-day fields are left out when they cannot be read. 400 with the reason, for example "Choose the first and last day.", "The last day cannot be before the first day.", "A single request cannot be longer than a year." or "Time off by the hour is for one day. For several days, request whole days." | Session (licensed) |
| POST | /user-management/leave/requests | Request time off, in whole or half days or by the hour. body The LeaveRequestCreate object: { leaveTypeId, startDate, endDate?, startHalf?, endHalf?, note? (up to 500 characters) } for days. For hours on one day add { minutes (whole number, at least 15), startTime 'HH:MM' }. Optional: repeatWeeks (2 to 12, one-day requests only), replaceIds[] (your own bookings by the hour that whole days replace, at most 20). returns 201 { request:LeaveRequest, requests:[ LeaveRequest ] (every request made, one per week of a repeat), skipped:string[] (the dates of a repeat that were left out because they are not working days), replaced:[ LeaveRequest ] (bookings by the hour the new request replaced) } | 400 with the reason in words (dates, limits of the type, balance) | 409 when it overlaps time off the person already has waiting or approved guard Hours must start and end on the same day. A day holds a whole or half day or hours, never both. A type with approval switched off is approved at once. Repeat: a second identical call answers 409, because it clashes with the request the first one made (pending or approved) | Session (licensed) |
| POST | /user-management/leave/requests/:id/cancel | Cancel a request. body No body. returns 200 { request:LeaveRequest } with status cancelled and no typeName guard An approved request that is cancelled returns its days to the balance. When someone other than the person cancels it, the person is notified. Repeat: a second call answers 409 "This request was already cancelled." and returns no days twice | Session (licensed) |
| GET | /user-management/leave/approvals | The requests waiting for your decision. returns 200 { approvals:[ LeaveRequest ] }, each with personName:string and available:number|null (what the person would have left of that type; null when the type tracks no balance); earliest start date first, at most 200; no paging parameters are read. | Session (licensed) |
| POST | /user-management/leave/requests/:id/decide | Approve or decline one request. Nobody can decide their own. body The LeaveDecision object: { decision: 'approve' | 'decline', note? } — note is required for a decline (up to 500 characters) and is shown to the person. returns 200 { request:LeaveRequest } | 400 bad decision or missing reason | 404 when the request does not exist or is not yours to decide | 409 when it was already decided guard The decider must be above the requester in the reporting line, an administrator, or hold manage_leave. Repeat: a request that is no longer pending answers 409 | Session (licensed) |
| POST | /user-management/leave/approvals/approve | Approve several requests in one call. Each is decided exactly as a single decision would be. body The LeaveApproveMany object: { ids: number[] } — 1 to 50 request ids. returns 200 { approved:[ LeaveRequest ], failed:[{ id:integer, error:string }] } | 400 for an empty list or more than 50 guard One that cannot be approved is reported in failed; the others still go through. Each writes its own audit entry and notifies the person. Repeat: a request that is no longer pending is not decided again: it is listed in failed with "This request was already <its status>." | Session (licensed) |
| POST | /user-management/leave/approvals/decline | Decline several requests in one call, each with its own reason. Each is decided exactly as a single decline would be. body The LeaveDeclineMany object: { items:[ LeaveDeclineItem ] } with each item { id:integer, note:string } — 1 to 50 items; every note is required (up to 500 characters). returns 200 { declined:[ LeaveRequest ], failed:[{ id:integer, error:string }] } | 400 for an empty list, more than 50, or any missing reason (nothing is declined in that case) guard The page copies one reason to every item when the approver gives a single reason for all. Repeat: a request that is no longer pending is not decided again: it is listed in failed with "This request was already <its status>." | Session (licensed) |
| GET | /user-management/leave/calendar | Who is out. body Query from, to (YYYY-MM-DD, both required; a year or less). returns 200 { out:[{ id:integer, userId:integer, personName:string, startDate:string (date), endDate:string (date), startTime:string|null, minutes:integer|null, status:'pending'|'approved', typeName:string|null, mine:boolean, workingDays?:string[] }], today:string (date), workDays?:integer[], holidays?:[{ date:string (date), name:string }] }. out is the people you may see (yourself, the people who share your manager, and everyone below you), earliest start first. typeName is null, and a request still waiting is left out, for a person who is not you or below you, unless you hold manage_leave. workingDays, workDays and holidays are left out when the working calendar cannot be read | 400 "Choose a date range." or "Choose a range of a year or less." Not paged: every entry that overlaps the range comes back in one reply. | Session (licensed) |
| GET | /user-management/leave/setup | The types of time off and working calendars. returns 200 { types:[ LeaveType ] (by sort order, then name), calendars:[ WorkingCalendar ] (the default first, then by name) }. Not paged: every type and every calendar. | Session + manage_leave (licensed) |
| POST | /user-management/leave/types | Add a type of time off (change with PUT /types/:id, retire with DELETE /types/:id). body The LeaveTypeWrite object: { name:string (required, up to 60 characters), tracksBalance?:boolean (default true), annualDays?:number (0 to 366), accrual? (upfront | monthly; default upfront), carryOverMaxDays?:number (0 to 366), paid?:boolean (default true), requiresApproval?:boolean (default true), allowHours?:boolean (default true), allowNegative?:boolean, minPartMinutes?:integer, maxPartMinutes?:integer (whole minutes, 15 to 1440), isActive?:boolean (default true), sortOrder?:integer, accrueFrom?:string (YYYY-MM-DD, create only) } returns POST 201, PUT 200: { types:[ LeaveType ] }, the whole list. DELETE 200 { types:[ LeaveType ], deleted:boolean }: deleted is false when the type has been used, in which case it is retired and kept. 400 "A type of time off needs a name." or "There is already a type of time off called ..." | 404 "Type of time off not found." guard Repeat: a second POST with the same name is refused with 400. | Session + manage_leave (licensed) |
| POST | /user-management/leave/calendars | Add a working calendar (change with PUT /calendars/:id, remove with DELETE /calendars/:id). body The HolidayCalendarWrite object: { name:string (required), workDays?:integer[] (0 for Sunday to 6 for Saturday; default 1 to 5), holidays?:[{ date:string (YYYY-MM-DD), name:string }] (at most 400, no date twice), hoursPerDay?:number (1 to 24), dayStart?:string ('HH:MM'), isDefault?:boolean, location?:string } returns POST, PUT and DELETE all answer 200 { calendars:[ WorkingCalendar ] }, the whole list | 400 with the reason, including "Make another calendar the default before deleting this one." | 404 "Calendar not found." guard Repeat: not deduplicated: every call creates another calendar, also with the same name | Session + manage_leave (licensed) |
| GET | /user-management/leave/people/:userId | One person's balances, requests and balance history. returns 200 { balances:[ LeaveBalance ], requests:[ LeaveRequest ], hoursPerDay:number, dayStart:string (HH:MM) }; balances include retired types, requests is the newest 100 by start date. | Session + manage_leave | view_employee_records | manage_employee_records (licensed) |
| POST | /user-management/leave/people/:userId/adjust | Adjust a person's balance, with a reason. body The LeaveBalanceAdjust object: { leaveTypeId:integer, days:number (not 0, between -366 and 366; negative removes), note:string }, all required. returns 201 { balances:[ LeaveBalance ] } (retired types included) | 400 for a type with no allowance, a bad number of days or a missing reason guard Added to the balance history with the adjuster's email and the reason, and written to the audit log. Repeat: not deduplicated: every call adds another entry to the balance history | Session + manage_leave (licensed) |
Goals, Review Cycles & Surveys
user-management-service
All three need the HR module (the hris entitlement, Team and Advanced). Goals carry no permission on any route: the service decides per person who may read and who may write. A review that is not yours to write or read answers 404. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page.
Worked example: Your own goals
API token of the person; no permission is needed. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/goals/mine
# Reply
200
{
"person": { "id": 12, "name": "Dana Levi" },
"canEdit": true,
"goals": [
{
"id": 31, "userId": 12, "title": "Close the books in five working days",
"description": null, "period": "2026 H2", "dueDate": "2026-12-31",
"status": "on_track", "progress": 40, "weight": null,
"createdByEmail": "dana.levi@example.com", "completedAt": null,
"createdAt": "2026-07-01T09:00:00.000Z", "updatedAt": "2026-09-30T15:20:00.000Z"
}
]
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/goals/mine | Your own goals (another person's at /goals/people/:userId, if you are above them in the reporting line or hold an employee-records permission). returns 200 { person:{ id:integer, name:string }, canEdit:boolean, goals:[ Goal ] }; everything, no paging (a person has at most 50 goals) | 404 "Not found." for a person whose goals you may not read. | Session (licensed) |
| POST | /user-management/goals | Add a goal (change with PUT /goals/:id, remove with DELETE /goals/:id). body The GoalWrite object: { title:string (required), userId?:integer (default: yourself), description?:string (up to 2,000 characters), period?:string (up to 40), dueDate?:string (YYYY-MM-DD), status? (on_track | at_risk | done | dropped), progress?:integer (0 to 100), weight?:integer|null (0 to 100) }. PUT takes any subset. A person can have at most 50 goals. returns 201 { goal:Goal } | PUT 200 { goal:Goal } | DELETE 204. 400 "A goal needs a title.", "Nothing to update." or when the person already has 50 goals | 404 "Not found." when the goal does not exist or is not yours to change. guard Repeat: POST is not deduplicated. | Session (licensed) |
| GET | /user-management/goals/templates | The example goals. Adding, changing or deleting one (POST, PUT /templates/:id, DELETE /templates/:id) is refused with 403 without manage_employee_records. body Write: { title (required), description? (up to 2,000 characters), isActive? (default true), sortOrder? } returns 200 { canManage:boolean, templates:[ GoalTemplate ] } (only the active ones unless you may manage them). POST 201, PUT 200: { template:GoalTemplate }. DELETE 204. 400 "An example needs a title." or when there are already 30 | 403 "Only HR can do this." | 404 "Example not found." The GET is not paged: every example in one reply (at most 30 can exist). | Session (licensed) |
| GET | /user-management/reviews/mine | Your reviews, the reviews you have to write, and feedback asked of you. returns 200 { mine:object[], toReview:object[], toGive:object[] }; everything in one answer, no paging. Each entry of mine: { id:integer, cycle:ReviewCycle, state:'with_person'|'with_reviewer'|'ready_to_share'|'shared', reviewer:string|null, selfAnswers:object|null, selfSubmittedAt:string (date-time)|null, shared:boolean, sharedAt:string (date-time)|null, managerAnswers:object|null and rating:integer|null (both null until the review is shared), selfGoalRatings, managerGoalRatings } plus colleague-feedback and goal fields that are not typed here. Each entry of toReview: { id:integer, cycle:ReviewCycle, state, userId:integer, person:string, selfSubmitted:boolean, selfAnswers:object|null (null until the person submits), managerAnswers:object|null, rating:integer|null, managerSubmittedAt, sharedAt, calibration, selfGoalRatings, managerGoalRatings }. Answers are { <question key>: string }. toGive is not typed here. | Session (licensed) |
| PUT | /user-management/reviews/:reviewId/self | Save or submit your self review (the reviewer's half at PUT /:reviewId/manager). body { answers, submit?, goalRatings? }. The reviewer's half also takes rating (a whole number 1 to 5, or null). returns 200 { ok:true, submitted:boolean } | 400 "Answer every question before submitting.", "Choose a rating before submitting." | 404 "Review not found." | 409 when your half is already submitted, the review is already shared, it is with HR for calibration, or the cycle is closed. guard Without submit the answers are saved as a draft and can be sent again. Repeat: after submit, a second call is refused with 409. | Session (licensed) |
| POST | /user-management/reviews/:reviewId/share | The reviewer shares the review with its person. body No body. returns 200 { ok: true } | 400 "Submit your review before sharing it." | 404 "Review not found." | 409 when HR has not released the ratings for the cycle, or the cycle is closed. guard Repeat: safe; sharing a review that is already shared changes nothing and notifies nobody again. | Session (licensed) |
| POST | /user-management/reviews/:reviewId/feedback | The reviewer asks colleagues for feedback (read with GET; a colleague writes at PUT /reviews/feedback/:feedbackId or declines at POST /feedback/:feedbackId/decline; the reviewer withdraws a request with DELETE). body The FeedbackRequest object: { userIds[] }: active colleagues, not the person reviewed and not the reviewer; at most 8 per review. A colleague's answer: PUT /reviews/feedback/:feedbackId { answers, submit? }. returns 200 { added:integer } | 400 "Choose at least one colleague." and the other rules above | 404 "Review not found." | 409 when the cycle is closed, does not use colleague feedback, or the review is already shared. Answer: 200 { ok:true, submitted:boolean } | 409 when already submitted or declined. Decline: 200 { ok: true }. Withdraw (DELETE): 204 | 409 "Feedback that has been submitted cannot be removed." guard Repeat: asking the same colleague again adds nothing; added counts only new requests. | Session (licensed) |
| GET | /user-management/reviews/cycles | List review cycles. returns 200 { cycles:[ ReviewCycle ], each with progress:{ people:integer, selfDone:integer, managerDone:integer, shared:integer }, defaultQuestions:object[] (the starting questions, in the shape of ReviewCycle.questions) }, newest first; no paging parameters are read. | Session + manage_employee_reviews (licensed) |
| POST | /user-management/reviews/cycles | Create a review cycle (change with PUT /cycles/:id; DELETE /cycles/:id removes a draft only). body The ReviewCycleWrite object: { name:string (required), periodLabel?:string (up to 60 characters), selfDueDate?, managerDueDate? (YYYY-MM-DD; managerDueDate not before selfDueDate), department?:string, questions?:[{ label:string, key?:string, help?:string }] (1 to 12; the default questions when left out), includeRating?:boolean (default true), calibration?:boolean, goalRatings?:boolean, peerFeedback? (off | reviewer | person; default off) } returns POST 201, PUT 200: { cycle:ReviewCycle }. DELETE 204. 400 "A review cycle needs a name.", "A review needs at least one question." | 404 "Review cycle not found." | 409 "A closed cycle cannot be changed.", "Only a draft can be deleted. Close a launched cycle instead." guard Repeat: POST is not deduplicated. | Session + manage_employee_reviews (licensed) |
| POST | /user-management/reviews/cycles/:id/launch | Launch a cycle (close it at /cycles/:id/close). body No body, for the launch and for the close. returns 200 { cycle:ReviewCycle, added:integer (reviews created by this call), total:integer, withoutReviewer:integer } | 400 when nobody active is in scope | 404 | 409 "A closed cycle cannot be launched again." Close: 200 { cycle:ReviewCycle } | 409 "Only an open cycle can be closed." guard Repeat: closing twice is refused with 409. | Session + manage_employee_reviews (licensed) |
| GET | /user-management/reviews/cycles/:id/progress | Who is where in a cycle. returns 200 { cycle: ReviewCycle, ratings: [{ rating: 1..5, count: integer }], reviews: [{ id: integer, userId: integer, person: string, reviewerId: integer | null, reviewer: string | null, state: 'with_person' | 'with_reviewer' | 'ready_to_share' | 'shared', selfSubmittedAt: timestamp | null, managerSubmittedAt: timestamp | null, sharedAt: timestamp | null }] }. reviews is sorted by person name. ratings counts reviews the reviewer has submitted and leaves out your own review. 404 "Review cycle not found." Not paged: one entry per review in the cycle. | Session + manage_employee_reviews (licensed) |
| GET | /user-management/reviews/cycles/:id/calibration | The calibration view (change one rating with PUT /reviews/:reviewId/calibration; release with POST /cycles/:id/release-ratings). body PUT /reviews/:reviewId/calibration: { rating (a whole number 1 to 5), note (required: the reviewer reads it) } returns GET 200 { cycle:{ id:integer, name:string, status:string, ratingsReleasedAt:string (date-time)|null }, waiting:integer (reviews the reviewer has not submitted), distribution, originalDistribution, average, byReviewer:[{ reviewerId:integer, reviewer:string, reviews:integer, average, originalAverage }] (highest average first), fit, reviews:[{ id:integer, userId:integer, person:string, department:string|null, reviewer:string|null, submitted:boolean, shared:boolean, rating:integer|null (null until the reviewer submits), original:integer|null, changed:boolean, note:string|null, calibratedByEmail:string|null }] }. reviews is sorted highest rating first, then by name, leaves out your own review and is not paged; distribution, originalDistribution, average and fit are not typed here. Each read is written to the audit log. PUT 200 { ok: true } | 400 "That is already the rating." or a missing reason | 403 "You cannot calibrate your own review." | 409 when the reviewer has not submitted, the review is already shared, the cycle is closed, or it does not use calibration. Release: 200 { ok:true, already:boolean, waiting:integer }. guard Repeat: releasing twice is safe; the second answer carries already: true and notifies nobody. | Session + manage_employee_reviews (licensed) |
| PUT | /user-management/reviews/:reviewId/reviewer | Assign or reassign the reviewer (reopen a submitted half at POST /:reviewId/reopen). body The ReviewerReassign object: { reviewerId }. Reopen: { part: 'self' | 'manager' } returns 200 { ok: true } | 400 "Choose a reviewer.", "A person cannot review themselves.", "That reviewer is not an active person.", "part must be 'self' or 'manager'." | 404 "Review not found." | 409 "This review has already been shared." guard Repeat: not a no-op: naming the same reviewer again answers 200, clears anything that reviewer has written so far, and notifies them again | Session + manage_employee_reviews (licensed) |
| GET | /user-management/reviews/:reviewId | Read a review in full as HR. Each read is written to the audit log. returns 200 { review } | 403 "Your own review is not shared with you yet." | 404 "Review not found." | Session + manage_employee_reviews (licensed) |
| GET | /user-management/surveys/mine | The open surveys you are invited to. returns 200 { surveys:[ Survey ] }, each with responded:boolean; only open surveys you were invited to, most recently opened first; no paging. | Session (licensed) |
| POST | /user-management/surveys/:id/respond | Answer a survey, once. The answer sheet is stored without your identity. body { answers }: a whole number 1 to 5 for a scale question, 0 to 10 for an enps question, text for a text question. returns 200 { ok: true } | 400 "Answer at least one question." or a number outside its range | 404 "Survey not found." (also when you were not invited) | 409 "You have already answered this survey." or "This survey is closed." guard Repeat: refused with 409; a person answers once. | Session (licensed) |
| GET | /user-management/surveys | All surveys (the same question compared across closed surveys at /surveys/trends). returns 200 { surveys:[ Survey ], each with invited:integer and responses:integer, defaultQuestions:object[] (in the shape of Survey.questions), minResponses:5 }, newest first; no paging parameters are read. | Session + manage_employee_surveys (licensed) |
| POST | /user-management/surveys | Create a survey (change a draft with PUT /surveys/:id; DELETE /surveys/:id removes a draft; POST /:id/duplicate makes a new draft with the same questions). body The SurveyWrite object: { title:string (required), intro?:string (up to 2,000 characters), department?:string, closesAt?:string (YYYY-MM-DD, not in the past), questions?:[{ label:string (up to 300 characters), type: scale | enps | text, key?:string }] (1 to 20; the default questions when left out) } returns POST 201, PUT 200: { survey:Survey }. DELETE 204. Duplicate: 201 { survey:Survey }. 400 "A survey needs a title.", "A survey needs at least one question.", a closing date in the past | 404 "Survey not found." | 409 "A closed survey cannot be changed.", "Only a draft can be deleted. Close an open survey instead." guard Repeat: POST and duplicate each create another draft. | Session + manage_employee_surveys (licensed) |
| POST | /user-management/surveys/:id/open | Open a survey to its audience (close it at /surveys/:id/close). body No body, for the open and for the close. returns 200 { survey:Survey, invited:integer } | 400 when fewer than 5 people would be asked, or the closing date has passed | 404 | 409 "Only a draft survey can be opened." Close: 200 { survey:Survey } | 409 "Only an open survey can be closed." guard Repeat: refused with 409; a survey opens once and closes once. | Session + manage_employee_surveys (licensed) |
| GET | /user-management/surveys/:id/results | The combined results of a closed survey. Never individual answers. returns 200 { survey: Survey, results: { responses: integer, invited: integer, responseRate: integer | null, minResponses: 5, withheld: boolean, reason?: 'open' | 'too_few', questions: object[] } }. responseRate is a whole percentage, null when nobody was invited. While the survey is open, or with fewer than 5 responses, withheld is true, reason says which, and questions is empty. Otherwise each number question is { key: string, label: string, type: string, answered: integer, average: number | null, distribution: [{ value: integer, count: integer }], enps?: integer } and each written question is { key, label, type: 'text', answered: integer, comments: string[] } with the comments in shuffled order. Each read is written to the audit log. 404 "Survey not found." | Session + manage_employee_surveys (licensed) |
Training: Courses, Learning Paths & Imports
user-management-service
Every route needs Training on your plan (the training entitlement, Team and Advanced) and answers 403 when it is explicitly switched off on your licence. Uploaded video and SCORM import are new and not yet validated end-to-end. A capitalised name in a body or returns line, such as TrainingAssignment, is an object typed field by field under Data Objects on the Training page.
Worked example: Your own training to do
API token of the person; no permission is needed. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/training/mine
# Reply
200
{
"assignments": [
{
"id": 204, "courseId": 7, "userId": 12, "dueDate": "2026-10-31", "status": "assigned",
"completedAt": null, "lastCompletedAt": null, "expiresAt": null, "overdue": false,
"acknowledgedAt": null, "hasCertificate": false, "quizTriesLeft": null, "quiz": null,
"course": { "id": 7, "title": "Security awareness", "proof": "acknowledge", "isActive": true }
// ... the remaining TrainingCourse fields
}
]
}
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/training/mine | Your own training, to do and done. returns 200 { assignments:[ TrainingAssignment ] }; no paging. Open items on a retired course are left out. | Session (licensed) |
| POST | /user-management/training/assignments/:assignmentId/complete | Mark an assignment done. It must be your own, unless you hold manage_employee_training; a manager cannot complete training for a report. body The TrainingComplete object: { acknowledged: true } for an item that asks you to confirm you have read it; { reason } when HR marks an item with a security topic done for someone else. returns 200 { assignment:TrainingAssignment } | 400 "Confirm that you have read and understood it.", "Upload your certificate first.", "Answer the questions to finish this." | 404 "Assignment not found." guard Repeat: safe; completing an item that is already done returns it and records nothing new. | Session (licensed) |
| POST | /user-management/training/assignments/:assignmentId/quiz | Answer the knowledge check of your own assignment. body { answers, draw? } returns 200 { passed:true, score:integer, assignment:TrainingAssignment } | 200 { passed:false, score:integer, passMark:integer, triesLeft:integer|null (null when tries are not limited), wrongCount:integer, quiz:TrainingQuiz (the next try's questions) } | 200 { passed:false, stale:true, quiz:TrainingQuiz } when the questions changed while you answered | 400 "This training item has no knowledge check." | 404 | 429 when today's tries are used up. guard Repeat: after a pass, another call answers { passed: true, assignment } and records nothing. | Session (licensed) |
| PUT | /user-management/training/assignments/:assignmentId/certificate | Upload a certificate for training that asks for one (download with GET). Your own, or as HR. body The file itself as the raw request body: PDF, PNG or JPEG. returns 201 { assignment:TrainingAssignment } | 400 "This training does not ask for a certificate." | 404 "Assignment not found." | 409 "This training is already marked done." | 415 for any other file type. GET: the file | 404 "No certificate has been uploaded." guard Repeat: a second upload answers 201 and stores a second file; the assignment points at the newest and the earlier one stays in the document vault. Once the training is marked done the answer is 409 | Session (licensed) |
| GET | /user-management/training/people/:userId | One person's training, if you are above them in the reporting line or hold manage_employee_training, view_employee_records or manage_employee_records. returns 200 { person: { id: integer, name: string }, assignments: [ TrainingAssignment ] }. exemptUntil is present only on an open item the person is excused from, and overdue is then false. Open items on a retired course are left out. Reading someone else's record is written to the audit log. 404 "Not found." when the person is neither below you nor readable with one of the three permissions. Not paged: every assignment of the person in one reply. | Session (licensed) |
| GET | /user-management/training/team | What is open and overdue for the people below you in the reporting line. returns 200 { people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ assignmentId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, renewal: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. direct is true for someone who reports to you directly. People are sorted most overdue first; items a person is excused from, and items on a retired course, are left out. With nobody below you: people is empty and every summary figure is 0. Not paged: everyone below you in one reply. | Session (licensed) |
| GET | /user-management/training/courses | List courses (one course's knowledge check with its answers at /courses/:id/quiz). returns 200 { courses:[ TrainingCourse ] }, each with the counts assigned:integer, completed:integer and overdue:integer; courses in use first, then by title; everything, no paging. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/courses | Create a course (change with PUT /courses/:id). body The TrainingCourseWrite object: { title:string (required), description?:string (up to 2,000 characters), url?:string (http or https), content?:string (up to 20,000 characters), proof? (none | acknowledge | certificate | quiz; default none), quiz?:object (at least one question with proof quiz), renewEveryMonths?:integer (1 to 120), dueDays?:integer (1 to 365; default 30), topic?:string, autoAssign?:boolean, isActive?:boolean, videoUrl?:string, videoRequired?:boolean, audience?:object } returns POST 201, PUT 200: { course:TrainingCourse } | 400 with the reason | 404 "Training item not found." guard A course with a security topic must be redone at least every 12 months and cannot use proof 'none'. Repeat: POST is not deduplicated. | Session + manage_employee_training (licensed) |
| GET | /user-management/training/library | The ready-made course library. returns 200 { videos: [{ id: string, title: string, seconds: integer, url: string }], courses: [{ key: string, title: string, description: string, minutes: integer, sections: integer, questions: integer, passMark: integer, securityAwareness: boolean, topic: string, feeds: integer, fills: array, hasVideo: boolean, added: boolean, guided: boolean }] }. added is true when you already have an item with that title; guided is true when that item already has a knowledge check. Not paged: the whole library in one reply. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/library/:key/adopt | Add a library course to your own list as an ordinary course: editable and not yet assigned. body The LibraryAdopt object: { replace?: true } returns 201 { course:TrainingCourse } | 404 "That course is not in the library." | 409 when you already have a training item with that title and replace is not true guard Repeat: without replace a second call answers 409, because the item now exists; with replace: true it writes the library text and knowledge check again | Session + manage_employee_training (licensed) |
| POST | /user-management/training/courses/:id/assign | Assign a course to people (the choices an audience can pick from at GET /training/audience-options). body The TrainingAssign object: { audience (required: 'everyone' | 'department' | 'people'), department (with 'department'), userIds[] (with 'people'; at most 2,000), dueDate? (YYYY-MM-DD; default: the course's days to finish, counted from today) } returns 200 { added:integer (new assignments), matched:integer (active people the request matched), delivered:integer (people a notice reached) } | 400 "Choose a department.", "Choose at least one person.", "Nobody active matches that." | 404 "Training item not found." (also for a retired course) guard Repeat: safe. A person who already has the course is left exactly as they are; added counts only new assignments. | Session + manage_employee_training (licensed) |
| GET | /user-management/training/courses/:id/status | Who has and has not done one course. returns 200 { course: TrainingCourse, left: integer, assignments: TrainingAssignment[] }. Each assignment has course null and adds person: string and, for someone excused, exemptUntil: date and exemptReason: string (overdue is then false). left counts assignments of people who are no longer active; they are not listed. Sorted overdue first, then by name. 404 "Training item not found." Not paged: every assignment of the course in one reply. | Session + manage_employee_training (licensed) |
| GET | /user-management/training/roster | Every assignment across all courses. Add ?format=csv to download the same rows. body Query courseId?, department?, status?, format=csv. returns 200 { rows:[{ assignmentId:integer, courseId:integer, course:string, securityAwareness:boolean, active:boolean, userId:integer, person:string, email:string, department:string|null, manager:string|null, state:'overdue'|'open'|'exempt'|'completed', exemptUntil:string (date)|null, exemptReason:string|null, dueDate:string (date)|null, completedAt:string (date-time)|null, lastCompletedAt:string (date-time)|null, expiresAt:string (date)|null, timesCompleted:integer, markedByHr:boolean, hasCertificate:boolean }], truncated:boolean, summary:{ total, overdue, open, exempt, completed } (integers), departments:string[], dashboard:object (the figures behind the page's charts; not typed here) }; sorted overdue first, then open, exempt and completed; up to 20,000 assignments; truncated is true beyond that. No paging parameters are read. With format=csv: the same rows as a file, with headers X-Export-Row-Count and X-Export-Truncated. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/assignments/batch | Change the due date of, remind or remove a selection of assignments in one call (remove one with DELETE /assignments/:assignmentId). body The TrainingBatchAction object: { action (required: 'remove' | 'due' | 'remind' | 'complete'), ids[] (at most 2,000) or filter { courseId, department, status }, dueDate (with 'due'), reason? }. DELETE /assignments/:assignmentId takes reason in the body or as ?reason=. returns 200 { action:string, done:integer, skipped:[{ id:integer, reason:string }] } | 400 "Choose at least one assignment.", "Too many in one go.", "Choose the new due date." DELETE: 204 | 404 "Assignment not found." guard One that cannot be acted on is listed in skipped with its reason; the others still go through. Repeat: for remove, an assignment that is already gone is listed in skipped with "Assignment not found."; for due and complete, one that is already done is listed in skipped with "Already done." | Session + manage_employee_training (licensed) |
| POST | /user-management/training/courses/:id/import-completions | Record completions of one course that happened in another system (a file covering several courses at POST /training/import-completions). body The CompletionsImport object: { source (required, at least 3 characters: where the completions come from), rows:[ CompletionRow ] (at most 5,000; each { email, completedAt (YYYY-MM-DD) }) }. The file route (/training/import-completions) takes { source, rows[], dateOrder? }. returns 200 { imported:integer, skipped:[{ email:string, reason:string }] }; the file route answers { imported, items[], skipped, ambiguous } (not typed here) | 400 "There is nothing to import." or more than 5,000 rows | 404 "Training item not found." guard Repeat: safe; a row whose person already has a completion on or after that date is listed in skipped and nothing is recorded twice | Session + manage_employee_training (licensed) |
| GET | /user-management/training/settings | Read the reminder settings (save with PUT). returns 200 { daysBefore: integer, renewGraceDays: integer, sendHourUtc: integer | null }. Defaults when nothing is saved: 7, 30 and null (null means the daily run is not held to one hour). PUT takes the same three fields (daysBefore 1 to 30, renewGraceDays 7 to 90, sendHourUtc 0 to 23 or empty), answers 200 with the saved values and writes an audit entry; 400 names the field that is out of range. | Session + manage_employee_training (licensed) |
| GET | /user-management/training/exemption-suggestions | People on long approved leave who have open training. returns 200 { suggestions: [{ userId: integer, person: string, email: string, courseId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, leaveUntil: 'YYYY-MM-DD' }] }, sorted by person. One row per open item of an active person whose approved leave is 28 days or longer and is running now or starts within 7 days. Items the person is already excused from are left out. Nothing is recorded by this read. Not paged: every suggestion in one reply. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/courses/:id/video | Upload a video file for a course, .mp4 or .webm (remove with DELETE). body The video file itself as the raw request body: an .mp4 or a .webm file. No JSON body. DELETE: no body. returns 201 { course:TrainingCourse, sizeBytes:integer } | 400 "The file is empty." or an interrupted upload | 413 when the video is over the size limit | 415 for a file that is not an .mp4 or a .webm video. DELETE: 200 { course:TrainingCourse }. guard Repeat: a second upload replaces the first | Session + manage_employee_training (licensed) |
| GET | /user-management/training/courses/:id/video/file | Watch a course's uploaded video, as HR or as someone the course is assigned to. returns 200 the whole file, or 206 one part when a Range header is sent, as the stored content type with Content-Disposition: inline, Accept-Ranges: bytes and Cache-Control: private, no-store, no-transform. 416 with Content-Range: bytes */<size> when the range starts past the end. 404 "Training item not found." when the item does not exist or is not assigned to you; 404 "This item has no uploaded video." | Session (licensed) |
| POST | /user-management/training/courses/:id/scorm | Import a SCORM 1.2 package of one lesson (remove with DELETE). body The SCORM package itself, a .zip file of at most 50 MB, as the raw request body. No JSON body. DELETE: no body. returns 201 { course:TrainingCourse } | 400 "The file is empty.", or a package that cannot be read | 413 when the .zip is over 50 MB. DELETE: 200 { course:TrainingCourse }. guard Repeat: a second import replaces the earlier package | Session + manage_employee_training (licensed) |
| GET | /user-management/training/courses/:id/scorm/launch | Get a short-lived pass to open a course's SCORM lesson, as HR or as someone it is assigned to. returns 200 { token: string, channel: string, expiresAt: timestamp, launch: string, package: { title: string | null, launch: string, files: integer, bytes: integer, importedAt: timestamp } }. token is the pass for /training-content/:pass/*; it runs out 4 hours after it is issued. launch is the path of the page to open first, inside the package. 404 "Training item not found." when the item does not exist or is not assigned to you; 404 "This item has no SCORM package." | Session (licensed) |
| GET | /training-content/:pass/* | The files of a SCORM lesson, shown in a sandboxed frame. The pass in the path is the credential. returns 200 the file as its stored content type (206 for a Range request, 416 when the range starts past the end), with a sandboxing Content-Security-Policy, Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff. Every refusal is the same 404 ("Not found" or "Not found."): a malformed or expired pass, a person who is no longer assigned, a file that is not in the package. 405 { error: "Method not allowed" } for anything but GET and HEAD. | Launch pass (rate-limited) |
| GET | /user-management/training/paths | List learning paths. returns 200 { paths:[ LearningPath ] } with people, finished and percent on each; paths in use first, then by title; everything, no paging. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/paths | Create a learning path (change with PUT /paths/:id, remove with DELETE /paths/:id). body The LearningPathWrite object: { title:string (required), courseIds:integer[] (required: 2 to 20 courses in order, each once, none retired), description?:string (up to 2,000 characters), isActive?:boolean (default true), autoAssign?:boolean } returns POST 201, PUT 200: { path:LearningPath }. DELETE 204. 400 with the reason | 404 "Learning path not found." guard Repeat: POST is not deduplicated. | Session + manage_employee_training (licensed) |
| POST | /user-management/training/paths/:id/members | Add one person or a selection to a path. body The PathMembersAdd object: { userIds[] } or { userId }; at most 500 people in one call. returns 200 { added:integer, already:integer, notFound:integer (ids that matched no active person), assigned:integer, finished:integer, failed:integer } | 400 "Choose at least one person.", "This path is paused. Switch it back on before adding people.", "Nobody active matches that." | 404 guard Repeat: safe; people already on the path are counted in already and not added again. | Session + manage_employee_training (licensed) |
| GET | /user-management/training/paths/:id/progress | Per person: the step they are on, how many are done, when they started and finished. returns 200 { path: LearningPath, left: integer, rows: [{ userId: integer, person: string, department: string | null, step: integer | null, course: string | null, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, done: integer, total: integer, startedAt: timestamp | null, completedAt: timestamp | null }], summary: { people: integer, finished: integer, overdue: integer } }. step and course are null once the person has finished the path. left counts members who are no longer active; they are not listed. Each read is written to the audit log. 404 "Learning path not found." Not paged: one row per active member of the path. | Session + manage_employee_training (licensed) |
Phishing Simulation
user-management-service
Practice phishing emails to your own staff. Part of Training (the training entitlement, Team and Advanced), with its own permission: manage_employee_training is not accepted on any managing route here. PhishingCampaign is typed under Data Objects on the Training page.
Worked example: The simulations and their totals
API token holding manage_phishing_simulation. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/user-management/training/phishing/campaigns
# Reply
200
{
"campaigns": [
{
"id": 3, "title": "October practice", "templateKey": "...", "difficulty": null,
"subject": "...", "status": "sent", "createdBy": "sam.cohen@example.com", "createdById": 9,
"approvedBy": "noa.stern@example.com", "approvedAt": "2026-10-01T09:00:00.000Z",
"sendAt": null, "sentAt": "2026-10-01T09:05:00.000Z", "courseId": 7, "resultsPrunedAt": null,
"recipients": 40, "sent": 40, "clicked": 6, "reported": 11, "clickRate": 15, "reportRate": 28
}
],
"kpis": { "campaignsSent": 1, "emailsSent": 40, "clickRate": 15, "reportRate": 28 },
"me": { "id": 9, "isAdmin": false }
}
// The rates are whole percentages of emails sent, and null when none was sent.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /user-management/training/phishing/campaigns | List simulations (one at /campaigns/:id; the people in one at /campaigns/:id/people). returns 200 { campaigns: [ PhishingCampaign ], kpis: { campaignsSent: integer, emailsSent: integer, clickRate: integer | null, reportRate: integer | null }, me: { id: integer, isAdmin: boolean } }. The rates are whole percentages of emails sent, null when none was sent. GET /campaigns/:id/people answers 200 { people: [{ id: integer, firstName: string, lastName: string, email: string, name: string }] }. guard The list is the newest 200 simulations; no paging parameters are read. One that does not exist answers 404 "That simulation does not exist." | Session + manage_phishing_simulation (licensed) |
| POST | /user-management/training/phishing/campaigns | Create a simulation (change with PUT /campaigns/:id, remove with DELETE /campaigns/:id). body The PhishingCampaignWrite object: { templateKey:string (required: one of the built-in templates), userIds:integer[] (required: 1 to 2,000; people who are not active users are left out and counted in skipped), title?:string (up to 160 characters; defaults to the template's name), courseId?:integer (a course in use), sendAt?:string (a date and time) } returns POST 201, PUT 200: { campaign:PhishingCampaign, recipients:integer, skipped:integer (ids sent that are not active people) } | DELETE 204. 400 "Give the simulation a name.", "Pick one of the built-in templates.", "Pick at least one person." | 404 | 409 "Only a draft can be changed. Send it back to draft first." or "Only a draft can be deleted." guard Repeat: POST is not deduplicated. | Session + manage_phishing_simulation (licensed) |
| GET | /user-management/training/phishing/templates | The practice email templates (the courses a simulation can point to at /phishing/courses). returns 200 { senderName: string, linkReady: boolean, mailReady: boolean, templates: [{ key: string, name: string, difficulty: string, subject: string, tells: string[], previewHtml: string }] }. linkReady is true when practice links would start with an https address; mailReady is true when outgoing mail is set up. GET /phishing/courses answers 200 { courses: [{ id: integer, title: string }] }: the active training items, by title. Neither list is paged. | Session + manage_phishing_simulation (licensed) |
| POST | /user-management/training/phishing/campaigns/:id/submit | Send a simulation for approval. body No body. returns 200 { campaign:PhishingCampaign }, now waiting for approval | 400 "Pick at least one person before submitting." | 409 "Only a draft can be submitted for approval." guard Repeat: refused with 409 once it is waiting for approval. | Session + manage_phishing_simulation (licensed) |
| POST | /user-management/training/phishing/campaigns/:id/approve | Approve a simulation. The person who prepared it is refused, unless they are an administrator (return it to its preparer at /campaigns/:id/send-back). body No body for the approve. Send back: { reason?:string }. returns 200 { campaign:PhishingCampaign }, now approved | 403 with sod: true when you prepared it | 409 "Only a simulation waiting for approval can be approved." Send back: 200 { campaign:PhishingCampaign } | 409 unless it is waiting for approval, or approved and not yet sent. guard Repeat: a second approve is refused with 409. | Session + manage_phishing_simulation (licensed) |
| POST | /user-management/training/phishing/campaigns/:id/send | Send an approved simulation now (stop one at /campaigns/:id/cancel). body No body, for the send and for the cancel. returns 202 { campaign:PhishingCampaign }; sending continues in the background | 409 "Only an approved simulation can be sent." or "This simulation is already being sent." Cancel: 200 { campaign:PhishingCampaign } | 409 when it is already sent or already cancelled. guard Repeat: refused with 409 while it is being sent and after it was sent. | Session + manage_phishing_simulation (licensed) |
| GET | /user-management/training/phishing/campaigns/:id/results | Per-person results. Each read is written to the audit log. returns 200 { campaign: PhishingCampaign, rows: [{ userId: integer, person: string, email: string | null, department: string | null, sentAt: timestamp | null, clickedAt: timestamp | null, reportedAt: timestamp | null, sendError: string | null, outcome: 'not_sent' | 'no_action' | 'clicked' | 'reported' | 'clicked_then_reported' }], pruned: boolean }. Once the per-person results have passed their retention period, rows is empty and pruned is true; the totals on campaign are kept. Someone whose account is gone shows as "Former user #<id>" with email null. 404 "That simulation does not exist." Not paged: one row per person the simulation went to (a simulation goes to at most 2,000 people). | Session + manage_phishing_simulation (licensed) |
| GET | /user-management/training/phishing/settings | Read the simulation settings (save with PUT). returns 200 { senderName: string, linkBase: string, retentionMonths: integer }. Defaults when nothing is saved: "IT Service Desk", an empty linkBase and 12. PUT takes the same three fields (linkBase must be https://host with no path; retentionMonths 1 to 36), answers 200 with the saved values and writes an audit entry; 400 names the field that is wrong. | Session + manage_phishing_simulation (licensed) |
| POST | /user-management/training/phishing/report | Report a suspicious email. Only your own practice email is matched. body The PhishingReport object: { token?:string (the link in the email, or the 64-character code at the end of it), campaign?:integer (a simulation id, read when token is absent) }. returns 200 { simulation:boolean } guard Repeat: safe; only the first report of a practice email is recorded and written to the audit log, and the answer is the same each time | Session (licensed) |
| GET | /user-management/training/phishing/mine/:token | After a click on your own practice email: the training it points to. returns 200 { simulation: boolean, course: { id: integer, title: string } | null, assignmentId: integer | null }. simulation is false when the token is not one of your own sent practice emails. course is null when the simulation points to no training or the item is retired; assignmentId is null when that item is not open for you. | Session (licensed) |
| GET | /public/phishing/l/:token | The page a person lands on after clicking a practice link. It still answers after a plan change, so a link already in an inbox explains itself. returns Always 200 { simulation: true, tells: string[] } with Cache-Control: no-store. tells are the signs that gave the practice email away. A real, unknown or malformed token gets the same shape, so the answer says nothing about a person. The first visit with a real token records the click; later visits change nothing. | Public (rate-limited) |
Policy Signing & People Evidence
compliance-service
The policies people sign, who has signed, and the signed records. These routes answer when either Training or the Compliance Hub is on your plan, so they are available from Team. The policy register rows themselves are listed under Compliance Hub above.
Worked example: How long signatures and training records are kept
API token of any signed-in person; the read needs no permission. The values shown are made up; the field names and the shape are what the route sends.
curl -H "Authorization: Bearer $ZX_TOKEN" https://<your-instance>-api.zonitix.com/api/v1/compliance/reports/compliance/evidence-retention
# Reply
200
{ "signatureMonths": 0, "trainingMonths": 0, "min": 12, "max": 240 }
// 0 means no period is set. Changing signatureMonths is a PUT that needs manage_compliance.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /compliance/reports/compliance/policy-templates | The policy template library, with which templates you have already added. returns 200 { libraryVersion: string, companyName: string | null, templates: [{ id: string, title: string, version: string, summary: string, ownerRole: string, reviewCadenceDays: integer, requiresAcknowledgment: boolean, controlIds: string[], fills: array, ownText: boolean, body: string, adopted: { policyId: string, version: string, owner: string, adoptedAt: timestamp | null, status: string, reviewedAt: timestamp | null, reviewedBy: string | null, draftedBy: string | null } | null }] }. body is the template text with your company name filled in. adopted is null for a template you have not added. Not paged: the whole library in one reply. | Session + view_reporting | manage_compliance | manage_employee_training (licensed) |
| POST | /compliance/reports/compliance/policy-templates/adopt | Add one or several templates to your policy register. body { items: [{ templateId (required), owner, nextReviewDue?, requireAck? }] } returns 201 { created[], skipped[], libraryVersion } | 200 with the same shape when every template was already added | 400 "Pick at least one template to adopt." or an unknown templateId. guard Each one is added as a draft that its owner must approve. Repeat: safe; a template already in the register is reported in skipped and not added again. | Session + manage_compliance (licensed) |
| POST | /compliance/reports/compliance/policies/approve | Approve or re-approve one or several policies. The service allows the policy's owner or a holder of manage_compliance. body { ids[] } (or { id }) returns 200 { approved[], refused[] }. 400 "Pick at least one policy to approve." for an empty list; otherwise, when nothing was approved, the status and reason of the first refusal (400, 403 or 404) with { message, approved[], refused[] }. guard The service decides who may approve each policy and enforces separation of duties; every approval and every blocked self-approval is written to the audit log. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/:id/document | The text of a policy, for any signed-in person. returns 404 "Policy not found" for an unknown id. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/ack/mine | The policies you are asked to sign, and the ones you have signed. returns 200 { pending:object[], acknowledged:object[], exempt:object[] }; no paging. Every entry carries id:string, name:string, version:string, hasDocument:boolean and the policy's description, link and owner as stored. pending adds dueBy, overdue:boolean, reason and previouslySignedAt:string (date-time)|null; acknowledged adds acknowledgedAt:string (date-time) and validUntil; exempt adds exemptReason and exemptUntil. All three are empty for an account that is not a person. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/:id/sign-document | What you sign: the policy text cut into sections, each with a fingerprint. returns 200 the document with contentHash and sections[{ n, heading, hash }] | 404 "That policy is not available for signing." for a draft, or a policy that does not ask for signatures. | Session (licensed) |
| PUT | /compliance/reports/compliance/policies/:id/sign-progress | Save which sections you have confirmed so far (read it back with GET). body { contentHash (from sign-document), sections[] (the hashes confirmed so far) }. GET takes ?contentHash=. returns 200 { sections[], openedAt } | 404 "That policy is not available for signing." | 409 "This policy changed while you were reading it. Reload and read the current text." guard Repeat: safe; the list sent replaces the saved one. | Session (licensed) |
| POST | /compliance/reports/compliance/policies/:id/ack | Sign a policy as yourself. body { signedName (your full name, at least 2 characters), contentHash (from sign-document), sections[] (the hash of every section) } returns 201 { policyId, name, version, contentHash, sections, signedName, acknowledgedAt, alreadyAcknowledged, renewal, kind?, nameMatches? } | 400 "Type your full name to sign." or "Confirm every section before you sign" | 404 "That policy is not available for signing." | 409 when the text changed while you read it. guard Repeat: safe. While your signature is current a second call records nothing and answers alreadyAcknowledged: true. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/:id/signed-document | The text you signed. Only ever your own signature (your signed copy as a PDF at /policies/:id/signed-copy.pdf). returns 200 { exact, name, version, link, body, ... }. exact is false when the stored version of the text you signed is no longer on record and the current text is shown instead. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/ack/team | What the people below you in the reporting line still have to sign. returns 200 { people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ policyId: string, policy: string, version: string, dueBy: 'YYYY-MM-DD' | null, overdue: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. Only people below you who still have a policy due or overdue are listed, most overdue first; summary.people counts everyone below you. Not paged. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/ack/status | Who has and has not signed, across policies. body Query policyId?, department?, state? (signed | due | overdue | exempt, or open for due and overdue together). returns 200 { rows[], counts { total, signed, due, overdue, exempt }, departments[], policies[{ id, name, version }] }; overdue first, then by due date and name. Everything in one answer, no paging. guard Each read is written to the audit log. | Session + manage_compliance | manage_employee_training (licensed) |
| POST | /compliance/reports/compliance/policies/ack/remind | Remind the people who still owe a signature now. body { policyId? } (without it: every policy that still has open signatures) returns 200 { owed, reached, failed, policies[] } | 404 "That policy does not need signing." guard Repeat: not deduplicated; every call sends the reminder again. | Session + manage_compliance | manage_employee_training (licensed) |
| GET | /compliance/reports/compliance/policies/:id/ack/roster | The roster of one policy: who holds a current signature (as a signed PDF at /ack/roster.pdf; the download is written to the audit log). body Query ?asOf=yyyy-mm-dd for the roster as it stood at the end of that day. returns 200 { policy, people[], asOf } | 400 "asOf must be a date like 2026-03-31." The PDF is sent with headers X-Signature-Status and X-Content-SHA256. people[] is not paged: it comes back whole. guard Each read is written to the audit log. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/people/evidence.pdf | Everything on record for one person, by ?email= or ?userId=, as a signed PDF. returns 200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256. guard Each export is written to the audit log with the person it was about. | Session + manage_compliance (licensed) |
| GET | /compliance/reports/compliance/training/:courseId/my-certificate.pdf | Your own certificate of completion for a course. returns 200 application/pdf as an attachment, with the headers X-Signature-Status and X-Content-SHA256 and Cache-Control: no-store. The download is written to the audit log. 404 "You have not completed this training." | Session (licensed) |
| GET | /compliance/reports/compliance/training/:courseId/roster.pdf | The signed roster of one course. body Query ?asOf=yyyy-mm-dd returns 200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256 | 400 for a bad asOf. | Session + manage_compliance | manage_employee_training (licensed) |
| GET | /compliance/reports/compliance/exemptions | Recorded exemptions (record one with POST; end one at POST /exemptions/:exemptionId/end). The service allows a compliance manager, or HR for training. body GET query: kind (policy | training), targetId?, includeEnded=1. POST: { kind, targetId, email, reasonCode (long_leave | out_of_scope | covered_elsewhere | leaving | other), note? (up to 500 characters; at least 8 with 'other'), expiresAt (yyyy-mm-dd, in the future, at most 366 days ahead) } returns GET 200 { exemptions[], reasons[] }. POST 201 the exemption | 400 with the field at fault | 403 when you may not record that kind, or "You cannot exempt yourself" | 404 "Policy not found." or "Training item not found." | 409 "This person is already exempt until ...". End: 200 the exemption | 404 "Exemption not found." The GET is not paged and reads no limit or offset: it looks at the newest 2,000 recorded exemptions, newest first, and returns those still in force (all of them with includeEnded=1). guard The list is written to the audit log on every read. Repeat: a second POST for the same person and item is refused with 409; ending one twice answers 200 again and logs nothing. | Session (licensed) |
| GET | /compliance/reports/compliance/evidence-retention | How long signatures and completions are kept (change with PUT, manage_compliance). The same periods in plain words at /my-data-notice. body PUT: { signatureMonths } required: 0 keeps signatures indefinitely, otherwise a whole number between min and max. returns 200 { signatureMonths:integer (0 when signatures are kept with no end date), trainingMonths:integer (the HR retention setting; 0 when it is off), min:12, max:240 }; PUT answers the same shape | 400 for any other value. guard PUT is written to the audit log at warning level with the before and after. Repeat: safe. | Session (licensed) |
| GET | /compliance/reports/compliance/policies/ack/coverage | Signing coverage as a combined figure. returns 200 { total, policies[] }. Without manage_compliance the per-policy name lists (outstanding, overdue, renewing, untrailed) come back empty: counts only. Each entry of policies[] carries the counts acknowledged, required, exemptCount, pct, outstandingCount, overdueCount, untrailedCount, dueCount and rolloutCount, and the name lists outstanding, overdue, untrailed and renewing. policies[] is not paged, but each name list holds at most its first 100 email addresses: read outstandingCount, overdueCount and untrailedCount for the full numbers. | Session + view_reporting | manage_compliance (licensed) |
| GET | /compliance/reports/compliance/evidence-map | Which controls training completions and policy signatures feed. A Compliance Hub route: it needs the Compliance Hub on your plan (Advanced). returns 200 { topics[], training[], policySigning[] }; each control as { id, title, frameworks[], topics[] }. Not paged. | Session + view_reporting | manage_compliance | manage_employee_training (licensed) |