Developer HubAPI Reference
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/users were 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/docs fills in your instance’s API address, and its openapi.json button downloads a copy with that address first. A file fetched with curl keeps the template, so set the server yourself in the tool you load it into: https://<your-instance>-api.zonitix.com/api/v1 with 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.version is the fixed text 1.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.

AreaOperationsWhat is in it
Users17The directory: list, search, create, update, delete, bulk change, invites, onboard, offboard, import, export.
Groups5Groups: list, create, update, delete, read members.
Roles6Roles: list, create, update, delete, read members, set permissions.
Permissions1The permission list, read only.
Workflows14Pipelines: list, create, edit, promote, reject, clone, dry run, retire, import, export.
Workflow runs12Runs of a pipeline: list, start, read, update, claim, run a step, schedule, recall.
Devices28Device reads only: the inventory, one device, software, groups, profiles, MDM status. No device write is in it.
Audit logs3List, export and verify the audit log.
API tokens6Token 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.

FieldMeaning
x-required-permissionsThe permissions that open the operation. Holding any one is enough. An empty list means any signed-in caller.
x-authIn the saved file: permission (one of the listed permissions is needed) or authenticated (any signed-in caller).
x-api-tokenWhether a personal API token can call it: allowed, not-allowed, or "allowed (not for password changes)".
x-requires-sudoPresent when a fresh step-up is needed, which a token cannot do: true, or conditional when only a password change needs it.
x-sourceThe 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.json

    Every 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.json

    One 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.json on 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 (appRevision in the JSON, info.version in 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 from A-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 429 with RateLimit-* 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/me routes; 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 than GET, HEAD or OPTIONS) must also send an Origin (or Referer) header for your instance’s own address, or the gateway answers 403 before the route runs. A request that carries an Authorization header is not subject to that check, so token calls need no Origin.
  • 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_security can 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.json under components.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 be null; 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, object and arrays written as integer[]. A string that is a date and time is written string (date-time) and is ISO 8601 in UTC; string (date) is YYYY-MM-DD. A name such as UserRef is 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, error on the Error object; 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 null as 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 status is listed here as active, expired or revoked, and the token routes can also answer invalidated. Another: an audit-log entry’s level is listed here as info, warning, error or critical, and a stored level can also be warn or debug.
  • 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
FieldTypeNull / read-onlyNote
errorstringno
messagestringno
OkResponsein a reply1 field
FieldTypeNull / read-onlyNote
okbooleanno
Userin a reply35 fields

A directory user. Credential material (password hash, MFA secrets, recovery codes, invite token) is never returned.

FieldTypeNull / read-onlyNote
idintegerread-only
firstNamestringnullable
lastNamestringnullable
emailstring (email)no
sAMAccountNamestringnullableLogin / directory account name (unique).
positionstringnullableJob title.
departmentstringnullable
statusstringnoe.g. active, inactive, disabled, pending, offboarded, deleted
isPrimaryAdminbooleanread-only
sourcestringnolocal or the directory it syncs from
sourceProviderIdintegernullable
photoUrlstringnullable
managerIdintegernullable
externalIdstringnullable
employeeIdstringnullable
phoneNumberstringnullable
personalEmailstringnullable
attributesobjectnoCustom directory attributes.
mfaEnabledbooleanread-only
mfaEnabledAtstring (date-time)nullable, read-only
mustChangePasswordbooleanread-only
passwordLastChangedstring (date-time)nullable, read-only
personaIdintegernullablePersona (role template / bill of materials).
lastActiveAtstring (date-time)nullable, read-only
terminationDatestring (date-time)nullableScheduled last working day; null cancels a scheduled offboarding.
inviteExpiresAtstring (date-time)nullable, read-only
invitedAtstring (date-time)nullable, read-only
failedLoginAttemptsintegerread-only
lockoutUntilstring (date-time)nullable, read-only
sessionsInvalidatedAtstring (date-time)nullable, read-only
GroupsGroupSummary[]read-only
RolesRoleSummary[]read-only
ManagerUserRefnullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
UserCardin a reply7 fields

Minimal picker view of a user (returned to callers without directory permissions).

FieldTypeNull / read-onlyNote
idintegerno
firstNamestringnullable
lastNamestringnullable
emailstring (email)no
positionstringnullable
departmentstringnullable
photoUrlstringnullable
UserRefin a reply4 fields

A labelled user reference.

FieldTypeNull / read-onlyNote
idintegerno
firstNamestringnullable
lastNamestringnullable
emailstring (email)no
UserPagein a reply2 fields
FieldTypeNull / read-onlyNote
countintegernoTotal matching users.
rows(User | UserCard)[]no
UserCreaterequest body16 fields
FieldTypeRequiredNote
firstNamestringoptional
lastNamestringoptional
emailstring (email)required
sAMAccountNamestringoptional
positionstringoptional
departmentstringoptional
managerIdintegeroptional
personaIdintegeroptional
statusstringoptional
sourcestringoptional
employeeIdstringoptional
phoneNumberstringoptional
attributesobjectoptional
roleIdsinteger[]optional
groupIdsinteger[]optional
sendInvitebooleanoptionalEmail a set-password link (recommended for API use).
UserUpdaterequest body15 fields
FieldTypeRequiredNote
firstNamestringoptional
lastNamestringoptional
emailstring (email)optional
sAMAccountNamestringoptional
positionstringoptional
departmentstringoptional
managerIdintegeroptional, null clears it
personaIdintegeroptional, null clears it
photoUrlstringoptional, null clears it
employeeIdstringoptional, null clears it
phoneNumberstringoptional, null clears it
terminationDatestring (date)optional, null clears it
statusstringoptionalNon-terminal statuses only (use Offboard to terminate).
roleIdsinteger[]optionalReplaces the user's roles (requires manage_roles).
groupIdsinteger[]optionalReplaces the user's groups (requires manage_groups).
UserBulkUpdaterequest body3 fields
FieldTypeRequiredNote
userIdsinteger[]required
actionstring: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRolerequired
valueanyoptionalAction argument (manager id, department, persona id, status in active|inactive|disabled, group/role id).
Groupin a reply13 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
sourcestringno
externalIdstringnullable
sourceProviderIdintegernullable
requiresMfabooleannoLegacy mirror of mfaPolicy = require.
mfaPolicystring: inherit | require | exemptno
mfaExemptUntilstring (date-time)nullable
mfaExemptReasonstringnullable
Usersobject[]read-onlyMember ids only.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
GroupSummaryin a reply3 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringno
descriptionstringnullable
GroupWriterequest body6 fields
FieldTypeRequiredNote
namestringoptional
descriptionstringoptional
userIdsinteger[]optionalReplaces the membership.
mfaPolicystring: inherit | require | exemptoptionalRequires manage_security.
mfaExemptUntilstring (date-time)optional, null clears itRequires manage_security.
mfaExemptReasonstringoptionalRequires manage_security.
Rolein a reply8 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
PermissionsPermission[]read-only
Usersobject[]read-onlyMember ids only.
Groupsobject[]read-onlyGroups the role is dynamically bound to.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
RoleSummaryin a reply3 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringno
descriptionstringnullable
RoleWriterequest body5 fields
FieldTypeRequiredNote
namestringoptional
descriptionstringoptional
userIdsinteger[]optional
permissionIdsinteger[]optional
groupIdsinteger[]optional
Permissionin a reply8 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
modulestringnullable
descriptionstringnullable
RolesRoleSummary[]read-only
Usersobject[]read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only

Workflows, runs, devices, audit logs, API tokens

WorkflowNodein a reply3 fields
FieldTypeNull / read-onlyNote
idstringno
typestringno
dataobjectno
Workflowin a reply22 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
versionintegerread-only
isCurrentbooleanread-only
typestringnoonboarding | offboarding | custom type
descriptionstringnullable
createdBystringnullable, read-only
approvedBystringnullable, read-only
approvedAtstring (date-time)nullable, read-only
isActivebooleanread-only
isDefaultbooleanread-only
automationModestring: manual | provisioning | autono
statusstringnodraft | pending | approved | rejected
rejectionReasonstringnullable, read-only
simulationRunbooleanread-only
personaOverridesobjectnoPer-persona exclusions: { "<personaId>": { excluded: [...] } }
nodesWorkflowNode[]no
edgesobject[]no
purgeablebooleanread-onlyWhether this never-used draft may be permanently deleted.
deletedAtstring (date-time)nullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
WorkflowWriterequest body8 fields
FieldTypeRequiredNote
namestringoptional
typestringoptional
descriptionstringoptional
statusstring: draft | pendingoptionalSubmit for approval with pending.
automationModestring: manual | provisioning | autooptional
personaOverridesobjectoptional
nodesWorkflowNode[]optional
edgesobject[]optional
WorkflowRunin a reply18 fields

A run of a workflow (a "task").

FieldTypeNull / read-onlyNote
idintegerread-only
workflowIdintegerno
statusstringno
titlestringno
descriptionstringnullable
currentStepintegerno
assignedTostringnullableQueue that owns the current step.
assignedToUserIdintegernullableThe one person who owns the step, when person-owned.
dataobjectnoRun payload (temporary credentials are never included).
createdBystringnullable
userIdintegernullableThe subject user of the run.
sourceCandidateIdintegernullable
startDatestring (date)nullable
dueDatestring (date)nullable
nodesWorkflowNode[]nullable, read-onlyBuilt server-side. Lists carry id + label/type only.
edgesobject[]nullable, read-onlyOmitted in lists.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
WorkflowRunPagein a reply2 fields
FieldTypeNull / read-onlyNote
rowsWorkflowRun[]no
totalintegerno
WorkflowRunCreaterequest body11 fields
FieldTypeRequiredNote
workflowIdintegerrequired
titlestringrequired
descriptionstringoptional
statusstringoptional
assignedTostringoptional
currentStepintegeroptional
dataobjectoptional
userIdintegeroptional
sourceCandidateIdintegeroptional
startDatestring (date)optional
dueDatestring (date)optional
WorkflowRunUpdaterequest body4 fields
FieldTypeRequiredNote
statusstringoptional
assignedTostringoptional
dataobjectoptional
currentStepintegeroptional
Devicein a reply40 fields

A device (read-only view). The device's agent key is never returned.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
typestringnullable
serialNumberstringnullable
statusstringno
manufacturerstringnullable
userIdintegernullable
assetTagstringnullable
departmentstringnullable
locationstringnullable
costCenterstringnullable
customFieldsobjectnullable
platformstringnullablemacOS | linux | windows
mdmUdidstringnullable
pendingCommandstringnullable
reEnrollAllowedbooleanno
enrollmentApprovedbooleannullablefalse = pending approval; null = grandfathered/trusted.
retiredAtstring (date-time)nullable
retiredReasonstringnullable
hostnamestringnullable
osVersionstringnullable
osBuildstringnullable
architecturestringnullable
chipstringnullable
modelNamestringnullable
cpuCoresintegernullable
memoryBytesintegernullable
diskTotalBytesintegernullable
diskFreeBytesintegernullable
osUpdateAvailablebooleanno
agentVersionstringnullable
agentVersionPinstringnullable
loggedInUserstringnullable
enrolledAtstring (date-time)nullable
lastCheckinAtstring (date-time)nullable
lastCheckinIpstringnullable
inventoryobjectnullableRaw agent/MDM inventory.
OwnerUserRefnullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
DeviceFleetPagein a reply7 fields
FieldTypeNull / read-onlyNote
itemsobject[]no
totalintegerno
pageintegerno
pageSizeintegerno
kpisobjectno
staleDaysintegerno
truncatedbooleanno
AuditLogin a reply11 fields
FieldTypeNull / read-onlyNote
idintegerread-only
levelstringnoinfo | warning | error | critical
actionstringno
detailsstringnullable
payloadobjectnullableStructured context; origin service in payload.source.
userEmailstringnullableActor.
chainHashstringnullableSHA-256 of this row chained to the previous one.
prevHashstringnullable
chainSeqintegernullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
AuditLogPagein a reply7 fields
FieldTypeNull / read-onlyNote
totalintegerno
logsAuditLog[]no
countsobjectno
windowDaysintegerno
retentionDaysintegernullable
limitintegerno
pageintegerno
AuditLogExportin a reply5 fields
FieldTypeNull / read-onlyNote
exportedAtstring (date-time)no
countintegerno
truncatedbooleanno
windowDaysintegerno
logsAuditLog[]no
AuditChainVerificationin a reply6 fields
FieldTypeNull / read-onlyNote
okbooleanno
chainedintegerno
unchainedintegerno
firstBrokenIdintegernullable
brokenTypestringnullable
anchorobjectno
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.

FieldTypeNull / read-onlyNote
idintegerread-only
userIdintegernoOwner.
ownerstringnullable
namestringno
tokenPrefixstringnoNon-secret leading characters (e.g. zx_AbC123), to recognise a token.
scopesstring[]noPermissions granted at creation. At use they are intersected with the owner's current permissions.
createdAtstring (date-time)read-only
expiresAtstring (date-time)no
lastUsedAtstring (date-time)nullable
lastUsedIpstringnullable
revokedAtstring (date-time)nullable
revokedBystringnullable
revokeReasonstringnullable
statusstring: active | expired | revokedno
ApiTokenScopeCatalogin a reply3 fields
FieldTypeNull / read-onlyNote
scopesobject[]no
defaultExpiryDaysintegerno
maxExpiryDaysintegerno
ApiTokenCreaterequest body3 fields
FieldTypeRequiredNote
namestringrequired
expiresInDaysintegeroptional
scopesstring[]requiredSubset of your permissions.
ApiTokenCreatedin a reply1 field

Every field of ApiToken, plus:

FieldTypeNull / read-onlyNote
tokenstringnoThe 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.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
kindstringnocompliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2
frameworkstringnullable
queriesstring[]noData set names. Empty array when none.
filtersobjectnoFilter values keyed by filter name. Empty object when none.
windowobjectnullable{ from, to }
sectionsstring[]nullablesummary | controls | tables
columnsobjectnullableQuery reports only.
groupBystringnullable
variantstringnullableBudget reports only.
lifecycleTypestringnullableleaver | joiner
formatsstring[]nopdf | csv | xlsx | pptx
recipientsstring[]noEmail addresses.
everyHoursintegerno1 to 2160
enabledbooleanno
webhookUrlstringnullable, read-onlyMasked: the host and the last four characters.
webhookConfiguredbooleanread-only
webhookSignedbooleanread-onlyTrue when a signing secret is stored.
webhookSecretRotatedAtstring (date-time)nullable, read-only
webhookSigningSecretstringread-onlyPresent only on the reply that issued it.
lastRunAtstring (date-time)nullable, read-only
lastStatusstringnullable, read-onlyThe status of the last run.
failureAlertedAtstring (date-time)nullable, read-only
ownerstringnullable, read-onlyupdatedBy, or createdBy when it was never edited.
createdBystringnullable, read-onlyEmail address.
updatedBystringnullable, read-onlyEmail address.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
scheduleIdintegernullablenull for a run that no schedule started.
scheduleNamestringnullable
kindstringno
frameworkstringnullable
triggerstringnoscheduled | manual
triggeredBystringnullable
statusstringnorunning | ok | failed | interrupted
errorstringnullableWhy the run failed.
readinessScoreintegernullable
passCountintegernullable
failCountintegernullable
manualCountintegernullable
totalControlsintegernullable
naCountintegernullable
applicableintegernullable
deliveredbooleannoWhether the email was sent.
deliveryErrorstringnullable
deliveryAttemptsintegerno
deliveryModestringnullable
nextRetryAtstring (date-time)nullable
claimedAtstring (date-time)nullable
durationMsintegernullable
observationFromstring (date-time)nullable
observationTostring (date-time)nullable
artifactHashstringnullableSHA-256 of the stored PDF.
csvHashstringnullable
xlsxHashstringnullable
pdfSignatureobjectnullableCarries status: signed | unsigned.
hasPdfbooleanread-onlyA PDF is stored for this run.
hasCsvbooleanread-only
hasXlsxbooleanread-only
sealedbooleanread-onlyThe seal value itself is never returned.
sealMissingbooleanread-onlyA stored file that should carry a seal has none.
canonicalbooleannullable
redactedobjectnullable
rowCountsobjectnoEmpty object when the run stored no row counts.
retainUntilstring (date-time)nullable
legalHoldbooleanno
verifyUrlstringnullable, read-onlynull when the run stored no file hash.
createdAtstring (date-time)read-only
SavedReportin a reply16 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringnoUp to 120 characters.
kindstringnocompliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2
frameworkstringnullable
queriesstring[]noData set names. Empty array when none.
filtersobjectnoFilter values keyed by filter name. Empty object when none.
windowobjectnullable{ from, to }
sectionsstring[]nullablesummary | controls | tables
columnsobjectnullableQuery reports only.
groupBystringnullable
variantstringnullableBudget reports only.
lifecycleTypestringnullableleaver | joiner
formatstringnotable | pdf
createdBystringnullable, read-onlyEmail address.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
runIdintegerread-only
formatstringnopdf | csv
expiresAtstring (date-time)no
revokedbooleanno
viewCountintegerread-only
lastViewedAtstring (date-time)nullable, read-only
createdBystringnullable, read-only
createdAtstring (date-time)read-only
tokenHintstringnullable, read-onlyThe last few characters of the token.
recipientEmailstringnullable
recipientLabelstringnullableUp to 80 characters.
AuditorSharein a reply10 fields

An auditor workspace link. The create reply adds token (string), once.

FieldTypeNull / read-onlyNote
idintegerread-only
frameworkstringno
labelstringnullableUp to 80 characters.
expiresAtstring (date-time)no
revokedbooleanno
viewCountintegerread-only
lastViewedAtstring (date-time)nullable, read-only
createdBystringnullable, read-only
createdAtstring (date-time)read-only
tokenHintstringnullable, read-only
AuditorRequestin a reply10 fields
FieldTypeNull / read-onlyNote
idstringread-only16 hex characters.
shareIdintegerread-onlyThe auditor link it was raised on.
frameworkstringread-only
controlIdstringread-only
notestringread-onlyUp to 500 characters.
statusstringread-onlyopen | resolved
responsestringnullableUp to 500 characters.
respondedBystringnullable, read-only
createdAtstring (date-time)read-only
respondedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
statusstringread-onlyqueued | running | ready | failed | expired
progressintegerread-only0 to 100.
stagestringnullable, read-onlyThe current step, in words.
requestedBystringnullable, read-onlyThe email or username of the administrator who started it.
createdAtstring (date-time)read-only
startedAtstring (date-time)nullable, read-only
completedAtstring (date-time)nullable, read-only
expiresAtstring (date-time)nullable, read-onlyWhen the file is deleted: 24 hours after it became ready.
expiredAtstring (date-time)nullable, read-onlyWhen the file was deleted.
sizeBytesintegernullable, read-onlyThe size of the ZIP.
zipSha256stringnullable, read-onlyThe SHA-256 the downloaded ZIP must have.
summaryobjectnullable, read-only{ totals, datasets: [{ name, rows, incomplete }], notes[] } once ready.
errorstringnullable, read-onlyWhy a failed job failed.
downloadCountintegerread-only
lastDownloadedAtstring (date-time)nullable, read-onlyWhen the current link was used.
EnvironmentProfilein a reply4 fields

The systems the organisation has declared it uses. Answered by the environment profile routes.

FieldTypeNull / read-onlyNote
selectedstring[]noIdentifiers of the declared systems. Empty array when none.
customobject[]noEach { id: string, name: string, category: string }. Empty array when none.
updatedAtstring (date-time)nullable, read-only
updatedBystringnullable, read-onlyThe 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.

FieldTypeNull / read-onlyNote
frameworkstringno
windowDaysintegerno
fromstring (date)no
tostring (date)no
snapshotCountintegernoAutomatic snapshots found in the period.
firstAtstring (date-time)nullableThe first snapshot in the period.
lastAtstring (date-time)nullableThe last snapshot in the period.
heldThroughoutintegernoCount of rows with that verdict.
hadGapsintegernoCount of rows with the verdict Gaps in period.
noDataintegernoCount of rows with the verdict No period data.
incompleteCoverageintegernoCount of rows with that verdict.
rowsobject[]noEach { 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
FieldTypeRequiredNote
legalHoldbooleanoptionalThe state you want. Anything that is not true or false answers 400. Left out, the hold is flipped.
ReportShareCreaterequest body5 fields
FieldTypeRequiredNote
formatstring: pdf | csvoptionalDefault pdf.
confirmCsvbooleanoptionalMust be true to share the CSV; otherwise 400 with code CSV_CONFIRM_REQUIRED.
ttlHoursintegeroptionalDefault 168. 1 to 2160.
recipientEmailstringoptional
recipientLabelstringoptionalUp to 80 characters.
AuditorEvidenceRequestCreaterequest body2 fields
FieldTypeRequiredNote
controlIdstringrequiredA control of the framework the link was made for.
notestringrequiredUp to 500 characters.
EvidenceItemCreaterequest body5 fields
FieldTypeRequiredNote
controlIdstringrequired
urlstringrequiredA link must be http or https.
kindstringoptionalfile for a file already uploaded to this server. Anything else is a link.
labelstringoptional
frameworkstringoptional
SecurityExceptionRequestrequest body2 fields

Asks to accept the risk of one finding until a date.

FieldTypeRequiredNote
justificationstringrequiredEmpty, or only spaces, answers 400 with code JUSTIFICATION_REQUIRED.
acceptedUntilstring (date-time)requiredWhen the acceptance ends. Missing, not a date, or not in the future answers 400 with code EXPIRY_REQUIRED.
SecurityExceptionRevokerequest body1 field
FieldTypeRequiredNote
notestringoptional
FindingsArchiverequest body1 field
FieldTypeRequiredNote
scopestring: all | remote | localoptionalDefault all. Also read from ?scope when the body does not carry it. Any other value answers 400.
AssetCriticalityUpdaterequest body1 field
FieldTypeRequiredNote
criticalitystringrequiredlow | medium | high | crit. critical is accepted and stored as crit.
RemediationHandoffrequest body1 field
FieldTypeRequiredNote
handoffRefstringoptionalA 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.

FieldTypeRequiredNote
providerIdstringoptionalThe connected ticketing system that receives the tickets. Empty switches automatic ticketing off.
projectKeystringoptional

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.

FieldTypeNull / read-onlyNote
idstringread-only16 hex characters.
namestringnoUp to 140 characters.
versionstringnoUp to 20 characters; '1.0' when not sent.
ownerstringnoEmpty string when not set.
linkstringnoEmpty string when not set.
descriptionstringno
frameworksstring[]noUp to 8.
statusstringread-onlydraft | approved
requireAckbooleanno
audienceobjectnullablePresent only once it was set. { kind: 'everyone' } or { kind: 'rule', departments: string[], positions: string[], locations: string[] }.
ackEveryMonthsintegernoPresent only once it was set. 0 to 36.
ackDueDaysintegernoPresent only once it was set. 1 to 180.
signSectionsbooleannoPresent only once it was set.
controlIdsstring[]noPresent only once it was set.
nextReviewDuestring (date)nullable
reviewedAtstring (date)nullable, read-onlySet by approval, never by a write.
reviewedBystringnullable, read-only
draftedBystringnullable, read-only
draftedAtstring (date-time)read-only
templateIdstringnullable
templateVersionstringnullable
hasDocumentbooleanread-onlyWhether the policy has text.
mappedControlsstring[]read-onlyThe controls the policy counts towards.
ackSettingsobjectread-only{ everyMonths: integer | null, dueDays: integer, signSections: boolean, audience: object | null }.
createdBystringnullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
Riskin a reply14 fields
FieldTypeNull / read-onlyNote
idstringread-only16 hex characters.
titlestringnoUp to 140 characters.
descriptionstringnoUp to 1,000 characters.
categorystringno'General' when not sent.
likelihoodintegerno1 to 5
impactintegerno1 to 5
scoreintegerread-onlylikelihood x impact
statusstringnoopen | treating | accepted | closed
treatmentstringno
ownerstringno
controlIdsstring[]noAt most 40.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
createdBystringnullable, 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.

FieldTypeNull / read-onlyNote
idstringread-only16 hex characters.
namestringnoUp to 140 characters.
categorystringno'SaaS' when not sent.
tierstringnolow | medium | high | critical
ownerstringno
notesstringnoUp to 500 characters.
soc2OnFilebooleanno
dataSharedbooleanno
reviewStatusstringnonone | requested | received | reviewed
reviewedAtstring (date)nullable
reviewerstringno
riskDecisionstringnoapproved | conditional | rejected, or an empty string before a decision.
nextReviewDuestring (date)nullable
documentsobjectno{ soc2: boolean, iso: boolean, pentest: boolean, dpa: boolean, questionnaire: boolean }
reviewsVendorReview[]read-onlyAt most 20, oldest first.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
createdBystringnullable, read-only
VendorReviewin a reply11 fields

One entry of a vendor’s review history.

FieldTypeNull / read-onlyNote
idstringread-only12 hex characters.
kindstringread-onlyrequested | completed
atstring (date-time)read-only
bystringnullable, read-only
reviewerstringread-only
dueDatestring (date)nullable, read-onlyOn a requested entry.
decisionstringread-onlyOn a completed entry: approved | conditional | rejected.
riskRatingstringread-onlyOn a completed entry: low | medium | high | critical.
summarystringread-onlyOn a completed entry. Up to 1,000 characters.
documentsobjectread-onlyOn a completed entry: the same five flags as Vendor.documents.
nextReviewDuestring (date)nullable, read-onlyOn a completed entry.
EvidenceItemin a reply8 fields

Evidence a person attached to a control.

FieldTypeNull / read-onlyNote
idstring (uuid)read-only
controlIdstringread-only
kindstringread-onlyfile | link
urlstringread-only
labelstringread-onlyEmpty string when none was given.
sha256stringnullable, read-onlyFor a file: computed by the server from the stored bytes.
addedBystringnullable, read-only
addedAtstring (date-time)read-only
TrustCenterSettingsin a reply10 fields
FieldTypeNull / read-onlyNote
publishedbooleanno
headlinestringnoUp to 140 characters.
introstringnoUp to 1,000 characters.
frameworksstring[]noUp to 8 catalogue names.
showReadinessbooleanno
practicesobject[]noEach { title: string, detail: string }. Up to 20.
autoPracticesbooleanno
contactEmailstringno
updatedAtstring (date-time)nullable, read-only
updatedBystringnullable, read-only
BackupPointin a reply16 fields

One row of GET /compliance/reports/backups.

FieldTypeNull / read-onlyNote
filenamestringread-only
typestringread-onlydump | base
restorablebooleanread-only
fetchbooleanread-onlyPresent only on a backup with no local file: true when it is copied back from your storage before it is restored.
foundInStoragebooleanread-onlyPresent (true) only on a backup found in your storage that this server holds no record of.
sizeBytesintegerread-only
modifiedAtstring (date-time)nullable, read-only
pgVersionstringnullable, read-onlyThe PostgreSQL version the dump was taken from; null when it could not be read.
serverPgMajorintegernullable, read-only
versionCompatiblebooleanread-onlytrue when either version is not known.
localbooleanread-onlyA copy is on this server.
offboxbooleanread-onlyA copy is in your storage.
offboxMissingbooleanread-only
previousDestinationbooleanread-onlyNot sent on a foundInStorage row.
destinationKeystringnullable, read-only
offboxInfoobjectnullable, read-only{ provider: string, bucket: string | null, region: string | null, key: string | null }
BackupAuditEntryin a reply6 fields
FieldTypeNull / read-onlyNote
idintegerread-only
actionstringread-only
detailsstringnullable, read-only
userEmailstringnullable, read-only
levelstringnullable, read-only
createdAtstring (date-time)read-only
WalArchiveSummaryin a reply7 fields

With ?list=1 the reply is { count: integer, segments: WalSegment[] } instead.

FieldTypeNull / read-onlyNote
countintegerread-only
totalBytesintegerread-only
neweststring (date-time)nullable, read-only
oldeststring (date-time)nullable, read-only
providerstringread-only
bucketstringnullable, read-only
regionstringnullable, read-only
WalSegmentin a reply7 fields
FieldTypeNull / read-onlyNote
namestringread-only
sizeBytesintegerread-only
uploadedAtstring (date-time)read-only
keystringnullable, read-only
bucketstringnullable, read-only
regionstringnullable, read-only
providerstringread-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.

FieldTypeNull / read-onlyNote
enabledbooleanno
intervalHoursintegerno1 to 168; 24 when never set.
lastRunAtstring (date-time)nullable, read-only
nextDueAtstring (date-time)nullable, read-onlynull when the schedule is off, has never run, or is already due.
reportDeliverbooleanno
reportRecipientsstringnoEmpty string when none.
externalEnabledbooleanno
externalIntervalHoursintegerno
externalLastRunAtstring (date-time)nullable, read-only
trivyImagesstringnoEmpty string when none.
slaAlertEnabledbooleannotrue when never set.
slaAlertPermissionstringnoThe permission whose holders are emailed.
SecurityIssuein a reply32 fields
FieldTypeNull / read-onlyNote
idstring (uuid)read-only
findingIdstring (uuid)read-only
fingerprintstringread-only
probestringread-only
targetstringread-only
titlestringnullable, read-only
severitystringread-onlyCritical | High | Medium | Low | Inconclusive | Informational; treat any other value as the lowest rank.
cvssVectorstringnullable, read-only
cvssScorenumbernullable, read-only
cveIdstringnullable, read-only
pkgNamestringnullable, read-only
installedVersionstringnullable, read-only
fixedVersionstringnullable, read-only
locationstringnullable, read-only
statusstringnoopen | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive | verified_fixed
statusNotestringnullable
ownerstringnullable
ticketRefstringnullable
ticketProviderstringnullable, read-only
ticketStatusstringnullable, read-only
slaDueAtstring (date-time)nullable, read-only
slaBreachAlertedAtstring (date-time)nullable, read-only
kevListedbooleanread-only
epssScorenumbernullable, read-only0 to 1
confirmedExposurebooleanread-onlytrue only when the issue is KEV-listed and the installed version is known to be older than the fixed one.
regressedbooleanread-only
firstSeenAtstring (date-time)read-only
lastSeenAtstring (date-time)read-only
evidenceobjectnullable, read-only
archivedAtstring (date-time)nullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
SecurityRemediationin a reply17 fields
FieldTypeNull / read-onlyNote
idstring (uuid)read-only
findingIdstring (uuid)nullable, read-only
issueIdstring (uuid)nullable, read-only
findingProbestringnullable, read-only
findingTargetstringnullable, read-only
targetKindstringread-only
targetIdstringnullable, read-only
targetLabelstringnullable, read-only
recommendedPayloadobjectread-only
statusstringread-onlyValues the service writes include proposed, handed_off, resolved and failed.
handoffRefstringnullable, read-only
createdBystringnullable, read-only
timelineobject[]read-only
evidenceobjectnullable, read-only
resolvedAtstring (date-time)nullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerno
firstNamestringnullable
lastNamestringnullable
emailstringno
namestringnoFirst and last name joined; the email address when both are empty.
positionstringnullableJob title.
departmentstringnullable
managerIdintegernullable
managerNamestringnullable
statusstringnullableThe account status, for example active.
employeeIdstringnullableYour own employee number, not the Zonitix id.
photoUrlstringnullable
terminationDatestring (date-time)nullable
hireDatestring (date)nullable
employmentTypestringnullable
workLocationstringnullable
levelstringnullable
remindersEmployeeReminder[]noEmpty for a person who has left.
EmployeeReminderin a reply5 fields

An upcoming date on a file. Worked out on each read, not stored.

FieldTypeNull / read-onlyNote
kindstring: probation | contract | right_to_workno
labelstringno"Probation ends", "Contract ends" or "Right to work expires".
datestring (date)no
daysintegernoDays from today; negative once the date has passed.
overduebooleannotrue when days is below 0.
EmployeeFilein a reply7 fields

GET /user-management/employees/:userId, and the reply of every write on a file.

FieldTypeNull / read-onlyNote
userEmployeeFileUserno
profileEmployeeProfileno
emergencyContactsEmergencyContact[]noIn the stored order.
historyEmploymentEvent[]noThe newest 200 job changes, newest effective date first.
remindersEmployeeReminder[]no
authorityHrAuthoritynoWhich system owns each governed field.
peopleobjectno{ "<user id>": name } for the managers named in history.
EmployeeFileUserin a reply18 fields

The identity half of an employee file.

FieldTypeNull / read-onlyNote
idintegerno
firstNamestringnullable
lastNamestringnullable
emailstringno
namestringno
positionstringnullable
departmentstringnullable
managerIdintegernullable
managerNamestringnullable
statusstringnullable
employeeIdstringnullable
phoneNumberstringnullable
personalEmailstringnullable
photoUrlstringnullable
terminationDatestring (date-time)nullable
personaIdintegernullable
sourcestringnullableWhere the account came from, for example local.
createdAtstring (date-time)no
EmployeeProfilein a reply18 fields

The HR half of an employee file. Every field is null until it is set.

FieldTypeNull / read-onlyNote
preferredNamestringnullable
legalNamestringnullable
nationalitystringnullable
hireDatestring (date)nullable
originalHireDatestring (date)nullable
employmentTypestringnullable
workLocationstringnullable
levelstringnullable
costCenterstringnullable
legalEntitystringnullable
probationEndDatestring (date)nullable
contractEndDatestring (date)nullable
noticePeriodstringnullableFree text, up to 255 characters.
rightToWorkStatusstring: verified | pending | expired | not_requirednullable
rightToWorkVerifiedAtstring (date)nullable
rightToWorkExpiresAtstring (date)nullable
hasDateOfBirthbooleannoThe date itself is never in a file; reveal it with POST /reveal.
hasHomeAddressbooleannoThe address itself is never in a file; reveal it with POST /reveal.
EmergencyContactin a reply4 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringnullablenull only when the stored value cannot be read.
relationshipstringnullable
phonestringnullable
EmploymentEventin a reply13 fields

One job change in a file's history.

FieldTypeNull / read-onlyNote
idintegerno
userIdintegerno
typestringnopromotion | transfer | manager_change | title_change | other for a change a person recorded.
effectiveDatestring (date)no
statusstring: applied | scheduled | cancellednoscheduled until its effective date arrives.
changesobjectno{ "<field>": { from, to } } for each field that changed.
reasonstringnullable
sourcestringnomanual for a change a person recorded.
sourceRefstringnullable
actorEmailstringnullable
appliedAtstring (date-time)nullable
createdAtstring (date-time)no
updatedAtstring (date-time)no
HrAuthorityin a reply7 fields

Which system owns people data. Also the base of GET /employees/hr-settings.

FieldTypeNull / read-onlyNote
modestring: zonitix | externalno
providerIdintegernullableThe connected HR system, when mode is external.
providerNamestringnullable
providerMissingbooleannotrue when mode is external and that connection no longer exists.
fieldsobjectno{ "<field key>": "zonitix" | "external" }: the owner of each governed field.
overridesobjectnoThe per-field choices as saved.
lastSyncobjectnullableWhat 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.

FieldTypeNull / read-onlyNote
idintegerno
effectiveDatestring (date)no
currencystringnoA 3-letter code.
payPeriodstring: annual | monthly | hourlyno
bonusPctnumbernullable
reasonstringnullable
sourcestringnomanual for a record a person typed in.
sourceOfferIdintegernullableThe signed offer the record was carried over from.
actorEmailstringnullable
createdAtstring (date-time)no
voidedAtstring (date-time)nullable
voidedBystringnullable
voidReasonstringnullable
EmployeeDocumentin a reply9 fields

The entry for a stored file. The file itself is fetched from the download route.

FieldTypeNull / read-onlyNote
idintegerno
namestringnoUp to 180 characters.
categorystring: contract | offer | job_change | policy | certificate | review | letter | otherno
visibilitystring: hr | employee | managerno
mimeTypestringnullablenull until the file has been uploaded.
sizeBytesintegerno0 until the file has been uploaded.
expiresAtstring (date)nullable
uploadedBystringnullableThe email address of the person who created the entry.
createdAtstring (date-time)no
DepartureRecordin a reply9 fields

The leaving record on a file.

FieldTypeNull / read-onlyNote
idintegerno
lastDaystring (date)no
departureTypestring: voluntary | involuntary | end_of_contractnullable
reasonCodestringnullableOne of the keys in the reply's reasons list.
rehireEligiblebooleannullable
checklistobject[]noEach { key:string, label:string, done:boolean, doneBy:string|null, doneAt:string (date-time)|null }.
sourcestringno
recordedBystringnullable
createdAtstring (date-time)no
LeaveBalancein a reply16 fields

One per type of time off.

FieldTypeNull / read-onlyNote
typeIdintegerno
namestringno
paidbooleanno
tracksBalancebooleanno
requiresApprovalbooleanno
annualDaysnumberno
allowNegativebooleanno
isActivebooleanno
allowHoursbooleanno
minPartMinutesintegernoThe smallest booking by the hour.
maxPartMinutesintegernullableThe most per booking by the hour.
balancenumbernullablenull for a type that does not track a balance.
pendingnumbernoDays in requests still waiting for a decision.
availablenumbernullablebalance minus pending, to two places; null without a balance.
availableExactnumbernullableThe same figure before rounding.
takennumbernoApproved days that start in the current calendar year.
LeaveRequestin a reply17 fields
FieldTypeNull / read-onlyNote
idintegerno
userIdintegerno
leaveTypeIdintegerno
typeNamestringnullableNot sent in the reply to a cancel.
startDatestring (date)no
endDatestring (date)no
startHalfbooleanno
endHalfbooleanno
startTimestring (HH:MM)nullableSet only for time off by the hour.
minutesintegernullableSet only for time off by the hour.
daysnumbernoWorking days the request uses.
statusstring: pending | approved | declined | cancelledno
notestringnullableUp to 500 characters.
decidedByEmailstringnullable
decidedAtstring (date-time)nullable
decisionNotestringnullable
createdAtstring (date-time)no
LeaveTypein a reply15 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringnoUp to 60 characters; unique without regard to case.
paidbooleanno
requiresApprovalbooleanno
tracksBalancebooleanno
annualDaysnumberno0 to 366.
accrualstring: upfront | monthlyno
accrueFromstring (date)nullable
carryOverMaxDaysnumberno0 to 366.
allowNegativebooleanno
allowHoursbooleanno
minPartMinutesintegerno
maxPartMinutesintegernullable
isActivebooleannofalse for a retired type.
sortOrderintegerno
WorkingCalendarin a reply8 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringno
locationstringnullable
workDaysinteger[]noThe working days of the week.
hoursPerDaynumberno
dayStartstring (HH:MM)no
isDefaultbooleanno
holidaysobject[]noEach { date:string (date), name:string }, earliest first.
Goalin a reply13 fields
FieldTypeNull / read-onlyNote
idintegerno
userIdintegerno
titlestringno
descriptionstringnullable
periodstringnullable
dueDatestring (date)nullable
statusstring: on_track | at_risk | done | droppedno
progressintegerno
weightintegernullable
createdByEmailstringnullable
completedAtstring (date-time)nullable
createdAtstring (date-time)no
updatedAtstring (date-time)no
GoalTemplatein a reply5 fields

An example goal.

FieldTypeNull / read-onlyNote
idintegerno
titlestringno
descriptionstringnullableUp to 2,000 characters.
isActivebooleanno
sortOrderintegerno
ReviewCyclein a reply16 fields
FieldTypeNull / read-onlyNote
idintegerno
namestringno
periodLabelstringnullable
selfDueDatestring (date)nullable
managerDueDatestring (date)nullable
questionsobject[]noEach { key:string, label:string, help:string|null }; at most 12.
includeRatingbooleanno
departmentstringnullablenull when the cycle covers everyone.
peerFeedbackstringnoThe colleague-feedback mode of the cycle.
calibrationbooleanno
ratingsReleasedbooleanno
ratingTargetsobjectnullablenull when the cycle does not use calibration.
goalRatingsbooleanno
statusstringnodraft, open or closed.
launchedAtstring (date-time)nullable
closedAtstring (date-time)nullable
Surveyin a reply10 fields
FieldTypeNull / read-onlyNote
idintegerno
titlestringno
introstringnullable
questionsobject[]noEach { key:string, label:string, type: scale | enps | text }; at most 20.
departmentstringnullablenull when everyone is asked.
statusstringnodraft, open or closed.
closesAtstring (date)nullable
openedAtstring (date-time)nullable
closedAtstring (date-time)nullable
expiredbooleannotrue 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.

FieldTypeNull / read-onlyNote
idintegerno
titlestringno
descriptionstringnullable
urlstringnullable
renewEveryMonthsintegernullable
isActivebooleannofalse for a retired item.
contentstringnullableThe text to read; up to 20,000 characters.
autoAssignbooleanno
dueDaysintegerno
proofstringnoWhat finishing takes; none when nothing is asked.
securityAwarenessbooleanno
securityAwarenessSincestring (date)nullable
topicstringnullableThe compliance topic the item counts under.
topicLabelstringnullable
videoUrlstringnullable
videoRequiredbooleanno
hasVideoFilebooleanno
scormobjectnullable{ title:string|null, launch:string, files:integer, bytes:integer, importedAt:string (date-time)|null }.
audienceobjectnullable{ kind: "everyone" } or { kind: "rule", departments:string[], positions:string[], locations:string[] }; null when people are assigned by hand.
audienceTextstringnoThe same audience in words.
quizTrainingQuiznullable
TrainingQuizin a reply6 fields

A knowledge check as a person sees it: questions and options only.

FieldTypeNull / read-onlyNote
passMarkintegerno
maxAttemptsintegernullableTries per day; null when unlimited.
perTryintegernullableHow many questions one try draws from the pool.
drawstringoptionalPresent on one person's try: identifies the questions drawn. Send it back with the answers.
poolintegeroptionalPresent with draw.
questionsobject[]noEach { id, text:string, options:string[], example? }.
TrainingAssignmentin a reply15 fields
FieldTypeNull / read-onlyNote
idintegerno
courseIdintegerno
userIdintegerno
dueDatestring (date)nullable
statusstringnoassigned or completed.
completedAtstring (date-time)nullable
lastCompletedAtstring (date-time)nullable
expiresAtstring (date)nullableWhen a completion that has to be renewed runs out.
overduebooleannoWorked out on each read: assigned, past its due date, and the item is not retired.
acknowledgedAtstring (date-time)nullable
hasCertificatebooleanno
quizTriesLeftintegernullablenull when tries are not limited or the item was not loaded.
quizTrainingQuiznullableThis try's questions, only while the item is still to be done.
courseTrainingCoursenullable
exemptUntilstring (date)optionalPresent only on an open item the person is excused from; overdue is then false.
LearningPathin a reply10 fields
FieldTypeNull / read-onlyNote
idintegerno
titlestringno
descriptionstringnullable
isActivebooleannofalse for a paused path.
autoAssignbooleanno
autoAssignFromstring (date)nullable
stepsobject[]noEach { courseId:integer, position:integer, title:string, isActive:boolean, dueDays:integer }, in order.
peopleintegeroptionalIn the path list only: active members.
finishedintegeroptionalIn the path list only.
percentintegeroptionalIn the path list only: finished as a whole percentage of people.
PhishingCampaignin a reply20 fields

A practice phishing simulation with its totals.

FieldTypeNull / read-onlyNote
idintegerno
titlestringno
templateKeystringno
difficultystringnullable
subjectstringno
statusstring: draft | pending_approval | approved | sending | sent | cancelledno
createdBystringno
createdByIdintegernullable
approvedBystringnullable
approvedAtstring (date-time)nullable
sendAtstring (date-time)nullable
sentAtstring (date-time)nullable
courseIdintegernullableThe training item a person who clicks is pointed to.
resultsPrunedAtstring (date-time)nullable
recipientsintegerno
sentintegerno
clickedintegerno
reportedintegerno
clickRateintegernullableA whole percentage of emails sent; null when none was sent.
reportRateintegernullableA 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.

FieldTypeRequiredNote
preferredNamestringoptional, null clears it
legalNamestringoptional, null clears it
nationalitystringoptional, null clears it
costCenterstringoptional, null clears it
legalEntitystringoptional, null clears it
noticePeriodstringoptional, null clears it
rightToWorkStatusstring: verified | pending | expired | not_requiredoptional, null clears itAny other value answers 400.
hireDatestring (date)optional, null clears itA real calendar date, or the answer is 400. The same for every date below.
originalHireDatestring (date)optional, null clears it
probationEndDatestring (date)optional, null clears it
contractEndDatestring (date)optional, null clears it
rightToWorkVerifiedAtstring (date)optional, null clears it
rightToWorkExpiresAtstring (date)optional, null clears it
dateOfBirthstring (date)optional, null clears itStored encrypted.
homeAddressstringoptional, null clears itAt 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.

FieldTypeRequiredNote
namestringrequired
relationshipstringoptional
phonestringoptionalAt most 40 characters.
JobChangeCreaterequest body4 fields
FieldTypeRequiredNote
typestring: promotion | transfer | manager_change | title_change | otherrequired
effectiveDatestring (date)optionalDefault today. A later date stores the change as scheduled; it is applied on that day.
changesobjectrequiredAt least one of position, department, level, employmentType, workLocation (strings; null clears) and managerId (a user id; null removes the manager).
reasonstringoptionalAt 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.

FieldTypeRequiredNote
phoneNumberstringoptional, null clears itAt most 40 characters.
personalEmailstring (email)optional, null clears itMust be an email address and must differ from the work email.
preferredNamestringoptional, null clears it
homeAddressstringoptional, null clears itAt most 1000 characters. Stored encrypted.
CompensationCreaterequest body6 fields
FieldTypeRequiredNote
amountnumberrequiredGreater than 0 and at most 10000000000.
currencystringrequiredThree letters; upper-cased.
effectiveDatestring (date)optionalDefault today.
payPeriodstring: annual | monthly | hourlyoptionalDefault annual.
bonusPctnumberoptional0 to 999.
reasonstringoptional
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.

FieldTypeRequiredNote
namestringrequired on createCharacters that cannot be in a file name become spaces; cut to 180 characters.
categorystring: contract | offer | job_change | policy | certificate | review | letter | otheroptionalDefault other.
visibilitystring: hr | employee | manageroptionalDefault hr. employee means HR and the person; manager means HR, the person and their manager.
expiresAtstring (date)optional, null clears it
LeaveRequestCreaterequest body10 fields

Whole or half days over a range, or hours on one day when minutes is sent.

FieldTypeRequiredNote
leaveTypeIdintegerrequiredA type that is in use.
startDatestring (date)required
endDatestring (date)optionalDefault startDate. Not before startDate and at most 366 days after it. With minutes it must be the same day.
startHalfbooleanoptionalThe first day is a half day.
endHalfbooleanoptionalThe last day is a half day.
minutesintegeroptionalTime off by the hour: at least 15, and it must end on the day it starts.
startTimestringrequired with minutesHH:MM.
notestringoptionalAt most 500 characters.
repeatWeeksintegeroptional2 to 12: the same one-day request on that weekday for this many weeks. Weeks that hold no working day are reported in skipped.
replaceIdsinteger[]optionalAt most 20 of your own bookings by the hour that this whole-day request replaces.
LeaveDecisionrequest body2 fields
FieldTypeRequiredNote
decisionstring: approve | declinerequired
notestringrequired for declineAt 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.

FieldTypeRequiredNote
namestringrequiredAt most 60 characters.
tracksBalancebooleanoptionalDefault true. When false, annualDays and carryOverMaxDays are stored as 0.
annualDaysnumberoptional0 to 366. Default 0.
accrualstring: upfront | monthlyoptionalDefault upfront; any other value is stored as upfront.
carryOverMaxDaysnumberoptional0 to 366. Default 0.
paidbooleanoptionalDefault true.
requiresApprovalbooleanoptionalDefault true.
allowHoursbooleanoptionalDefault true.
allowNegativebooleanoptionalDefault false.
minPartMinutesintegeroptional, null clears itThe smallest amount by the hour: 15 to 1440.
maxPartMinutesintegeroptional, null clears itThe most by the hour in one request: 15 to 1440, not less than minPartMinutes.
isActivebooleanoptionalDefault true.
sortOrderintegeroptionalDefault 0.
accrueFromstring (date)optionalCreate 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.

FieldTypeRequiredNote
namestringrequired
locationstringoptional, null clears itThe work location this calendar applies to.
workDaysinteger[]optionalDays of the week as 0 (Sunday) to 6 (Saturday). Default 1 to 5.
holidaysobject[]optionalEach { date:string (YYYY-MM-DD), name:string }, both required. At most 400, and no date twice.
hoursPerDaynumberoptional1 to 24.
dayStartstringoptionalHH:MM.
isDefaultbooleanoptionaltrue makes this the default calendar. The only calendar is always the default.
LeaveBalanceAdjustrequest body3 fields
FieldTypeRequiredNote
leaveTypeIdintegerrequiredA type that tracks a balance.
daysnumberrequiredNot 0, between -366 and 366; a negative number removes days.
notestringrequiredWhy 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.

FieldTypeRequiredNote
titlestringrequired on create
userIdintegeroptionalCreate only: whose goal it is. Default yourself.
descriptionstringoptional, null clears itAt most 2000 characters.
periodstringoptional, null clears itAt most 40 characters.
dueDatestring (date)optional, null clears it
statusstring: on_track | at_risk | done | droppedoptionaldone sets progress to 100.
progressintegeroptional0 to 100. 100 with no status sets status to done.
weightintegeroptional, null clears it0 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.

FieldTypeRequiredNote
namestringrequired
periodLabelstringoptional, null clears itAt most 60 characters.
selfDueDatestring (date)optional, null clears it
managerDueDatestring (date)optional, null clears itNot before selfDueDate.
departmentstringoptional, null clears itLimits the cycle to one department.
questionsobject[]optional1 to 12, each { label:string (required), key?:string, help?:string (at most 300 characters) }. The default questions when left out.
includeRatingbooleanoptionalDefault true.
calibrationbooleanoptionalDefault false. Takes effect only with includeRating.
goalRatingsbooleanoptionalDefault false.
peerFeedbackstring: off | reviewer | personoptionalDefault 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.

FieldTypeRequiredNote
titlestringrequired
introstringoptional, null clears itAt most 2000 characters.
departmentstringoptional, null clears itLimits the survey to one department.
closesAtstring (date)optional, null clears itNot in the past.
questionsobject[]optional1 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.

FieldTypeRequiredNote
titlestringrequired
descriptionstringoptional, null clears itAt most 2000 characters.
urlstringoptional, null clears itAn http or https address, at most 1000 characters.
contentstringoptionalThe text of the course, at most 20000 characters.
proofstring: none | acknowledge | certificate | quizoptionalHow a person marks it done. Default none.
quizobjectoptionalThe knowledge check. With proof quiz it needs at least one question.
renewEveryMonthsintegeroptional, null clears it1 to 120.
dueDaysintegeroptionalDays to finish, 1 to 365. Default 30.
topicstringoptional, null clears itOne of the training topics the instance lists.
autoAssignbooleanoptionalDefault false.
isActivebooleanoptionalDefault true. false retires the course.
videoUrlstringoptional
videoRequiredbooleanoptionalTakes effect only when the course has a video.
audienceobjectoptionalWho autoAssign applies to.
LearningPathWriterequest body5 fields
FieldTypeRequiredNote
titlestringrequired
courseIdsinteger[]required2 to 20 courses in order, each once, none retired.
descriptionstringoptional, null clears itAt most 2000 characters.
isActivebooleanoptionalDefault true.
autoAssignbooleanoptionalDefault false.
PhishingCampaignWriterequest body5 fields
FieldTypeRequiredNote
templateKeystringrequiredOne of the built-in templates.
titlestringoptionalCut to 160 characters. Defaults to the name of the template.
userIdsinteger[]required1 to 2000 active people.
courseIdintegeroptional, null clears itA training course in use, linked to the simulation.
sendAtstring (date-time)optional, null clears itWhen to send.
SensitiveFieldRevealrequest body1 field
FieldTypeRequiredNote
fieldstring: dateOfBirth | homeAddressrequiredAny other value is refused with 400.
HrAuthorityWriterequest body3 fields

Which system holds the people data: this instance, or a connected HR system.

FieldTypeRequiredNote
modestring: zonitix | externalrequiredAny other value is refused with 400.
providerIdintegerrequired for externalA connected HR system; any other id is refused with 400. Not read for zonitix.
fieldsobjectoptionalPer field: the field key, and zonitix or external as its value.
HrRetentionWriterequest body1 field
FieldTypeRequiredNote
monthsintegerrequired0 switches retention off; otherwise a whole number from 6 to 240. Anything else is refused with 400.
CompensationVoidrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredCut to 255 characters; blank is refused with 400.
LeaveApproveManyrequest body1 field
FieldTypeRequiredNote
idsinteger[]required1 to 50 request ids. An id sent twice is handled once.
LeaveDeclineItemrequest body2 fields
FieldTypeRequiredNote
idintegerrequiredA request id.
notestringrequiredThe reason the person reads; cut to 500 characters.
LeaveDeclineManyrequest body1 field
FieldTypeRequiredNote
itemsLeaveDeclineItem[]required1 to 50 items. When any item has no note, the whole call is refused with 400 and nothing is declined.
FeedbackRequestrequest body1 field
FieldTypeRequiredNote
userIdsinteger[]requiredActive colleagues; not the person reviewed and not the reviewer.
ReviewerReassignrequest body1 field
FieldTypeRequiredNote
reviewerIdintegerrequiredAn active person other than the person reviewed.
TrainingCompleterequest body2 fields
FieldTypeRequiredNote
acknowledgedbooleanoptionalMust be true when you finish your own item that asks you to confirm you have read it.
reasonstringoptionalRead when HR marks an item done for someone else; cut to 500 characters.
LibraryAdoptrequest body1 field
FieldTypeRequiredNote
replacebooleanoptionaltrue 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
FieldTypeRequiredNote
audiencestring: everyone | department | peoplerequiredAny other value is refused with 400.
departmentstringrequired for department
userIdsinteger[]required for peopleAt most 2,000 ids.
dueDatestring (date)optionalDefault: 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.

FieldTypeRequiredNote
courseIdintegeroptional
departmentstringoptional
statusstringoptional
TrainingBatchActionrequest body5 fields
FieldTypeRequiredNote
actionstring: remove | due | remind | completerequiredAny other value is refused with 400.
idsinteger[]optionalAt most 2,000 assignment ids. When empty or left out, filter is read.
filterTrainingBatchFilteroptionalRead only when ids is empty or left out.
dueDatestring (date)required for due
reasonstringoptionalCut 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
FieldTypeRequiredNote
emailstringrequiredMatched to an active person without regard to letter case. An address listed twice is used once.
completedAtstring (date)requiredYYYY-MM-DD; not in the future and not more than 5 years ago.
CompletionsImportrequest body2 fields
FieldTypeRequiredNote
sourcestringrequiredWhere the completions come from; at least 3 characters, cut to 200.
rowsCompletionRow[]required1 to 5,000 rows. A row that cannot be used is listed in skipped with its reason.
PathMembersAddrequest body2 fields
FieldTypeRequiredNote
userIdsinteger[]optionalAt most 500 people. Send this or userId.
userIdintegeroptionalOne 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.

FieldTypeRequiredNote
tokenstringoptionalThe link in the email, or the 64-character code at the end of it.
campaignintegeroptionalA 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.

FieldTypeNull / read-onlyNote
idintegerread-only
titlestringno
descriptionstringnullable
departmentstringnullable
statusstring: Draft | Open | Published | Closed | Filled | On HoldnoOnly Published roles are on the careers site.
financeApprovalStatusstring: Pending | Approved | Rejectedread-onlyThe headcount budget gate.
locationstringnullable
employmentTypestringnullable
senioritystringnullable
workModestringnullable
minExperienceMonthsintegernullable
currencystringnullable
salaryRangestringnullable
salaryMinintegernullable
salaryMaxintegernullable
payPeriodstringnullable
competenciesarrayno
requirementsarrayno
applicationQuestionsobject[]noEach has id, label, type, required and options on the careers site.
hiringManagerIdintegernullable
ownerIdintegernullable
personaIdintegernullable
createdByIdintegernullable, read-only
financeApprovedByIdintegernullable, read-only
financeApprovedAtstring (date-time)nullable, read-only
financeRejectionReasonstringnullable, read-only
financeRequestedAtstring (date-time)nullable, read-only
isTalentPoolbooleanread-onlytrue only for the talent-pool requisition, which the list leaves out.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
candidateCountintegerread-onlyIn the list only: every applicant, not the preview.
stageCountsobjectnullable, read-onlyIn the list only: { "<STAGE IN CAPITALS>": integer }; null when the role has no candidates.
Candidatesobject[]read-onlyIn 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-onlyIn the list only. Owner, Creator and FinanceApprover have the same shape.
OfferTemplatesobject[]read-onlyIn the list only: each { id:integer, name:string, createdAt }.
InterviewTemplatesobject[]read-onlyIn the list only: each { id:integer, name:string, roundName:string|null, createdAt }.
Candidatein a reply32 fields

One application to one requisition.

FieldTypeNull / read-onlyNote
idintegerread-only
firstNamestringno
lastNamestringno
emailstringno
phoneNumberstringnullable
resumeUrlstringnullableThe stored file name; fetch the file with GET /recruitment/candidates/:id/resume.
sourcestringnullable
stagestring: Sourced | Screen | Interview | Offer | Hired | Rejectedno
matchScoreintegernullable
aiSummarystringnullable
scoreSourcestringnullableprocessing until the first screen has finished.
calibrationResultobjectnullablenull for an interviewer who has not yet submitted their own scorecard.
parsedProfileobjectnullable
rejectionEmailSentbooleanno
onboardingDataobjectno
applicationAnswersarrayno
knockoutFlagbooleanno
knockoutReasonsarrayno
jobPostingIdintegerno
userIdintegernullableThe linked user account, when there is one.
ownerIdintegernullable
consentAtstring (date-time)nullable
humanReviewRequestedbooleanno
aiNoticeAckAtstring (date-time)nullable
tagsstring[]noEach up to 40 characters.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
technicalFitScoreintegernullable, read-onlyIn the list only: 0 to 100 from the completed scorecards; null without one.
JobJobPostingread-onlyIn the list only, with HiringManager.
Owner{ id:integer, firstName:string|null, lastName:string|null, email:string }nullable, read-onlyIn the list only.
OfferOffernullable, read-onlyIn the list only.
InterviewsInterview[]read-onlyIn 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.

FieldTypeNull / read-onlyNote
idintegerread-only
candidateIdintegerno
salarynumbernullableA 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.
signOnBonusstringnullableIn the candidate list only, under the same rule as salary.
currencystringnullable
payPeriodstringnullable
levelstringnullable
targetBonusPctintegernullable
startDatestring (date)nullable
statusstring: Draft | Sent | Accepted | Declinedno
declineReasonstringnullable
signedAtstring (date-time)nullable
signedManuallyByintegernullable
approvalStatusstring: Pending | Approved | Rejectedno
approverIdintegernullable
termsSetByintegernullableThe person an approval is refused from.
offerTemplateIdintegernullable
supportingDocsobject[]no
documentUrlstringnullable
docusealSubmissionIdstringnullableThe signing submission, once the offer has been sent.
rescindStatusstringnullable
rescindRequestedByintegernullable
rescindReasonstringnullable
rescindApprovedByintegernullable
tokenExpiresAtstring (date-time)nullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
TemplateOfferTemplatenullable, read-onlyIn the candidate list only.
Approver{ id:integer, firstName:string|null, lastName:string|null, email:string }nullable, read-onlyIn the candidate list only.
Interviewin a reply21 fields
FieldTypeNull / read-onlyNote
idintegerread-only
candidateIdintegerno
interviewerIdintegerno
scheduledAtstring (date-time)nullablenull while the candidate has not yet picked one of the proposed times.
feedbackstringnullable"Hidden from your view" on another interviewer's row until you have submitted your own scorecard.
structuredFeedbackobjectnullablenull on a masked row.
scoreintegernullable1 to 5; null on a masked row.
statusstring: Scheduled | Completed | Cancelledno
externalEventIdstringnullable
icsSequenceintegerno
cancelledAtstring (date-time)nullable
cancelReasonstringnullable
sequenceintegernullable
isShadowbooleannullableA shadow interviewer's score does not count towards technicalFitScore.
interviewTemplateIdintegernullable
proposedSlotsarraynoThe times offered for self-scheduling.
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
maskedbooleanoptionaltrue on a row whose score and feedback were hidden from you.
Interviewer{ id:integer, firstName:string|null, lastName:string|null, email:string }nullable, read-onlyIn the candidate list only.
TemplateInterviewTemplatenullable, read-onlyIn the candidate list only, with its Questions.
InterviewTemplatein a reply11 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
roundNamestringnullable
jobPostingIdintegernullablenull for a template that is not tied to one requisition.
sourceJobIdintegernullable
sourceJobTitlestringnullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
QuestionsInterviewQuestion[]noIn order.
usageCountintegerread-onlyIn the list only.
InterviewQuestionin a reply12 fields
FieldTypeNull / read-onlyNote
idintegerread-only
interviewTemplateIdintegerno
questionTextstringno
categorystringnullable
contextstringnullable
idealAnswerstringnullable
weightintegernullableDefault 1.
mustHavebooleannullable
anchorsobjectnullable
orderintegernullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
OfferTemplatein a reply11 fields
FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
bodystringno
docusealTemplateIdintegernullable
jobPostingIdintegernullable
sourceJobIdintegernullable
sourceJobTitlestringnullable
supportingDocsobject[]no
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
usageCountintegerread-onlyIn the list only.
CandidateNotein a reply8 fields
FieldTypeNull / read-onlyNote
idintegerread-only
candidateIdintegerno
authorIdintegernullable
bodystringno
mentionsarrayno
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
Author{ id:integer, firstName:string|null, lastName:string|null, email:string }nullable, read-onlyIn the list only.
CandidateMessagein a reply8 fields

One email to or from a candidate, as the message list returns it.

FieldTypeNull / read-onlyNote
idintegerread-only
directionstring: outbound | inboundno
fromAddrstringnullable
toAddrstringnullable
subjectstringnullable
bodystringnullable
createdAtstring (date-time)read-only
sentByIdintegernullableThe user who sent an outbound message.
Referralin a reply8 fields
FieldTypeNull / read-onlyNote
idintegerread-only
userIdintegernoThe employee who made the referral.
candidateIdintegerno
notesstringnullable
statusstring: Pending | Reviewed | Hired | Rejectedno
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
Candidateobjectread-onlyIn 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.

FieldTypeRequiredNote
titlestringrequired on create
descriptionstringoptional
departmentstringoptional
statusstring: Draft | Open | Published | Closed | Filled | On HoldoptionalOpen and Published are refused with 409 until the headcount budget is approved.
locationstringoptional
employmentTypestringoptional
senioritystringoptional
workModestringoptional
minExperienceMonthsintegeroptional, null clears itA number, or text that is a number: rounded, 0 to 2147483647. Blank is stored as empty; anything else answers 400 INVALID_NUMBER.
currencystringoptional
salaryRangestringoptional
salaryMinintegeroptional, null clears itRead the same way as minExperienceMonths.
salaryMaxintegeroptional, null clears itRead the same way as minExperienceMonths.
payPeriodstringoptional
competenciesarrayoptional
requirementsarrayoptional
applicationQuestionsobject[]optionalThe extra questions on the public application form.
hiringManagerIdintegeroptional, null clears it
ownerIdintegeroptional, null clears it
personaIdintegeroptional, null clears itAssigning 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.

FieldTypeRequiredNote
jobPostingIdintegerrequired
emailstring (email)requiredStored in one normalised spelling. The same email on the same requisition answers 409.
firstNamestringrequired
lastNamestringrequired
phoneNumberstringoptionalStored encrypted.
sourcestringoptional
ownerIdintegeroptionalRead 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.

FieldTypeRequiredNote
idsinteger[]required1 to 200 candidate ids.
actionstring: stage | reject | reassign | tagrequired
stagestring: Sourced | Screen | Interview | Offer | Hired | Rejectedrequired for stageAny capitalisation.
sendEmailbooleanoptionalreject only: the rejection email is sent unless this is false.
reasonstringoptionalreject only.
ownerIdintegeroptional, null clears itreassign only.
addstring[]optionaltag only: labels to add.
removestring[]optionaltag 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.

FieldTypeRequiredNote
addstring[]optional
removestring[]optionalMatched without regard to capitalisation.
CandidateNoteCreaterequest body2 fields
FieldTypeRequiredNote
bodystringrequiredTrimmed and cut to 5000 characters; empty answers 400.
mentionsinteger[]optionalUser ids to notify. Values that are not positive whole numbers, and repeats, are dropped.
CandidateMessageSendrequest body2 fields
FieldTypeRequiredNote
subjectstringrequiredTrimmed and cut to 500 characters.
bodystringrequiredTrimmed. A blank line starts a new paragraph in the email.
InterviewCreaterequest body7 fields
FieldTypeRequiredNote
interviewerIdintegerrequiredA person who is allowed to conduct interviews.
scheduledAtstring (date-time)optional, null clears itText that is not a date answers 400 INVALID_TIME. Without it the interview is created with no time.
sequenceintegeroptionalThe round number. Default 1.
interviewTemplateIdintegeroptional
isShadowbooleanoptionalDefault false.
durationMinsintegeroptionalUsed when checking the interviewer's other bookings.
allowOverlapbooleanoptionaltrue 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.

FieldTypeRequiredNote
interviewerIdintegerrequired400 INTERVIEWER_REQUIRED without it.
slotsstring (date-time)[]requiredAt least two usable times, or the answer is 400 NOT_ENOUGH_SLOTS.
interviewTemplateIdintegeroptional
durationMinsintegeroptional
allowOverlapbooleanoptionalWithout it, times that clash with the interviewer's bookings are dropped, and fewer than two left answers 409 DOUBLE_BOOKED.
isShadowbooleanoptionalDefault false.
ScorecardSubmitrequest body3 fields

Marks the interview Completed.

FieldTypeRequiredNote
scoreintegeroptional1 to 5; anything else answers 400 INVALID_SCORE.
feedbackstringoptional
structuredFeedbackobjectoptionalYour marks and notes per competency. The recommendation is carried inside it.
InterviewQuestionInputrequest body8 fields
FieldTypeRequiredNote
questionTextstringrequired
categorystringoptional
contextstringoptional
idealAnswerstringoptional
weightintegeroptionalDefault 1.
mustHavebooleanoptionalDefault false.
anchorsobjectoptional
orderintegeroptionalDefaults 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.

FieldTypeRequiredNote
namestringrequired on create
descriptionstringoptional
roundNamestringoptional
jobPostingIdintegeroptional, null clears itnull makes the template available to every requisition.
questionsInterviewQuestionInput[]optional
OfferTemplateWriterequest body4 fields

Create and update take the same keys; an update changes only the keys you send.

FieldTypeRequiredNote
namestringrequired on create
bodystringrequired on createThe text of the offer letter.
jobPostingIdintegeroptional, null clears itnull makes the template available to every requisition.
supportingDocsobject[]optionalAt 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.

FieldTypeRequiredNote
salarynumberoptional, null clears itNot negative, or the answer is 400.
currencystringoptional, null clears it
payPeriodstringoptional, null clears it
startDatestring (date)optionalYYYY-MM-DD, or the answer is 400.
levelstringoptional, null clears it
targetBonusPctintegeroptional, null clears it0 to 100; rounded to a whole number.
signOnBonusnumberoptional, null clears itNot negative.
hiringManagerIdintegeroptional, null clears itWritten 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.

FieldTypeRequiredNote
idsinteger[]required1 to 200 candidate ids.
approvebooleanrequiredOnly true approves; any other value refuses.
reasonstringoptionalStored with a refusal.
termsVersionsobjectoptional{ "<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
FieldTypeRequiredNote
ownerIdintegeroptional, null clears itA user id. null, 0 or a missing key leaves the requisition without an owner.
cascadebooleanoptionaltrue 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
FieldTypeRequiredNote
ownerIdintegeroptional, null clears itA user id. null, 0 or a missing key leaves the candidate without an owner.
BudgetRefusalrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredTrimmed; 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
FieldTypeRequiredNote
jobTitlestringrequiredBlank is refused with 400.
jobDescriptionstringoptional
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.

FieldTypeRequiredNote
jobTitlestringrequiredBlank is refused with 400.
CandidateStageWriterequest body1 field
FieldTypeRequiredNote
stagestringrequiredSourced, Screen, Interview, Offer, Hired or Rejected, in any capitalisation.
CandidateRejectrequest body2 fields
FieldTypeRequiredNote
sendEmailbooleanoptionalWhether the candidate is sent the rejection email.
reasonstringoptional
InterviewReschedulerequest body3 fields
FieldTypeRequiredNote
scheduledAtstring (date-time)requiredText that is not a date is refused with 400, code INVALID_TIME.
durationMinsintegeroptional
allowOverlapbooleanoptionalOnly the value true books the time although the interviewer already has an interview then.
InterviewCancelrequest body1 field
FieldTypeRequiredNote
reasonstringoptionalCut to 500 characters.
DraftForRequisitionrequest body1 field

Asks for a generated draft: interview questions, or an offer template.

FieldTypeRequiredNote
jobPostingIdintegeroptionalThe requisition used as context. Without it the draft is written for a general role.
OfferGatePolicyWriterequest body1 field
FieldTypeRequiredNote
requireScorecardsBeforeOfferbooleanrequiredAnything that is not true or false is refused with 400.
OfferApproverequest body1 field
FieldTypeRequiredNote
termsVersionstringoptionalThe 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
FieldTypeRequiredNote
reasonstringoptional
OfferDeclineReasonrequest body1 field
FieldTypeRequiredNote
reasonstringoptionalTrimmed and cut to 2000 characters. Blank, or anything that is not text, clears the stored reason.
OfferRescindRequestrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredBlank is refused with 400, code REASON_REQUIRED.
OfferSendrequest body1 field
FieldTypeRequiredNote
offerTemplateIdinteger | nulloptionalThe 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
FieldTypeRequiredNote
querystringrequiredThe question. The streaming route refuses a blank one with 400.
FeedbackRefinerequest body1 field
FieldTypeRequiredNote
textstringrequiredThe feedback text to tidy.
JobAlertSubscriberequest body4 fields
FieldTypeRequiredNote
emailstringrequiredTrimmed, lower-cased and cut to 200 characters; must look like an address.
consentbooleanrequiredMust be true.
departmentstringoptionalCut to 120 characters. Without it the alert covers every department.
localestringoptionalCut to 8 characters.
JobAlertTokenrequest body1 field
FieldTypeRequiredNote
tokenstringrequiredThe token from the link in the email.
SelfScheduleChoicerequest body1 field
FieldTypeRequiredNote
chosenstring (date-time)requiredOne 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.

FieldTypeNull / read-onlyNote
idintegerno
db_idintegernoThe same number as id; the path parameter of every route that takes :doc_id.
displayIdstringnoINV- followed by the id, padded to three digits.
filenamestringnullable
vendorstringnullableThe vendor name as read from the document.
canonicalVendorstringnullableThe name of the vendor record it is linked to; otherwise the same as vendor.
vendorIdintegernullable
amountnumbernoIn the invoice's own currency; 0 when none was read.
currencystringnoA 3-letter code; USD when none is stored.
currencyDefaultedbooleannotrue when no currency was on the document and your base currency was assumed.
amountBasenumbernoThe amount in your base currency; the entered amount when no conversion is stored.
datestringnoA display date such as "Oct 10, 2026", or "N/A". Use dateIso in code.
dateIsostring (date-time)no
renewalDatestringnoA display date, or "N/A".
categorystringnullable
costCenterstringnullable
departmentstringnullable
projectstringnullable
statusstringnoThe payment status; Pending when none is stored.
approvalStatusstringnoDraft when none is stored. See the approval states on the Finance Operations page.
extractionConfidencenumbernullable
invoiceNumberstringnullable
taxAmountnumbernullable
poNumberstringnullable
glCodestringnullable
customFieldsobjectnoYour own allocation fields; {} when none is set.
matchStatusstringnounmatched when no match has run.
submittedByintegernullable
approvedByintegernullable
paidByintegernullable
rejectionReasonstringnullable
InvoicePagein a reply6 fields

The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.

FieldTypeNull / read-onlyNote
itemsInvoice[]no
totalintegernoEvery invoice matching the filters, not the page.
pageintegernoStarts at 1.
limitintegernoDefault 25, 1 to 200.
pagesintegerno
filteredTotalAmountnumbernoThe sum of every matching invoice in your base currency.
Vendorin a reply11 fields
FieldTypeNull / read-onlyNote
idintegerno
canonicalNamestringno
aliasesstring[]noOther spellings that are matched to this vendor.
categorystringnullable
ownerstringnullable
contractRefstringnullable
contractRenewalstring (date-time)nullable
notesstringnullable
customFieldsobjectno{} when none is set.
totalSpendnumbernoTo two places.
mergedIntoIdintegernullableSet on a vendor that was merged into another.
PurchaseOrderin a reply14 fields

One row of GET /parser/finance/purchase-orders.

FieldTypeNull / read-onlyNote
idintegerno
poNumberstringno
vendorstringnullable
currencystringnoA 3-letter code.
totalAmountnumbernoIn the order's own currency.
totalAmountBasenumbernoIn your base currency: the figure budgets and matching use.
baseConvertedbooleannofalse when no converted value is stored; totalAmountBase is then the entered figure.
statusstringnoopen until the order is closed.
departmentstringnullable
costCenterstringnullable
projectstringnullable
lineCountintegernoIn the list only.
grnCountintegernoIn the list only: goods receipts recorded against the order.
orderedDatestringnullableA display date such as "Oct 10, 2026".
MatchExceptionin a reply14 fields
FieldTypeNull / read-onlyNote
idintegerno
docIdintegernoThe invoice.
displayIdstringno
vendorstringnullable
poNumberstringnullable
exceptionTypestringno
severitystringno
statusstring: open | waived | resolvedno
expectednumbernullable
actualnumbernullable
variancePctnumbernullable
tolerancePctnumbernullable
reasonstringnullable
createdAtstring (date-time)nullable
ApprovalQueueItemin a reply14 fields

One submitted invoice in GET /parser/finance/approvals-queue.

FieldTypeNull / read-onlyNote
db_idintegernoThe invoice id.
displayIdstringno
vendorstringnullable
amountnumberno
amountBasenumberno
currencystringno
matchStatusstringno
poNumberstringnullable
approvalsCountintegernoApprovals recorded so far.
approvalsRequiredintegernoWhat the approval rule for this amount asks for.
approversstring[]noThe names of the people who have approved.
ageDaysintegernullableDays since it was submitted.
submittedAtstring (date-time)nullable
highValuebooleanno
ApprovalRulein a reply6 fields

How many approvers an invoice of a given amount needs.

FieldTypeNull / read-onlyNote
idintegerno
namestringno
minAmountnumbernoIn your base currency.
maxAmountnumbernullablenull for no upper limit.
requiredApprovalsintegerno
isActivebooleanno
GLAccountin a reply5 fields
FieldTypeNull / read-onlyNote
idintegerno
codestringno
namestringno
typestringnullable
isActivebooleanno
Budgetin a reply62 fields

All the money fields are in your base currency except budgetEntered.

FieldTypeNull / read-onlyNote
idintegerno
scopeTypestringnoglobal when the budget covers all spend.
scopeValuestringnullable
labelstringnoscopeValue, or "All spend".
namestringnullable
displayNamestringno
descriptionstringnullable
budgetTypestringno
startDatestring (date-time)nullable
endDatestring (date-time)nullable
periodKindstringnullable
authoritativeSourcestring: invoice | cloud | bothnoWhich actuals drive consumedPct.
currencystringnullableThe currency the budget was entered in.
tagsarrayno
glAccountIdintegernullable
recurrencestringnullable
recurrenceIntervalstringnullable
warnPctnumbernullableThis budget's own warning threshold, as a fraction.
overPctnumbernullable
warnThresholdnumbernoThe threshold in force, as a fraction.
overThresholdnumberno
ownerEmailstringnullable
ownerUserIdintegernullable
ownerDepartmentstringnullable
periodstringno
budgetnumbernoThe budget amount in base currency.
budgetEnterednumbernoAs entered, in currency.
baseCurrencystringno
actualnumbernoPer authoritativeSource.
invoiceActualnumberno
cloudActualnumberno
cloudSyncedbooleanno
cloudSystemCountintegerno
committednumbernoOpen purchase-order value that has not been invoiced yet.
softCommittednumbernoPending and approved spend requests.
availablenumbernobudget minus actual, committed and softCommitted.
remainingnumbernobudget minus actual.
variancenumberno
baselineBudgetnumbernoThe first version of this budget.
varianceVsBaselinenumberno
varianceTypestring: favorable | unfavorableno
consumedPctnumbernoA percentage to one place.
projectednumberno
projectedPctnumberno
onPaceToExceedbooleanno
statusstring: OK | Warning | OvernoThe consumption status.
lifecycleStatusstringnoDraft, Submitted, Approved or Rejected.
pendingReapprovalbooleanno
approvedAmountBasenumbernullable
reapprovalEditorstringnullable
submittedByintegernullable
approvedByintegernullable
rejectionReasonstringnullable
versionNointegerno
parentBudgetIdintegernullable
isCurrentbooleanno
lockedbooleanno
versionLabelstringnullable
methodstringno
hasLinesbooleanno
frozenbooleanno
frozenReasonstringnullable
BudgetLinein a reply12 fields
FieldTypeNull / read-onlyNote
idintegerno
budgetIdintegerno
labelstringno
glAccountIdintegernullable
categorystringnullable
costCenterstringnullable
amountnumberno
justificationstringnullable
driverKeystringnullable
driverQtynumbernullable
unitCostnumbernullable
sortOrderintegerno
BudgetCommentin a reply6 fields
FieldTypeNull / read-onlyNote
idintegerno
budgetIdintegerno
authorIdintegernullable
authorstringnullable
commentstringno
createdAtstring (date-time)nullable
BudgetTransferin a reply22 fields
FieldTypeNull / read-onlyNote
idintegerno
amountnumberno
reasonstringnullable
statusstringno
currencystringnullable
amountBasenumberno
fromAmountnumbernullableWhat leaves the source budget, in its own currency.
fromCurrencystringnullable
toAmountnumbernullable
toCurrencystringnullable
fromBudgetIdintegerno
toBudgetIdintegerno
fromLabelstringno
toLabelstringno
requestedByintegernullable
requestedByEmailstringnullable
requestedAtstring (date-time)nullable
approvedByintegernullable
approvedAtstring (date-time)nullable
rejectionReasonstringnullable
approvalsHaveintegernoDistinct approvers recorded so far.
approvalsNeedintegernoWhat a transfer of this amount requires.
SpendRequestin a reply14 fields
FieldTypeNull / read-onlyNote
idintegerno
requesterEmailstringnullable
departmentstringnullable
budgetIdintegernullable
budgetNamestringnullable
amountnumberno
amountBasenumbernullable
currencystringnullable
descriptionstringnullable
vendorstringnullable
statusstringno
createdAtstring (date-time)nullable
approvedAtstring (date-time)nullable
rejectionReasonstringnullable
BudgetAlertin a reply9 fields
FieldTypeNull / read-onlyNote
idintegerno
budgetIdintegerno
tierstringno
messagestringnullable
consumedPctnumbernullable
ownerEmailstringnullable
ownerDepartmentstringnullable
createdAtstring (date-time)nullable
acknowledgedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerno
displayIdstringnoINV- and the id, padded to three digits.
filenamestringnullable
hasFilebooleannoWhether the uploaded file is stored.
fileContentTypestringnullable
vendorstringnullable
amountnumbernoIn currency.
currencystringnoUSD when none was read.
currencyDefaultedbooleannotrue when the currency was filled from the instance default.
amountAmbiguousbooleannotrue when amountReviewNote is set.
amountReviewNotestringnullable
dateReviewNotestringnullable
submitterNotestringnullable
baseCurrencystringno
amountBasenumbernullable
dateIsostring (date-time)nullable
categorystringnullable
statusstringnullable
approvalStatusstringnoDraft when none is stored.
extractionConfidencenumbernullable
invoiceNumberstringnullable
taxAmountnumbernullable
poNumberstringnullable
matchStatusstringnounmatched when none is stored.
lineItemsobject[]noIn line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }.
rawTextstringnoThe text read from the file; empty when there is none.
PurchaseOrderDetailin a reply15 fields

One purchase order with its lines and goods receipts.

FieldTypeNull / read-onlyNote
idintegerno
poNumberstringno
vendorstringnullable
currencystringno
totalAmountnumbernoIn currency.
totalAmountBasenumbernoIn base currency.
baseConvertedbooleannofalse when no base amount was stored for the order.
statusstringnoopen, partially_received, received, closed or cancelled.
departmentstringnullable
costCenterstringnullable
projectstringnullable
notesstringnullable
orderedDatestringnullableDisplay text such as "Oct 10, 2026".
linesobject[]noIn line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }.
goodsReceiptsobject[]noNewest 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.

FieldTypeNull / read-onlyNote
idintegerno
labelstringno
providerTypestringno
kindstringnullable
systemRefstringnullable
budgetIdintegernullable
meteringstring: live | derived | discoveryno
ownerEmailstringnullable
ownerDepartmentstringnullable
costnumberno0 when the provider could not be read.
forecastnumberno
isSyncedbooleanno
lastSyncstringnullable
BulkOutcomeItemin a reply5 fields
FieldTypeNull / read-onlyNote
idanynoThe id as an integer, or the value you sent when it could not be read as one.
okbooleanno
errorstringnoPresent only when ok is false: the reason this item was not done.
approvalStatusstringnoPresent 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.
kindstring: budget | transfernoPresent 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.

FieldTypeNull / read-onlyNote
actionstringnoThe action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply.
okintegernoHow many items were done.
failedintegernoHow many were not.
resultsBulkOutcomeItem[]noOne entry per distinct valid id, plus one for each value that could not be read as an id.
InvoiceImportSummaryin a reply8 fields
FieldTypeNull / read-onlyNote
importedintegernoRows stored as invoices.
skippedintegernoRows not stored.
errorsstring[]noOne message per skipped row; the first 50 are returned.
currencyDefaultedintegernoRows that named no currency and were given the base currency.
amountAmbiguousintegernoRows held for review because the amount could be read two ways.
dateHeldintegernoRows held for review because the date could not be read with certainty.
duplicatesSkippedintegernoRows skipped because the same vendor and invoice number are already on file or repeated in the file.
duplicatesHeldintegernoRows stored but held for review because another invoice has the same vendor, amount and date.
CurrencyRatein a reply3 fields
FieldTypeNull / read-onlyNote
codestringnoThree letters.
rateToBasenumbernoHow many units of the base currency one unit of code is worth.
updatedAtstring (date-time)noUTC.
CurrencyTablein a reply3 fields
FieldTypeNull / read-onlyNote
baseCurrencystringno
missingRatesarraynoCurrencies 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.
ratesCurrencyRate[]noEvery stored rate, ordered by code.
VendorMergeResultin a reply5 fields
FieldTypeNull / read-onlyNote
successbooleannoAlways true.
keptVendorIdintegerno
repointedintegernoHow many invoices moved to the kept vendor.
skippedLockedarraynoThe invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status.
loserRetainedbooleannotrue when the merged vendor was kept because invoices in a locked period still point at it.
PeriodCloseResultin a reply5 fields
FieldTypeNull / read-onlyNote
periodstringno
statusstringnoAlways locked.
journalsPostedintegernoInvoices that were journalled by this close; one already journalled is not posted again.
batchstringnoThe period, a hyphen and the UTC time of the close to the second.
forcedbooleannotrue when the period was locked over open items.
GLSyncRunin a reply4 fields
FieldTypeNull / read-onlyNote
connectorstringno
pushedintegernoJournal lines marked as synced by this call.
livebooleannofalse for the manual connector.
notestringno
AlertSweepResultin a reply2 fields
FieldTypeNull / read-onlyNote
evaluatedintegernoCurrent budgets looked at.
firedintegernoAlerts raised by this call.
InvoiceReviewrequest body8 fields

Corrections to what was read from an uploaded invoice. Only the keys you send are changed.

FieldTypeRequiredNote
vendorstringoptionalTrimmed; an empty value changes nothing.
categorystringoptionalTrimmed; an empty value changes nothing.
currencystringoptionalUpper-cased and cut to 3 letters.
datestring (date-time)optionalISO 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.
amountnumberoptionalA number, or text in a recognised number format. An entry that could be read two ways is refused with 400.
po_numberstringoptional, null clears itTrimmed, upper-cased and cut to 64 characters.
notestringoptional, null clears itTrimmed and cut to 1000 characters.
commitbooleanoptionaltrue 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.

FieldTypeRequiredNote
costCenterstringoptional, null clears it
departmentstringoptional, null clears it
projectstringoptional, null clears it
glCodestringoptional, null clears itOnce a chart of accounts exists the code must be in it, or the answer is 400.
currencystringoptionalUpper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it.
customFieldsobjectoptionalReplaces 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.

FieldTypeRequiredNote
reasonstringrequired for rejectBlank is refused with 400.
budget_overridebooleanoptionalapprove only: approve although the invoice takes a budget over its limit. Needs approve_invoice.
override_reasonstringrequired with budget_overrideBlank is refused with 400.
commentstringoptionalapprove 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.

FieldTypeRequiredNote
actionstring: submit | approve | reject | pay | clear_review | allocate | deleterequired
idsinteger[]required1 to 500 invoice ids.
allocationInvoiceAllocationoptionalRead for allocate only; without it nothing is changed.
reasonstringrequired for rejectOne reason shared by every invoice.
budget_overridebooleanoptionalapprove only, as in InvoiceAction.
override_reasonstringrequired with budget_override
commentstringoptionalapprove only, as in InvoiceAction; one comment shared by every invoice.
VendorUpdaterequest body9 fields

Only the keys you send are changed.

FieldTypeRequiredNote
canonicalNamestringoptionalA name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one.
categorystringoptional, null clears it
ownerstringoptional, null clears it
ownerUserIdintegeroptional, null clears it
contractRefstringoptional, null clears it
contractRenewalstring (date-time)optional, null clears itISO 8601; text that is not a date is stored as empty.
notesstringoptional, null clears it
aliasesstring[]optionalReplaces the alias list; each alias is normalised the way vendor names are matched.
customFieldsobjectoptionalReplaces 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).

FieldTypeRequiredNote
keystringrequiredCharacters 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.
labelstringoptionalCut to 60 characters. Defaults to the key.
typestring: text | select | number | dateoptionalAny other value is stored as text.
optionsstring[]optionalRead 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.

FieldTypeRequiredNote
baseCurrencystringoptionalThree letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate.
codestringrequired with rateToBaseUpper-cased and cut to 3 letters.
rateToBasenumberrequired with codeHow 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.

FieldTypeRequiredNote
keystring: 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_pctrequiredAny other key is refused with 400.
valueanyrequiredA 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
FieldTypeRequiredNote
descriptionstringoptional
quantitynumberoptionalDefault 0.
unit_pricenumberoptionalDefault 0. The line amount is quantity times unit_price; an amount you send is not read.
PurchaseOrderCreaterequest body9 fields
FieldTypeRequiredNote
po_numberstringrequiredTrimmed and upper-cased. A number that already exists is refused with 409.
vendorstringoptionalTrimmed and matched to a known vendor where one exists.
currencystringoptionalDefault USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it.
linesPurchaseOrderLineInput[]optionalWhen present, the order total is the sum of the lines.
total_amountnumberoptionalRead only when lines is empty. Default 0.
cost_centerstringoptional
departmentstringoptional
projectstringoptional
budget_overridebooleanoptionaltrue creates the order although it takes a budget over its limit; without it that case answers 409.
GoodsReceiptLineInputrequest body2 fields
FieldTypeRequiredNote
po_line_idintegeroptionalThe purchase-order line received against.
quantity_receivednumberoptionalDefault 0.
GoodsReceiptCreaterequest body3 fields
FieldTypeRequiredNote
po_idintegerrequired404 when the purchase order does not exist.
grn_numberstringoptional
linesGoodsReceiptLineInput[]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.

FieldTypeRequiredNote
namestringoptionalAt most 80 characters. Default "Rule".
minAmountnumberoptionalDefault 0. Not negative; at most 999999999999.99.
maxAmountnumberoptional, null clears itnull means no ceiling. Otherwise greater than minAmount and at most 999999999999.99.
requiredApprovalsintegeroptionalDefault 1. A whole number from 1 to 10.
isActivebooleanoptionalDefault 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.

FieldTypeRequiredNote
reasonstringrequiredBlank is refused with 400.
debitGlstringrequiredA general-ledger code. Once a chart of accounts exists it must be in it.
creditGlstringrequiredMust differ from debitGl.
amountnumberrequiredGreater 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.

FieldTypeRequiredNote
periodstringrequired on createYYYY-MM or YYYY.
scopeTypestringoptionalDefault global. A caller without a finance-wide budget permission may send only department, and it is set to their own department.
scopeValuestringoptional, null clears itThe department, cost centre or vendor the budget covers.
amountnumberoptionalDefault 0. A negative number or text that is not a number is stored as 0.
currencystringoptional, null clears itThe currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it.
namestringoptional, null clears it
descriptionstringoptional, null clears it
budgetTypestringoptionalDefault opex.
methodstringoptionalDefault top_down.
startDatestring (date-time)optional, null clears itISO 8601; text that is not a date is stored as empty.
endDatestring (date-time)optional, null clears it
periodKindstringoptional, null clears it
authoritativeSourcestring: invoice | cloud | bothoptionalDefault invoice. An update ignores any other value.
tagsarrayoptionalRead only when it is a list.
glAccountIdintegeroptional, null clears it
recurrencestringoptional, null clears it
recurrenceIntervalstringoptional, null clears it
warnPctnumberoptional, null clears itA fraction; brought inside 0 to 1.
overPctnumberoptional, null clears itA 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.

FieldTypeRequiredNote
labelstringoptionalDefault "Line".
amountnumberoptionalRounded to 2 places. Not read when driverQty and unitCost are both set.
driverKeystringoptionalWith headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one.
driverQtynumberoptional
unitCostnumberoptional
glAccountIdintegeroptional
categorystringoptional
costCenterstringoptional
justificationstringoptional
sortOrderintegeroptionalDefault 0.
BudgetTransferCreaterequest body5 fields
FieldTypeRequiredNote
fromBudgetIdintegerrequired
toBudgetIdintegerrequiredMust differ from fromBudgetId.
amountnumberrequiredGreater than 0.
currencystringoptionalThe currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side.
reasonstringoptional
BudgetApprovalsBulkrequest body4 fields

Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.

FieldTypeRequiredNote
actionstring: approve | rejectrequired
budgetIdsinteger[]optionalTogether with transferIds: at least 1 and at most 500 items.
transferIdsinteger[]optional
reasonstringrequired to reject a budgetStored 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
FieldTypeRequiredNote
budgetIdintegerrequiredA budget in your scope: 404 when it does not exist, 403 when it is outside your scope.
amountnumberrequiredGreater than 0.
currencystringoptionalEmpty means the base currency. 400 when no exchange rate is set for it.
descriptionstringoptional
vendorstringoptional
OwnedSystemClaimrequest body7 fields

Attaches a discovered system to an owner and, optionally, a budget.

FieldTypeRequiredNote
providerTypestringoptionalUpper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery.
externalProviderIdintegeroptionalA provider id from GET /parser/finance/systems/providers.
systemRefstringoptional
labelstringoptionalDefaults to systemRef, then providerType, then "System".
kindstringoptional
budgetIdintegeroptionalA budget you can edit: 404 when it does not exist, 403 when it is not yours to edit.
ownerEmailstringoptionalDefaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner.
DocumentStatusWriterequest body1 field
FieldTypeRequiredNote
statusstringrequiredStored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps.
FinanceQuestionrequest body1 field
FieldTypeRequiredNote
questionstringrequiredTrimmed; blank is refused with 400.
AppBudgetWriterequest body1 field

The yearly budget of one app (vendor), for the current calendar year.

FieldTypeRequiredNote
amountnumberoptionalGreater 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.

FieldTypeRequiredNote
categorystringoptional
ownerstringoptional
VendorBulkUpdaterequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
patchVendorBulkPatchrequired
VendorMergerequest body2 fields
FieldTypeRequiredNote
keepIdintegerrequiredThe vendor that stays.
mergeIdintegerrequiredThe vendor whose invoices and aliases move to keepId.
DigestSendrequest body1 field
FieldTypeRequiredNote
recipientsstring[]optionalEmail addresses. Without it the configured digest recipients are used.
PurchaseOrderStatusWriterequest body1 field
FieldTypeRequiredNote
statusstring: closed | cancelled | openoptionalDefault closed. Any other value is refused with 400.
PurchaseOrderBulkStatusrequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
statusstring: closed | cancelled | openoptionalDefault closed.
MatchExceptionWaiverequest body1 field
FieldTypeRequiredNote
reasonstringrequiredTrimmed; blank is refused with 400.
MatchExceptionBulkWaiverequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once.
reasonstringrequiredOne reason shared by every exception.
GLAccountCreaterequest body3 fields
FieldTypeRequiredNote
codestringrequiredTrimmed. A code that already exists is refused with 409.
namestringoptionalDefaults to the code.
typestringoptionalThe account type, stored as sent.
PeriodCloserequest body2 fields
FieldTypeRequiredNote
forcebooleanoptionaltrue locks the period although it still has open items.
reasonstringrequired with forceTrimmed; blank with force is refused with 400.
GLConnectorWriterequest body1 field
FieldTypeRequiredNote
connectorstring: manual | quickbooks | netsuite | xerorequiredTrimmed and lower-cased. Any other value is refused with 400.
BudgetRejectrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredTrimmed; blank is refused with 400.
BudgetLockrequest body1 field
FieldTypeRequiredNote
lockedbooleanoptionalDefault true. false unlocks, which needs manage_finance_policy.
BudgetFreezerequest body2 fields
FieldTypeRequiredNote
frozenbooleanoptionalDefault true. false lifts the freeze.
reasonstringoptionalStored with a freeze; not kept when the freeze is lifted.
BudgetCopyrequest body1 field
FieldTypeRequiredNote
periodstringrequiredThe period of the copy: YYYY-MM or YYYY.
BudgetCommentCreaterequest body1 field
FieldTypeRequiredNote
commentstringrequiredBlank is refused with 400.
TransferRejectrequest body1 field
FieldTypeRequiredNote
reasonstringoptionalTrimmed; stored as the rejection reason.
SpendRequestDecisionrequest body1 field

Read for the reject and cancel actions only; approve and fulfill read no body.

FieldTypeRequiredNote
reasonstringoptional

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.

FieldTypeNull / read-onlyNote
versionstringnullableNull for an installer uploaded without a version.
filenamestringno
sha256stringnoSHA-256 of the hosted file, 64 hex characters.
sizeBytesintegerno
createdAtstring (date-time)read-onlyWhen it was uploaded.
AgentPackageUploadin a reply8 fields

The reply to an installer upload.

FieldTypeNull / read-onlyNote
platformstringnomacos | linux | windows
versionstringnullable
filenamestringno
sha256stringnoSHA-256 of the stored file, 64 hex characters.
sizeBytesintegerno
sigstringnullableThe vendor release signature carried by a signed bundle.
kidstringnullableThe id of the key that signature was verified with.
signedbooleannoTrue when the upload was a signed bundle.
DeviceSetupStatein a reply2 fields

Which platforms are in use. Answered by the setup-state read.

FieldTypeNull / read-onlyNote
platformsobjectnoKeyed 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.
anyActivebooleannoTrue 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.

FieldTypeNull / read-onlyNote
idintegerread-only
labelstringnullable
expiresAtstring (date-time)no
maxUsesintegerno0 means unlimited.
useCountintegerread-only
usedAtstring (date-time)nullable, read-only
revokedAtstring (date-time)nullable, read-only
createdAtstring (date-time)read-only
statusstringread-onlyrevoked | 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.

FieldTypeRequiredNote
labelstringoptionalCut to 120 characters.
ttlHoursintegeroptionalDefault 24. Held to 1 to 720.
maxUsesintegeroptionalDefault 1. Held to 1 to 500.
DeviceCommandTypein a reply5 fields

One entry of the command catalogue: { types: DeviceCommandType[] }. A fixed list.

FieldTypeNull / read-onlyNote
typestringnolock | uninstall | restart | update_os | rename
labelstringno
platformsstring[]nomacos | linux | windows
destructivebooleannoTrue for uninstall only.
argsobject[]noThe 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).

FieldTypeRequiredNote
typestringrequiredlock | uninstall | restart | update_os | rename
argsobjectoptionalRequired for rename: { name } of letters, digits and hyphens, with no hyphen first or last, at most 63 characters and at most 15 on Windows.
reasonstringoptionalRequired 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.

FieldTypeNull / read-onlyNote
idintegerread-only
deviceIdintegerread-only
typestringnolock | uninstall | restart | update_os | rename
argsobjectnullable
statusstringread-onlyqueued, 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.
requestedBystringnullable, read-only
reasonstringnullable
batchIdstringnullable, read-onlyShared by the commands of one batch call.
resultobjectnullable, read-only{ status, detail } as the agent reported it, or { detail } for a cancelled or expired command.
deliveredAtstring (date-time)nullable, read-only
completedAtstring (date-time)nullable, read-only
createdAtstring (date-time)nullable, read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
okobject[]noEach { deviceId, commandId, noop }: noop is present, as true, only when nothing new was queued.
failedobject[]noBatch call: each { deviceId, error } and, where one applies, pendingCommand. Single-device call: each { deviceId, error, status } and, where one applies, code and pendingCommand.
batchIdstringnullableBatch 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.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
kindstringnostatic | smart
platformstringnullablemacos | linux | windows. Null for a group that is not bound to one platform.
criteriaobjectnullableThe rules of a smart group.
createdBystringnullable, read-only
createdAtstring (date-time)read-only
memberCountintegerread-onlyEvery member.
enforceableCountintegerread-onlyThe 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.

FieldTypeNull / read-onlyNote
idintegerno
hostnamestringnullable
namestringnullable
serialNumberstringnullable
platformstringnullable
ownerNamestringnullable
loggedInUserstringnullable
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.

FieldTypeNull / read-onlyNote
idintegerno
actionstringnoThe audit action name.
levelstringno
userEmailstringnullable
createdAtstring (date-time)no
detailsstringnullable
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.

FieldTypeNull / read-onlyNote
idintegerread-only
profileIdintegerread-only
fromVersionintegernullable, read-onlyThe profile version in force before the rollout.
toVersionintegerread-onlyThe version being rolled out.
ringsobject[]no2 to 5 rings, each { type: "groups", groupIds, label }, { type: "percent", percent, label } or, for the last ring, { type: "all", label }.
currentRingintegerread-onlyThe index of the ring now receiving the version, from 0.
statestringread-onlyrunning | paused | complete | rolled_back | canceled
autoPromotebooleannoDefault true.
promoteAfterHoursintegernoDefault 24. 1 to 720.
ringStartedAtstring (date-time)nullable, read-only
startedBystringnullable, read-only
endedAtstring (date-time)nullable, read-only
endedBystringnullable, read-only
holdReasonstringnullable, 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.

FieldTypeNull / read-onlyNote
idintegerread-only
platformstringnomacos | linux | windows
versionstringnoThe agent version targeted.
enabledbooleannoFalse while the rollout is on hold.
labelstringnullable
scopeobjectno{ type: "all" } or a scope naming groups or devices.
scopeLabelstringread-only
deviceCountintegerread-onlyDevices in scope.
onTargetintegerread-onlyDevices in scope already on that version or a newer one.
createdBystringnullable, read-only
createdAtstring (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.

FieldTypeNull / read-onlyNote
configuredbooleanno
connectedbooleannoWhether the MDM control plane answered.
tenantstringnullable
deviceCountintegernullableNull when the control plane did not answer.
enrollmentUrlstringnullable
enrollmentReadybooleanno
hasCertbooleannoWhether a push certificate is installed.
certNotAfterstringnullableWhen the push certificate expires.
SoftwarePagein a reply4 fields

One page of the software inventory. Paged by ?page and ?pageSize (default 50, 1 to 500).

FieldTypeNull / read-onlyNote
itemsobject[]noEach { name, publisher, deviceCount, platforms, versions: [{ version, count }] }.
totalintegernoEvery title matching the filters.
pageintegerno
pageSizeintegerno
DeviceCreaterequest body10 fields

Adds a device record by hand. Only these keys are read; any other key is ignored.

FieldTypeRequiredNote
namestringrequired
typestringoptional
serialNumberstringoptionalUnique across devices.
statusstringoptionalDefault available.
manufacturerstringoptional
userIdintegeroptionalThe owner, a user id.
assetTagstringoptional
departmentstringoptional
locationstringoptional
costCenterstringoptional
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.

FieldTypeRequiredNote
pendingEnrollmentbooleanoptional
noncompliantbooleanoptional
encryptionOffbooleanoptional
silentDaysintegeroptional, nullable1 to 365. Null turns the alert off.
enrollTokenDaysintegeroptional, nullable1 to 90. Null turns the alert off.
pushCertDaysinteger[]optionalAt most 6 whole numbers, each 1 to 365. Stored without repeats, largest first.
recipientsstringoptionalOnly the value manage_devices is accepted; anything else answers 400.
DeviceStaleSettingUpdaterequest body1 field
FieldTypeRequiredNote
staleDaysintegerrequiredA 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.

FieldTypeRequiredNote
fieldsobject[]optionalLeft 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
FieldTypeRequiredNote
userIdsinteger[]requiredThe users to email. At least one whole number.
ReservationLinkrequest body1 field
FieldTypeRequiredNote
reservationIdintegerrequiredThe id of the reservation record to link the device to.
EnrollmentRejectrequest body1 field
FieldTypeRequiredNote
reasonstringoptionalAt most 234 characters after trimming, or the answer is 400.
DeviceIdsRequestrequest body1 field

The body of the batch approve and unretire calls.

FieldTypeRequiredNote
idsinteger[]required1 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.

FieldTypeRequiredNote
platformstring: macos | linux | windowsoptionalDefault macos.
checkInIntervalMinutesintegeroptionalHeld to 5 to 1440.
allowUserQuitbooleanoptional
minOsVersionstringoptional, null clears itA dotted number such as 14.5, 24.04 or 10.0.22631. An empty string also clears it.
minAgentVersionstringoptional, null clears itA version such as 1.1.0, not above the adopted agent version. An empty string also clears it.
DeviceAssignrequest body6 fields
FieldTypeRequiredNote
userIdintegeroptional, null clears itThe owner, a user id. Null, or leaving the key out, unassigns the owner.
assetTagstringoptionalCut to 255 characters. An empty string clears it; a key left out keeps its value. The same for department, location and costCenter.
departmentstringoptional
locationstringoptional
costCenterstringoptional
customFieldsobjectoptionalValues keyed by custom field key. Keys that are not in the custom field list are dropped.
DeviceBatchAssignrequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped.
userIdintegerrequired, nullableA 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:

FieldTypeRequiredNote
idsinteger[]required1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped.
DeviceRetirerequest body2 fields
FieldTypeRequiredNote
idsinteger[]required1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped.
reasonstringrequired3 to 255 characters after trimming.
DeviceGroupCreaterequest body6 fields
FieldTypeRequiredNote
namestringrequired
descriptionstringoptional
kindstring: static | smartoptionalDefault static: any value other than smart is read as static.
platformstring: macos | linux | windowsoptionalBinds the group to one platform. Any other value leaves it unbound.
criteriaobjectoptionalSmart groups only, where it is required and checked.
deviceIdsinteger[]optionalStatic 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.

FieldTypeRequiredNote
namestringoptionalAn empty name is ignored.
descriptionstringoptionalAn empty string clears it.
criteriaobjectoptionalSmart groups only; ignored on a static group.
DeviceGroupPreviewrequest body3 fields
FieldTypeRequiredNote
criteriaobjectrequiredThe rules to try, checked as for a smart group.
platformstring: macos | linux | windowsoptional
kindstringoptionalWhen sent it must be smart.
DeviceGroupMembersrequest body1 field

The body of the add-members and bulk-remove calls.

FieldTypeRequiredNote
deviceIdsinteger[]requiredAt least one device id.
DeviceProfileCreaterequest body6 fields
FieldTypeRequiredNote
namestringrequired
platformstring: macos | linux | windowsrequiredCannot be changed later.
payloadsobject[]optionalEach { key, settings }. A key the platform does not support, or one that is not available, answers 400.
scopeobjectoptional{ type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }. Default type all.
descriptionstringoptional
enabledbooleanoptionalDefault false.
DeviceProfileUpdaterequest body7 fields

Only the keys you send are changed. The profile version goes up by one when something changed.

FieldTypeRequiredNote
namestringoptional
descriptionstringoptional
payloadsobject[]optionalAs on create.
scopeobjectoptionalAs on create.
enabledbooleanoptional
platformstring: macos | linux | windowsoptionalA profile that has a platform keeps it: a different value answers 400.
rolloutProfileRolloutStartoptionalSaves 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
FieldTypeRequiredNote
idsinteger[]required1 to 100 profile ids. A repeated id is deployed once.
ProfileRolloutStartrequest body3 fields
FieldTypeRequiredNote
ringsobject[]required2 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" }.
autoPromotebooleanoptionalDefault true.
promoteAfterHoursintegeroptional1 to 720. Default 24.
ProfileRolloutSettingsrequest body2 fields

Either key or both.

FieldTypeRequiredNote
autoPromotebooleanoptional
promoteAfterHoursintegeroptional1 to 720.
MdmEnrollCodeCreaterequest body1 field
FieldTypeRequiredNote
reusablebooleanoptionalTrue 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.

FieldTypeRequiredNote
certPemstringoptionalThe Apple push certificate in PEM form. Required unless p12 is sent.
keyPemstringoptionalIts private key in PEM form.
p12stringoptionalA .p12 file holding both. When it is sent, certPem and keyPem are not read. 400 when it cannot be opened.
passphrasestringoptionalThe export password of the .p12.
MdmLockRequestrequest body1 field
FieldTypeRequiredNote
messagestringoptionalPassed 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.

FieldTypeRequiredNote
reasonstringrequiredAt least 3 characters after trimming. The wipe and rotation calls cut it to 500 characters.
AgentRolloutHoldrequest body1 field
FieldTypeRequiredNote
enabledbooleanoptionalTrue 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.

FieldTypeNull / read-onlyNote
idintegerread-only
tokenstringread-onlyThe token value. Only its hash is stored.
labelstringnullable
expiresAtstring (date-time)no
maxUsesintegerno
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.

FieldTypeNull / read-onlyNote
okobject[]noBatch approve: each { deviceId, enrollmentApproved: true }. Batch assign: each { deviceId, userId }, userId null when unassigned.
failedobject[]noEach { 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.

FieldTypeNull / read-onlyNote
okinteger[]noThe ids done. A device already in the state asked for is listed here and nothing about it is changed.
failedobject[]noEach { 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.

FieldTypeRequiredNote
enrollmentTokenstringrequired
serialNumberstringrequiredAt most 255 characters. Spaces at either end are removed.
publicKeystringrequiredThe device's P-256 public key. A key on another curve is refused.
hostnamestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
osVersionstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
osBuildstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
architecturestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
chipstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
modelNamestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
agentVersionstringoptionalThe version of the agent that is running. The check-in offers an update only when a newer version is the target.
loggedInUserstringoptionalStored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as.
platformstringoptionalmacos, linux or windows, in any letter case. At check-in any other value gets the macOS policy.
cpuCoresintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
memoryBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
diskTotalBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
diskFreeBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
osUpdateAvailablebooleanoptionalAlso read: the text true or false, 1 or 0.
inventoryobjectoptionalFree-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.

FieldTypeNull / read-onlyNote
deviceIdintegernoThe 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.

FieldTypeRequiredNote
hostnamestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
osVersionstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
osBuildstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
architecturestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
chipstringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
modelNamestringoptionalA string longer than 255 characters is cut to 255. A value that is not text or a number is ignored.
agentVersionstringoptionalThe version of the agent that is running. The check-in offers an update only when a newer version is the target.
loggedInUserstringoptionalStored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as.
platformstringoptionalmacos, linux or windows, in any letter case. At check-in any other value gets the macOS policy.
cpuCoresintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
memoryBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
diskTotalBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
diskFreeBytesintegeroptionalA whole number, zero or more, or the same written as digits. Any other value is ignored.
osUpdateAvailablebooleanoptionalAlso read: the text true or false, 1 or 0.
inventoryobjectoptionalFree-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.
capabilitiesstring[]optionalWhat the agent supports, for example commands_v2 or enforcement_v1. At most 32 are read; each is lower-cased and cut to 64 characters.
commandResultsAgentCommandResult[]optionalThe outcome of commands delivered on an earlier check-in. The first 50 entries are read.
enforcementAgentEnforcementReportoptionalWhich 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.

FieldTypeRequiredNote
idintegerrequiredThe id of the command, as the check-in delivered it.
statusstring: succeeded | failed | unsupportedrequired
detailstringoptionalCut to 500 characters.
AgentEnforcementReportrequest body3 fields

The enforcement key of a check-in.

FieldTypeRequiredNote
policyHashstringoptionalThe policyHash of the policy the agent applied, from an earlier reply.
appliedstring[]optionalThe policy fields applied. At most 200 are read, each cut to 64 characters.
failedobject[]optionalEach { 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.

FieldTypeNull / read-onlyNote
okbooleannoAlways true.
deviceIdintegerno
commandstringnullableOne 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.
trustBundleobjectnullableThe current list of release-signing keys, or null when the instance holds none.
policyAgentPolicyno
updateAgentUpdatenullableNull unless a version newer than the agentVersion sent is the target for this device.
policyHashstringnoSHA-256 of the policy object, 64 hex characters. Send it back in enforcement.policyHash.
commandsAgentCommand[]noOnly for an agent that declares commands_v2. At most 20 per check-in, oldest first. Each is delivered once.
commandResultsAcceptedbooleannoOnly 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.

FieldTypeNull / read-onlyNote
idintegernoSend it back as the id of the AgentCommandResult.
typestringnoThe command, for example lock, uninstall, restart, update_os or rename.
argsobjectnullableThe 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.

FieldTypeNull / read-onlyNote
versionstringno
urlstringnoWhere to download exactly this version.
sha256stringnoSHA-256 of the file, 64 hex characters.
sigstringnullableThe release signature of this version.
kidstringnullableThe 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.

FieldTypeNull / read-onlyNote
allowUserQuitbooleanno
checkInIntervalMinutesintegerno
manageUpdatesbooleanno
updateAutoCheckbooleannullableNull unless manageUpdates is true.
updateAutoDownloadbooleannullableNull unless manageUpdates is true.
updateInstallSecuritybooleannullableNull unless manageUpdates is true.
updateInstallMacOSbooleannullableNull unless manageUpdates is true.
updateInstallAppsbooleannullableNull unless manageUpdates is true.
manageCompliancebooleanno
complianceFirewallbooleannullableNull unless manageCompliance is true.
complianceDisableSSHbooleannullableNull unless manageCompliance is true.
complianceDisableScreenSharingbooleannullableNull unless manageCompliance is true.
complianceDisableGuestbooleannullableNull unless manageCompliance is true.
complianceDisableAutoLoginbooleannullableNull unless manageCompliance is true.
complianceGatekeeperbooleannullableNull unless manageCompliance is true.
complianceRequireScreenLockbooleannullableNull unless manageCompliance is true.
complianceScreenLockMinutesintegernullableNull unless manageCompliance is true.
complianceAuditdbooleannullableNull unless manageCompliance is true.
complianceTimeSyncbooleannullableNull unless manageCompliance is true.
complianceFail2banbooleannullableNull unless manageCompliance is true.
complianceDefenderbooleannullableNull unless manageCompliance is true.
complianceBitlockerbooleannullableNull unless manageCompliance is true.
AgentRecoveryKeyEscrowrequest body3 fields

A signed call. One key is kept per device and key type; a later call replaces it.

FieldTypeRequiredNote
keyTypestring: bitlocker | filevaultrequiredRead in any letter case.
recoveryKeystringrequired
volumeIdstringoptionalCut to 255 characters.
AgentMdmEnrollRedeemrequest body1 field

Exchanges an enrolment code for the profile to install. code is the only key read.

FieldTypeRequiredNote
codestringrequiredThe 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.

FieldTypeRequiredNote
usernamestringrequiredThe account email. At most 255 characters.
passwordstringrequiredAt most 1024 characters.
consentAcceptedbooleanoptionaltrue 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.

FieldTypeNull / read-onlyNote
authbooleannoAlways true.
tokenstringnoThe 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.

FieldTypeNull / read-onlyNote
termsAcceptanceRequiredbooleannoAlways true. This field tells the three challenge objects apart.
userIdintegerno
termsVersionstringnoThe version of the terms to show.
termsTokenstringnoPresent 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.

FieldTypeNull / read-onlyNote
passwordChangeRequiredbooleannoAlways true.
userIdintegerno
requirementsobjectno{ minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account.
passwordChangeTokenstringnoThe 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.

FieldTypeNull / read-onlyNote
mfaRequiredbooleannoAlways true.
mfaStatusstringnosetup_pending (no factor enrolled yet: enrol one with the token) | challenge_pending (answer with an enrolled factor).
userIdintegerno
mfaTokenstringnoA short-lived token for the enrolment and verification calls. It is not a session.
mfaMethodsobjectnoPresent 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 }.

FieldTypeNull / read-onlyNote
idintegerread-only
usernamestringnoThe account email.
firstNamestringnullable
lastNamestringnullable
rolesstring[]noRole names, held directly or through a group.
groupsstring[]noGroup names.
permissionsstring[]noPermission names, held directly, through a role or through a group.
mfaEnabledbooleannoThe MFA flag stored on the account.
mfaVerifiedbooleannoWhether this session proved a second factor.
mfaRequiredbooleannoWhether the account is required to use one.
supportSessionSupportSessionInfonoPresent only in a support-access session.
SupportSessionInfoin a reply4 fields

Marks a session as a time-boxed support-access session.

FieldTypeNull / read-onlyNote
grantIdstringnullable
scopestringnoread_only | role
roleNamestringnullableThe role granted. Null for read_only.
expiresAtstring (date-time)no
PasswordChangeResultin a reply3 fields

The reply to a forced password change. It opens no session: sign in again afterwards.

FieldTypeNull / read-onlyNote
successbooleannoAlways true.
messagestringno
devicesRevokedintegernoHow 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.

FieldTypeNull / read-onlyNote
validbooleannoAlways true.
emailstringno
firstNamestringnoEmpty string when the account has none.
expiresAtstring (date-time)no
policyobjectno{ 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.

FieldTypeRequiredNote
tokenstringrequiredThe invitation token. At most 128 characters.
passwordstringrequiredAt 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.

FieldTypeNull / read-onlyNote
secretstringnoThe shared secret, base32.
otpauth_urlstringnoThe 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.

FieldTypeNull / read-onlyNote
authbooleannoAlways true.
tokenstringnoA session token that carries the proven second factor.
recoveryCodesstring[]nullableThe 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.

FieldTypeRequiredNote
mfaTokenstringrequiredThe mfaToken of the challenge.
tokenstringrequiredThe current code from the authenticator app. A code is accepted once.
rememberDevicebooleanoptionaltrue 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.

FieldTypeNull / read-onlyNote
authbooleannoAlways true.
tokenstringnoThe session token, also set as the session cookie.
trustedDeviceTokenstringnullableThe 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.

FieldTypeNull / read-onlyNote
challengestringnobase64url. Kept by the server until the registration is verified.
rpobjectno{ name, id }: the instance.
userobjectno{ id, name, displayName }: name and displayName are the account email.
pubKeyCredParamsobject[]noSet by the library.
timeoutintegernoMilliseconds. Set by the library.
attestationstringnonone
excludeCredentialsobject[]noThe passkeys the account already has.
authenticatorSelectionobjectnoresidentKey: preferred, userVerification: required.
PasskeyListin a reply3 fields

The signed-in user’s own second factors.

FieldTypeNull / read-onlyNote
credentialsobject[]noOldest first. Each { id: integer, transports: string[], createdAt: string (date-time) }. The public key is not included.
totpEnrolledbooleannoWhether an authenticator app is enrolled.
recoveryCodesRemainingintegernoUnused 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.

FieldTypeNull / read-onlyNote
idintegerread-only
userAgentstringnullable
ipAddressstringnullable
lastUsedAtstring (date-time)nullable
expiresAtstring (date-time)noAfter this the browser is asked for a second factor again.
createdAtstring (date-time)read-only
currentbooleannoTrue 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 }.

FieldTypeNull / read-onlyNote
okbooleannoAlways true.
emailstringnoThe account that was reset.
passkeysRemovedintegerno
devicesRevokedintegerno
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.

FieldTypeRequiredNote
newPasswordstringrequiredMust 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.

FieldTypeRequiredNote
passwordstringrequiredThe password of the signed-in account.
SignInLinkRequestrequest body1 field

Asks for a sign-in link by email.

FieldTypeRequiredNote
emailstringrequiredAt most 255 characters.
MfaSetupStartrequest body1 field

Every key is optional.

FieldTypeRequiredNote
mfaTokenstringoptionalThe 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.

FieldTypeRequiredNote
tokenstringrequiredThe code the authenticator app shows.
mfaTokenstringoptionalThe 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.

FieldTypeRequiredNote
mfaTokenstringrequiredThe token the sign-in answered when it asked for a second factor.
recoveryCodestringrequired
rememberDevicebooleanoptional

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.

FieldTypeNull / read-onlyNote
enabledbooleanno
protocolstringnooidc | saml
presetstringnookta | entra | google | other. Present only while single sign-on is on.
smtpConfiguredbooleannoWhether 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.

FieldTypeNull / read-onlyNote
enabledbooleanno
protocolstringnooidc | saml. oidc when none is stored.
presetstringnookta | entra | google | other. other when none is stored.
issuerstringnoEmpty string when unset.
clientIdstringnoEmpty string when unset.
clientSecretSetbooleanread-onlyWhether a client secret is stored.
samlobjectno{ idpEntityId: string, idpSsoUrl: string, certs: SsoCertificate[] }
jitobjectno{ enabled, allowedDomains, defaultRoleId, defaultGroupId }: creating an account on first sign-in.
mfaAcrValuesstring[]noEmpty 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.

FieldTypeNull / read-onlyNote
subjectstringnounparseable when the stored certificate cannot be read.
notAfterstringnullableThe expiry as the certificate states it.
fingerprint256stringnullableSHA-256 fingerprint.
expiredbooleanno
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.

FieldTypeNull / read-onlyNote
mfaRequiredbooleanno
mfaStatusstringnosetup_pending | challenge_pending
userIdintegerno
mfaTokenstringno
mfaMethodsobjectno{ passkey: boolean, totp: boolean }
termsAcceptanceRequiredbooleanno
termsTokenstringno
termsVersionstringno
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.

FieldTypeNull / read-onlyNote
schemasstring[]noThe core User schema and the enterprise User extension.
idstringread-onlyThe Zonitix user id, as a string.
userNamestringnoThe userName the identity provider sent, otherwise the account email.
externalIdstringnoWhen set.
nameobjectno{ givenName, familyName, formatted }: empty strings when the account has no name.
displayNamestringnoFirst and last name. Left out when both are empty.
emailsobject[]noOne entry: { value, type: "work", primary: true }.
activebooleannoFalse for an account that is offboarded, disabled, inactive, terminated, archived, deleted or suspended. True for every other status.
titlestringnoWhen set.
phoneNumbersobject[]noWhen set: one entry { value, type: "work" }.
urn:ietf:params:scim:schemas:extension:enterprise:2.0:UserobjectnoAny of department, employeeNumber and manager: { value }, each when set. An empty object otherwise.
metaobjectread-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.

FieldTypeRequiredNote
userNamestringoptionalAt 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.
externalIdstringoptionalAt most 255 characters. null clears it.
emailsobject[]optionalThe entry marked primary is used; otherwise the entry of type work; otherwise the first entry with a value.
nameobjectoptional{ givenName, familyName }
displayNamestringoptionalSplit into first and last name only when no name object is sent.
titlestringoptionalStored as the position.
activebooleanoptional
phoneNumbersobject[]optionalThe 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:Userobjectoptionaldepartment, 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.

FieldTypeNull / read-onlyNote
schemasstring[]noThe core Group schema.
idstringread-onlyThe Zonitix group id, as a string.
displayNamestringnoThe group name.
externalIdstringnoOnly for a group that SCIM created and that has one.
membersobject[]noEach { value: the user id as a string, display, $ref }. Left out when the request excludes members.
metaobjectread-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).

FieldTypeNull / read-onlyNote
schemasstring[]noThe SCIM ListResponse schema.
totalResultsintegernoEvery match, not only this page.
startIndexintegerno
itemsPerPageintegernoThe number of resources in this reply.
Resourcesobject[]noScimUser or ScimGroup objects.
ScimErrorin a reply4 fields

The body of every refused SCIM call.

FieldTypeNull / read-onlyNote
schemasstring[]noThe SCIM Error schema.
statusstringnoThe HTTP status, as a string.
detailstringnoThe reason.
scimTypestringnoPresent 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.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
tokenPrefixstringread-onlyThe first characters of the value, to recognise it by.
createdBystringnullable, read-only
createdAtstring (date-time)read-only
lastUsedAtstring (date-time)nullable, read-only
revokedAtstring (date-time)nullable, read-only
revokedBystringnullable, read-only
familyIdstringread-onlyThe connection the token belongs to. A token made with rotateFromId keeps the family of the one it replaces.
activebooleanread-onlyFalse once revoked.
ownsobjectnullable, read-onlyIn 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.

FieldTypeNull / read-onlyNote
policyobjectno{ onboardOnCreate: boolean, allowGroupCreate: boolean, managedGroupIds: integer[], privilegedAllowedGroupIds: integer[] }
groupsobject[]noEach { 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).

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
statusstringread-onlyactive | completed | archived
scopeTypestringnoall | department
scopeValuestringnullableThe department, for a department scope.
createdBystringread-only
dueDatestring (date)nullable
itemCountintegerread-only
decidedCountintegerread-only
revokeCountintegerread-only
completedAtstring (date-time)nullable, read-only
scheduleIdintegernullable, read-onlyThe recurring schedule that launched it.
lastReminderAtstring (date-time)nullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
AccessReviewCampaignCreaterequest body6 fields

Starts a review and creates its items.

FieldTypeRequiredNote
namestringrequired
descriptionstringoptional
scopeTypestringoptionalall (default) | department
scopeValuestringoptionalThe department. Required for a department scope.
dueDatestring (date)optional
externalSourcestringoptionalnone | 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.

FieldTypeNull / read-onlyNote
idintegerread-only
campaignIdintegerread-only
userIdintegerread-onlyThe person whose access it is.
userEmailstringnullable, read-only
reviewerEmailstringnoWho is asked to decide.
accessTypestringread-onlyrole | group | app | license
accessIdintegerread-onlyThe role or group id. 0 for an application or licence.
accessLabelstringnullable, read-only
providerTypestringnullable, read-only
runIdintegernullable, read-onlyThe workflow run that carries out a revoke.
driftbooleanread-only
decisionstringnopending | keep | revoke
decidedBystringnullable, read-only
decidedAtstring (date-time)nullable, read-only
notestringnullable
revokedbooleanread-only
reroutedFromstringnullable, read-onlyThe reviewer the item was moved away from.
reroutedAtstring (date-time)nullable, read-only
rerouteReasonstringnullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
campaignNamestringnullable, read-only
campaignDueDatestring (date)nullable, read-only
classificationstringread-onlydirectory (a role or a group) | blueprint (an application the person’s persona expects) | drift (an application outside it).
permissionCountintegernullable, read-onlyHow many permissions the role or group carries. Null for an application or licence.
privilegedbooleanread-onlyTrue 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.

FieldTypeNull / read-onlyNote
campaignAccessReviewCampaignno
itemsAccessReviewItem[]no
totalintegernoEvery item of the campaign.
limitintegernoThe limit applied.
offsetintegerno
AccessReviewQueuein a reply6 fields

The pending items assigned to the caller in active campaigns. Paged by ?limit (default 50, 1 to 200) and ?offset.

FieldTypeNull / read-onlyNote
itemsAccessReviewItem[]no
totalintegernoEvery item matching the filter.
limitintegerno
offsetintegerno
filterstringnoThe filter applied. all when the one sent is not recognised.
statsobjectno{ 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.

FieldTypeNull / read-onlyNote
accessTypestringnullablerole | group, or the value sent for any other type. Null when none was sent.
accessIdintegernullableNull when none was sent.
accessLabelstringnullableThe role or group name.
externalbooleannoFalse for a role or group.
permissionsstring[]nullablePermission names, sorted.
permissionCountintegernullable
privilegedbooleanno
AccessReviewSchedulein a reply17 fields

A recurring review. The schedule list is a bare array, newest first, every schedule in one reply.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
scopeTypestringnoall | department
scopeValuestringnullable
externalSourcestringnonone | persona | actual | both. Default both.
cadencestringnomonthly | quarterly (default) | semiannual | annual | custom
intervalMonthsintegernullableFor a custom cadence.
dueDaysintegernoDefault 14.
statusstringnoactive | paused
remindbooleannoDefault true.
autoClosebooleannoDefault true.
skipIfOpenbooleannoDefault false.
nextRunAtstring (date-time)nullable
lastRunAtstring (date-time)nullable, read-only
createdBystringread-only
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
generatedAtstring (date-time)no
scopeTypestringnoall | department
scopeValuestringnullable
externalSourcestringnonone | persona | actual | both
userCountintegerno
totalintegernoItems before the cut.
driftCountintegerno
privilegedCountintegerno
providersActiveintegerno
externalScannedbooleannoTrue when actual usage was read (externalSource actual or both).
cappedbooleanno
itemsobject[]noEach { 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.

FieldTypeNull / read-onlyNote
idintegerread-only
userIdintegernoThe person the access is for.
requestedBystringread-only
itemKindstringnoDefault app.
itemLabelstringno
providerTypestringnullable
justificationstringnullable
customFieldsobjectnullableValues keyed by custom-field key.
statusstringread-onlypending | approved | fulfilled | declined | rejected | failed
approverEmailstringnullable, read-only
decisionCommentstringnullable
decidedAtstring (date-time)nullable, read-only
runIdintegernullable, read-onlyThe workflow run that provisions an approved request.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringnoAt most 80 characters.
typestringnowebhook | splunk_hec | datadog | syslog_tls. Cannot be changed after creation.
typeLabelstringread-only
enabledbooleanno
urlstringnullable
portintegernullable
configobjectnoType-specific settings. Empty object when none.
secretSetbooleanread-onlyWhether a secret is stored.
cursorSeqintegernullable, read-onlyThe sequence number of the last audit event the destination acknowledged.
lastAckAtstring (date-time)nullable, read-only
lastAttemptAtstring (date-time)nullable, read-only
lastErrorstringnullable, read-only
lastErrorAtstring (date-time)nullable, read-only
consecutiveFailuresintegerread-only
nextAttemptAtstring (date-time)nullable, read-only
totalSentintegerread-only
skippedCountintegerread-only
lastSkipobjectnullable, read-only
gapCountintegerread-only
lagEventsintegernullable, read-onlyIn the list only: audit events not yet acknowledged.
lagSecondsintegernullable, read-onlyIn the list only.
createdBystringnullable, read-only
updatedBystringnullable, read-only
createdAtstring (date-time)nullable, read-only
updatedAtstring (date-time)nullable, read-only
LogForwardingHealthin a reply9 fields

The health block of the destination read. { error } alone when the state cannot be read.

FieldTypeNull / read-onlyNote
configuredintegernoDestinations that exist.
enabledintegernoDestinations switched on.
failingintegernoEnabled destinations whose last attempt failed.
staleintegernoEnabled destinations with a backlog and no acknowledgement for 15 minutes.
maxLagEventsintegerno
maxLagSecondsintegerno
degradedbooleannoTrue when any destination is failing or stale.
lastTickobjectnullable{ at: string (date-time), status: worked | idle | failed, summary }: the last delivery sweep. Null before the first one.
destinationsobject[]noEach { 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.

FieldTypeNull / read-onlyNote
activebooleanno
grantSupportAccessGrantnullableThe grant in force. Null when none is active.
accountobjectnullable{ email, status, mfaEnrolled: boolean, invitePending: boolean, lastActiveAt }. Null when no support account exists.
historyobject[]noUp to 20 ended grants, newest first: each a SupportAccessGrant plus endedAt, endReason (revoked | expired) and endedBy.
limitsobjectno{ minHours: 1, maxHours: 72 }
readOnlyPermissionsstring[]noThe permissions a read_only grant carries.
supportMailboxstringnoThe 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? }.

FieldTypeNull / read-onlyNote
grantIdstringread-onlyA UUID.
scopestringnoread_only | role
roleNamestringnullableThe role granted. Null for read_only.
grantedBystringread-only
grantedAtstring (date-time)read-only
expiresAtstring (date-time)read-only
hoursintegerno1 to 72.
reasonstringno5 to 500 characters.
emailstringnoThe support address the invitation went to.
administratorAcknowledgedbooleanread-onlyTrue when the role granted is administrator.
ApiTokenAdminRevokerequest body1 field
FieldTypeRequiredNote
reasonstringoptionalCut to 200 characters and stored as revokeReason. Without it the record says "Revoked by an administrator".
ScimTokenIssuerequest body2 fields
FieldTypeRequiredNote
namestringrequired
rotateFromIdintegeroptionalThe 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.

FieldTypeRequiredNote
fromTokenIdintegeroptionalMove the users and groups of this token’s connection to the token in the path.
unownedbooleanoptionalTrue 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).

FieldTypeRequiredNote
decisionstring: keep | revokerequired
notestringoptional
AccessReviewReassignrequest body1 field

The bulk call takes the same key plus ids (1 to 500 item ids).

FieldTypeRequiredNote
reviewerEmailstringrequiredAn 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.

FieldTypeRequiredNote
namestringrequired
cadencestring: monthly | quarterly | semiannual | annual | customoptionalDefault quarterly; an unknown value is read as quarterly.
intervalMonthsintegeroptionalCustom cadence only. At least 1.
scopeTypestring: all | departmentoptionalDefault all.
scopeValuestringoptionalThe department, for a department scope.
externalSourcestring: none | persona | actual | bothoptionalDefault both.
dueDaysintegeroptionalHeld to 1 to 365. Default 14.
remindbooleanoptionalDefault true.
autoClosebooleanoptionalDefault true.
skipIfOpenbooleanoptionalDefault false.
firstRunAtstring (date-time)optionalDefault now.
LiveAccessRevokerequest body5 fields
FieldTypeRequiredNote
userIdintegerrequired
accessTypestringrequired
accessLabelstringrequired
accessIdintegeroptionalRequired to revoke a role or a group.
providerTypestringoptional
AccessRequestCreaterequest body6 fields
FieldTypeRequiredNote
itemLabelstringrequiredWhat is being asked for.
itemKindstringoptionalDefault app.
providerTypestringoptional
justificationstringoptional
customFieldsobjectoptionalValues keyed by custom field key. Only keys in the custom field list are kept, 500 characters each.
userIdintegeroptionalWho 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).

FieldTypeRequiredNote
approvebooleanoptionalTrue approves. Anything else, or leaving it out, rejects.
commentstringoptional
AccessRequestFieldsWriterequest body1 field

Replaces the whole list of custom fields on the request form.

FieldTypeRequiredNote
fieldsobject[]optionalLeft 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
FieldTypeRequiredNote
namestringrequiredAt most 100 characters.
UserOffboardrequest body3 fields

All keys are optional.

FieldTypeRequiredNote
lastDaystringoptionalThe person’s last day.
immediatebooleanoptionalTrue runs the revoke steps at once, and then reason is required.
reasonstringoptionalAt least 5 characters when immediate is true.
UserOnboardrequest body3 fields

All keys are optional.

FieldTypeRequiredNote
personaIdintegeroptional
startDatestringoptional
managerIdintegeroptional
DottedReportsUpdaterequest body1 field
FieldTypeRequiredNote
managerIdsinteger[]optionalReplaces 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
FieldTypeRequiredNote
permissionIdsinteger[]requiredReplaces the role’s permission set.
OrgSnapshotCreaterequest body1 field
FieldTypeRequiredNote
namestringrequired
PasswordExpiryBatchrequest body1 field
FieldTypeRequiredNote
userIdsinteger[]required1 to 500 user ids. Repeated ids are counted once.
SupportAccessGrantRequestrequest body6 fields
FieldTypeRequiredNote
hoursintegerrequiredA whole number from 1 to 72.
emailstring (email)requiredThe support address the invitation goes to. At most 254 characters.
reasonstringrequired5 to 500 characters after trimming. Recorded in the audit log.
scopestring: read_only | roleoptionalDefault read_only.
roleNamestringoptionalRequired for scope role.
acknowledgeAdministratorbooleanoptionalMust be true when roleName is administrator.
SupportMailboxUpdaterequest body1 field
FieldTypeRequiredNote
emailstringoptionalA 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.

FieldTypeNull / read-onlyNote
idstringnodefault is the organisation-wide policy. Any other id is a policy scoped to groups.
namestringno
minLengthintegerno8 to 128.
requireUppercasebooleanno
requireNumbersbooleanno
requireSymbolsbooleanno
groupIdsinteger[]noThe 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.

FieldTypeNull / read-onlyNote
mfaEnabledbooleannoThe organisation-wide default that groups set to inherit follow.
passwordPoliciesPasswordPolicy[]no
passwordHistoryCountintegerno0 to 24.
namingConventionstringno[first_name].[last_name] when none is stored.
emailDomainstringnoEmpty string when unset.
conflictStrategystringnonumeric when none is stored.
sessionTimeoutstringnoA duration such as 1h, which is the value when none is stored. 5m to 24h.
auditLogRetentionobjectno{ days, source, minDays, maxDays }: the retention in force and its limits.
auditLogRetentionDaysintegerno90 to 3650.
workflowRequireSeparateApproverbooleannoTrue unless switched off.
workflowRequireSeparateRunApproverbooleannoTrue unless switched off.
reportRecipientPolicystringnoopen when none is stored.
reportRecipientDomainsstringnoEmpty string when unset.
awsMappingEnabledbooleannoA module switch. False when never set.
azureMappingEnabledbooleannoA module switch.
gcpMappingEnabledbooleannoA module switch.
securityLabEnabledbooleannoA module switch.
complianceEnabledbooleannoA module switch.
reportingEnabledbooleannoA module switch.
financeEnabledbooleannoA module switch.
activeAiModelstringnoThe model the local AI uses. Always one on the allow-list.
aiModelRoutingobjectnoEmpty object when unset.
aiEnabledbooleannoTrue unless switched off.
aiRequireUsePermissionbooleannoFalse unless switched on.
smtpConfiguredbooleannoTrue when a mail host, user and password are all stored.
brandingobjectno{ companyName: string, logoUrl: string or null, primaryBrandColor: string }
itNotificationRecipientsstringnoRestricted.
hrNotificationRecipientsstringnoRestricted.
smtpHoststringnoRestricted.
smtpPortintegernoRestricted.
smtpUserstringnoRestricted.
smtpFromstringnoRestricted.
smtpSecurebooleannoRestricted.
smtpPassSetbooleannoRestricted. 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.

FieldTypeNull / read-onlyNote
enabledbooleannoDefault false.
modestringnoallowlist | blocklist. Default blocklist.
countriesstring[]noDefault empty.
blockedIpsstring[]noDefault empty.
allowedIpsstring[]noDefault empty.
blockUnknownGeobooleannoDefault false.
dryRunbooleannoDefault false.
autoBlockobjectno{ enabled (default false), dryRun (default true), threshold (default 6), windowSec (default 600), banSec (default 21600), patterns, tuning }
geoDbLoadedbooleanread-onlyAdded 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.

FieldTypeNull / read-onlyNote
idintegerread-only
namestringno
descriptionstringnullable
typestringnoThe kind of system, for example OKTA or AWS.
configobjectnoThe connection settings, without secret values.
configuredCredentialsstring[]read-onlyThe names of the secrets that are set.
isActivebooleannoDefault true.
canReadUsersbooleannoDefault false.
canReadGroupsbooleannoDefault false.
canWriteUsersbooleannoDefault false.
canWriteGroupsbooleannoDefault false.
canReadAllResourcesbooleannoDefault false.
capabilitiesSetAtstring (date-time)nullable, read-only
isVerifiedbooleanread-onlySet by the validation call.
lastSyncstring (date-time)nullable, read-only
lastValidationobjectnullable, read-onlyThe result of the last validation.
lastValidatedAtstring (date-time)nullable, read-only
syncCountintegerread-only
syncIntervalintegernoDefault 24.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
providerIdintegerread-only
syncTypestringnullable
statusstringnullable
summarystringnullable
detailsobject[]noEmpty array when none.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idstringread-onlyA UUID. This is the id the rollback call takes.
actionTypestringread-only
targetEmailsstring[]read-only
sourceOfTruthstringread-only
priorStateobjectread-onlyThe values before the change.
newStateobjectread-onlyThe values after it.
statusstringread-onlySUCCESS when written without another value.
adminUserstringread-onlyWho ran it.
createdAtstring (date-time)read-only
updatedAtstring (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.

FieldTypeNull / read-onlyNote
idintegerread-only
statusstringread-onlypending when created.
requestedBystringread-only
reasonstringnullable
sourceOfTruthstringno
targetsstring[]no
emailsstring[]no
userCountintegerread-only
approverEmailstringnullable, read-only
decisionCommentstringnullable
decidedAtstring (date-time)nullable, read-only
appliedAtstring (date-time)nullable, read-only
resultobjectnullable, read-only
errorstringnullable, read-only
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
CloudResourceMapin a reply5 fields

The stored resource map of one cloud hook. The same shape serves the three clouds.

FieldTypeNull / read-onlyNote
providerIdintegernoThe hook the map belongs to.
nodesobject[]noThe resources found.
edgesobject[]noThe relations between them.
billingDataobjectnullable
lastUpdatedstring (date-time)noWhen 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.

FieldTypeNull / read-onlyNote
idstringread-onlyA UUID.
providerIdintegerread-only
snapshotDatestring (date-time)read-only
metadataobjectnoEmpty object when none.
nodesobject[]noDetail read only.
edgesobject[]noDetail read only.
billingDataobjectnullableDetail read only.
AiModelStatusin a reply7 fields

Whether one local AI model is downloaded and loaded.

FieldTypeNull / read-onlyNote
modelstringnoThe model asked about.
pulledbooleannoWhether it is downloaded.
readybooleannoWhether it is loaded and can answer.
statestringnoready | loading | pulling | engine_unreachable
reachablebooleannoWhether the model engine answered.
previousResidentbooleannullableWhether the model named by ?previous is still in memory. Null when the engine is unreachable.
consumersobject[]noThe features that use the local AI, each { key, label, detail, permission }.
AiModelCapabilityin a reply3 fields

Which models this server can run.

FieldTypeNull / read-onlyNote
boxobjectno{ cores, ramGB, gpu, cpuParamCap }: the server as the AI service sees it.
modelsobject[]noEach { id, label, sizeB, ramGB, fits: boolean, reason }.
defaultModelstringno
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.

FieldTypeNull / read-onlyNote
idintegerread-only
userIdintegerread-onlyAlways the caller.
typestringnoinfo when none was set.
messagestringno
linkstringnullableWhere in the app the notification points.
readAtstring (date-time)nullableNull while unread.
candidateIdintegernullable
createdAtstring (date-time)read-only
updatedAtstring (date-time)read-only
WorkflowRejectrequest body1 field
FieldTypeRequiredNote
reasonstringrequiredNot empty.
WorkflowBulkDecisionrequest body3 fields
FieldTypeRequiredNote
idsinteger[]required1 to 200 workflow ids. A repeated id is processed once.
actionstring: promote | rejectrequired
reasonstringoptionalRequired, and not empty, when action is reject.
WorkflowClonerequest body1 field
FieldTypeRequiredNote
namestringoptionalThe name of the copy, trimmed to 120 characters. Left out, the copy is named after its source with " (copy)" added.
WorkflowRetirerequest body1 field
FieldTypeRequiredNote
retiredbooleanoptionalTrue retires the pipeline. Anything else, or leaving it out, reinstates it.
WorkflowRunRecallrequest body2 fields
FieldTypeRequiredNote
targetNodeIdstringrequiredThe id of a human step the run has already passed.
reasonstringoptional
WorkflowNodeExecuterequest body2 fields

Only these two keys are read. The action, provider and target come from the run’s stored steps.

FieldTypeRequiredNote
nodeIdstringrequiredThe id of the step to run.
taskDataobjectoptional
WorkflowRunSchedulerequest body1 field
FieldTypeRequiredNote
startDatestring (date)optional, nullableA real YYYY-MM-DD date. Null, or leaving the key out, clears it.
CredentialRevealrequest body1 field
FieldTypeRequiredNote
nodeIdstringrequiredThe 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 (default active), source (default local), employeeId, phoneNumber, attributes, roleIds, groupIds, personaId.
  • The one hard-required field is conditional: password is required only when source is explicitly "local" (else 400 “Password is required for local users (or send an invitation instead).”, unless the body carries sendInvite: true). A user created without a password gets an unusable random hash and mustChangePassword: 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

If any 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

A duplicate 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 (gate manage_roles). Body { permissionIds: number[] } — replaces the role’s permission set. Response { ok: true }.
  • Permission side — POST /user-management/permissions/:id/assignments (gate manage_permissions). Body { roleIds?: number[], userIds?: number[] } — sets which roles and users hold this one permission. Direct userIds grants 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 the Authorization: Bearer token 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 as 400 { 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 be https:// — else 400 “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, complianceBitlocker and peers; complianceScreenLockMinutes (int, clamped 1–60).
  • Minimum versions: minOsVersion (a dotted number such as 14.5; anything else is 400) and minAgentVersion (such as 1.1.0; refused with 400 when 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 (gate request_system_updates). Body { mode }, required, one of backup, security, full, os-security, os-full (anything else → 400 “Invalid update mode…”). Always answers 202.
  • POST /compliance/reports/update-approve (gate approve_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.

StyleHow it worksExample (default, maximum)Reply
limit + offsetPage 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 + pagePage 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 + pageSizeA page number that starts at 1 and a page size.GET /user-management/devices/software (50, at most 500){ items, total }
cursor + limitSend 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 + countThe 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 400 for 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 count or total, 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 sort or order parameter. Each list comes back in the order its handler sets: users by highest id first, 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=score or ?sort=name on the candidate list.
  • Filters are plain query parameters. Each is named after the thing it filters (status, stage, level), and free text is search or q depending on the route. There is no shared filter expression syntax; the SCIM endpoints follow the SCIM standard’s own filter parameter 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/parser and /api/parser, and evidence files are at /api/v1/evidence, /api/v1/auth/evidence and /auth/evidence. Use the /api/v1 form 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 410 with a body that says what replaced it, rather than 404. No Deprecation or Sunset response header is sent ahead of that.
  • The version you call is the version you run. The OpenAPI file at /api/docs/openapi.json is the one shipped with the release installed on your instance, so it changes when that release does. Its info.version is the fixed text 1.0.0 and 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 needs view_system: current.tag (the release name, or null), current.commit, current.committedAt and current.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

}

}

MethodPathPurposeAuth
POST/auth/loginExchange 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/logoutInvalidate 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/meReturn 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/refreshRefresh 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/changeChange 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/sudoStep-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/requestEmail 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/verifyRedeem 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-termsAccept 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/statusReport 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/unlockCheck 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/completeCreate 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/brandingPublic 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 }

MethodPathPurposeAuth
POST/auth/mfa/setup/initBegin 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/confirmConfirm 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/verifyVerify 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-recoveryComplete 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/regenerateInvalidate 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/generateGet 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/verifyVerify 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/generateGet 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/verifyVerify 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/optionsStart 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/verifyFinish 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/credentialsList 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/:idRemove 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/availableWhether 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/optionsGet 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/verifyVerify 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/devicesList 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/allRevoke 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/:idRevoke 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/:userIdAdministratively 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/:userIdAdministratively 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" }

MethodPathPurposeAuth
GET/auth/settings/securityRead 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/securityUpdate 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/brandingSave 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/testSend 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-controlRead 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-controlUpdate 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-cacheForce 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/logWrite 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/historyList 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/:idOne 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/historySave 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/:idDelete 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/activeList 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/activeCreate 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/:idEnd 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/clusterAggregate 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/healthLiveness 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

}

]

}

MethodPathPurposeAuth
GET/user-management/usersList 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/searchTypeahead 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/statsAggregate 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/exportExport 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/usersCreate 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/:idUpdate 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/:idDelete 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/offboardStart 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-treeOrg 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-rollupsRolled-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-vacanciesOpen 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-reportsSet 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/groupsList 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/groupsCreate 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/:idUpdate 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/:idDelete a group.
body No body.
returns 204 no content | 404 not found
Session + manage_groups
GET/user-management/rolesList 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/rolesCreate 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/:idUpdate 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/:idDelete 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/permissionsReplace 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/permissionsList 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/assignmentsSet 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/:userIdGrant 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/:userIdRevoke 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/personasList 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/personasCreate 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/:idUpdate 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/:idDelete a persona.
body No body.
returns 204 no content
Session + manage_users | manage_personas
POST/user-management/personas/:id/provisioning-planGenerate 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-planPreview 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-snapshotsList 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-snapshotsTake 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/diffDiff 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/:idFetch 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/:idDelete 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/licenseCurrent 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/provisionRetired. 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/syncForce 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/resetClear 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/plansVendor 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/scheduleRead 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/scheduleUpdate 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/syncRun 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/modelsAvailable 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/scheduleRead 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/scheduleUpdate 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/syncRun 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/notificationsYour 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/readMark 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-allMark 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.

MethodPathPurposeAuth
GET/user-management/workflowsList 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/workflowsCreate 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/:idUpdate 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/:idDelete 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/promoteApprove 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/rejectReject 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-runValidate 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/exportExport 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/importImport 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-decisionPromote 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/cloneCopy 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/retireTake 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/defaultChoose 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/purgePermanently 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/tasksList 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/notificationsBounded 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/tasksStart 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/:idOne 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/claimMark 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/recallSend 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/:idUpdate 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/:idDelete 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-nodeExecute 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/kickoffRun 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/scheduleMove 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/evidenceThe 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-credentialShow 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 }

]

MethodPathPurposeAuth
GET/user-management/providersList 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/providersCreate 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/:idUpdate 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/:idDelete 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/testTest 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/validateValidate 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/syncRun 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/logsSync 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/:typePull 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/freebusyCalendar 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-eventsUser-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-summaryWhich 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-summaryWhich 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-appsId, 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/usersUsers 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/driftScan 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/trendDrift 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/orphansAccounts 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/compareField-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/policyRead 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/policyUpdate 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-fileRead 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-fileUpload 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-fileRemove 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/syncWrite 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-syncWrite 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/historyHistory 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/:idRoll 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-requestsList 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/approveApprove 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/rejectReject 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/summarySaaS 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/appsOne 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/usersPer-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 }.

MethodPathPurposeAuth
GET/compliance/metrics/overviewDashboard 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/tcoTotal-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-logsMost 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-statsLog 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/statsHeadline 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/assessmentsRecord 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/assessmentsList 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/:idDrill 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/trendReadiness 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-statesCurrent 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/refreshRe-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/:controlIdManually 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/attestRecord 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/submitSubmit 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/reviewReview 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-passWithdraw 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-automatedHand 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-policyRead 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-policyChange 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/attestationsRead-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/attestationsRetired. 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/attestationsRetired. Legacy attestation delete.
body Ignored.
returns 410 Gone { message, governedPath } — nothing is deleted
Session + manage_compliance
GET/compliance/reports/compliance/assignmentsList 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-assignmentsControls 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/assignmentsAssign 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/assignmentsRemove 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/sendEmail 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/remediationsList 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/remediationsCreate 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/remediationsDelete 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/configRead 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/configRetired. 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/sendRetired. 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/auditWrite 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-healthLive 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-statusApplication (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-patchesHost 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-statusState 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-requestRequest 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-approveApprove 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-rejectReject 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-statusState 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-configRead 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-configSave 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-archivesList 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-requestRequest 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-approveApprove 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-destinationRead 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-destinationConfigure 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/testVerify 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-setupMandatory 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/verifyCheck 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-providersList 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/devicesCompliance 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/devicesRegister 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/uploadUpload 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/:filenameDownload 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".

MethodPathPurposeAuth
POST/auth/security-lab/launchLaunch 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/resultsLatest 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/assetsDiscovered 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/scopesList 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/scopesDefine 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/scansCreate 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/scansList 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/:idScan 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/reportExport 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/abortAbort 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/:idUpdate 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/retestRetest 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": { ... } }

]

MethodPathPurposeAuth
GET/auth/aws-resources/:providerIdCurrent 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/:providerIdList 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/:idOne 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/:providerIdWhat 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/:providerIdIdentity-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/:providerIdExecute 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-invoicesPull 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/:providerIdCurrent 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/:providerIdList 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/:idOne 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/:providerIdWhat 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/:providerIdAzure/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/:providerIdExecute 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-invoicesPull 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/:providerIdCurrent 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/:providerIdList 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/:idOne 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/:providerIdWhat 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/:providerIdGCP 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/:providerIdExecute 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" }

]

MethodPathPurposeAuth
GET/recruitment/job-postingsList 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/optionsRequisition 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-postingsCreate 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/:idUpdate 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/ownerReassign 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/:idDelete 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-financeIndependent 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-financeRefuse 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-budgetSend 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/activityActivity 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/suggestSuggest 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/suggestDraft 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.

MethodPathPurposeAuth
GET/recruitment/candidatesList 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/summaryPipeline 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-optionsOwner 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/bulkApply 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-decisionApprove 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/candidatesAdd 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-screenScreen 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/rescreenRe-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-resumeParse 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-statusOnboarding 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/historyThis 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/messagesEmail 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/messagesSend 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/stageMove 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/tagsAdd 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/notesInternal 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/notesAdd 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/rejectReject 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/calibrateCalibrate 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-emailSend 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/ownerReassign 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/:idPurge 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-exportExport 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/resumeStream 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

]

}

]

MethodPathPurposeAuth
GET/recruitment/interviews/:idInterview 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/interviewsSchedule 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/proposePropose 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/rescheduleMove 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/cancelCancel 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/scorecardSubmit 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-templatesList 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-templatesCreate 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/:idUpdate 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/:idDelete 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-generateGenerate 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 }.

MethodPathPurposeAuth
GET/recruitment/settings/offer-gatesRead 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-gatesTurn 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-termsSet 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-approvalOffers 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-documentDownload 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-offerApprove 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-offerRefuse 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-offerGenerate 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-signedRecord 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-offerWithdraw 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-reasonRecord 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-requestRequest 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-approveApprove 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-templatesList 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-templatesCreate 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/:idUpdate 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/:idDelete 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-draftDraft 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-documentsUpload 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/:storedNameDownload 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

}

MethodPathPurposeAuth
POST/recruitment/chatRecruitment 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/streamRecruitment 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/refineRefine 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/referralsList 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/referralsSubmit 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/pipelinePipeline 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/:idCost/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/hiringConsolidated 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/insightsLLM-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-settingsRead 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-settingsUpdate 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

MethodPathPurposeAuth
GET/recruitment/public/jobsPublic 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/:idOne 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/jsonldOpen 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/jsonldOne 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.xmlJob 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.xmlJob 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/careersCareers-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/:jobIdSubmit 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-generalSubmit 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-alertsSubscribe 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/confirmConfirm 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/unsubscribeWithdraw 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/brandingCompany 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/:tokenCandidate 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/:tokenCandidate 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/:tokenCandidate 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/:docIdCandidate 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/hardwareCandidate 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/docusealZonitix 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-emailInbound-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.

MethodPathPurposeAuth
POST/parser/parseParse 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-documentsDocuments 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/timelineHistory 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/invoicesPaginated, 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/exportExport 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.xlsxExport 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.pdfExport 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/importBulk-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/statsParsing 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/healthIs 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_idOne invoice in full.
returns 200 the InvoiceDetail object | 404 'Document not found'
Session + view_finance | manage_finance (licensed)
GET/parser/finance/documents/:doc_id/fileThe 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/reviewCorrect 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/allocationAllocate 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/:actionApprove 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/bulkApply 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_idRe-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_idDelete 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/statusUpdate 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-resumeExtract 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-textExtract 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": "..." }

MethodPathPurposeAuth
GET/parser/finance/insightsLocal-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/askNatural-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/varianceSpend 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/commitmentsOpen 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-trendSpend 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-spendSpend 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/budgetSet 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/connectorsFinance 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/vendorsVendor 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_idUpdate 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-updateSet 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/mergeMerge 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-fieldsRead the vendor custom-field schema.
returns 200 { fields } — every field definition; not paged
Session + view_finance | manage_finance (licensed)
POST/parser/finance/vendor-custom-fieldsReplace 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-fieldsRead the allocation custom-field schema.
returns 200 { fields } — every field definition; not paged
Session + view_finance | manage_finance (licensed)
POST/parser/finance/allocation-custom-fieldsReplace 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-valuesValues 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/categoriesSpend categories in use.
returns 200 { categories:string[] } (sorted); not paged
Session + view_finance | manage_finance | submit_invoice (licensed)
GET/parser/finance/currencyRead 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/currencyUpdate 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/:codeDelete 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/settingsRead 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/settingsChange 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/digestThe 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/sendSend 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/auditFinance 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.csvControls-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" }

MethodPathPurposeAuth
GET/parser/finance/purchase-ordersList 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-ordersCreate 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_idOne 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/closeClose, 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-closeClose, 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-receiptsRecord 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-exceptionsInvoice-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/waiveWaive 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-waiveWaive 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-rulesList 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/previewShow 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-rulesCreate 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_idChange 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_idDelete 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-queueSubmitted 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/approvalsApproval 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-accountsList 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-accountsCreate 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/:periodMonth-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/:periodLock 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.csvExport 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/adjustmentsPost 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/reconciliationReconciliation 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-syncOutbound 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/connectorChoose 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/:periodPush 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/chargebackChargeback 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

}

]

}

MethodPathPurposeAuth
GET/parser/finance/budgetsList 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/budgetsCreate 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_idUpdate 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_idDelete 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/summaryOrganisation-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.csvBudget, 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-inboxBudget 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/alertsBudget 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/sweepRun 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/ackAcknowledge 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/auditAudit 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.csvAudit 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/submitSubmit 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/approveApprove 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/rejectReject 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/reforecastCreate 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/lockLock 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/freezeFreeze 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/copyCopy 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/actualsActual 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/versionsVersion 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-accuracyForecast 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-recordsApproval 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/linesLine 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/linesAdd 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_idChange 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_idDelete 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/commentsComments 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/commentsComment 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/transfersBudget 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/transfersRequest 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/approveApprove 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/rejectReject 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/bulkApprove 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-requestsSpend 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-requestsRequest 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/:actionApprove, 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/systemsSystems 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/providersProviders 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/systemsLink 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_idUnlink 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/anomaliesMonth-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.

MethodPathPurposeAuth
POST/ai/score-candidateScore 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-streamSame, 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-competenciesGenerate 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-questionsGenerate 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-templateDraft 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-scorecardsCalibrate 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-insightsSummarise 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/chatGeneral 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-textRewrite 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-reportNarrative 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-justificationDraft 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-workflowDraft 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-fieldsExtract 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-descriptionDraft 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-messageDraft 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-streamThe 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-queryTurn 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-queryTurn 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-combosFlag 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-findingsSuggest 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-findingExplain 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-statusWhether 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-capabilityThe 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/healthAI 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

}

MethodPathPurposeAuth
GET/user-management/logsQuery 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/verifyVerify 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/exportExport 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/logsAlways 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/devicesList 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/devicesRegister 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" }

MethodPathPurposeAuth
POST/agent/enrollFirst-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/heartbeatSigned 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/pkgDownload 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/binaryDownload 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/installThe 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/binaryDownload 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/installThe 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-keyAn 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-redeemRedeem 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/devicesView 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 }.

MethodPathPurposeAuth
GET/user-management/devicesThe 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/devicesCreate 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/:idOne 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/liteA 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/summaryFleet 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-policyThe 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/activityThe 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/trendsDaily 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/coverageWhich 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/alertsDevice alert settings.
returns { pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured }
Session + view_devices
PUT/user-management/devices/settings/alertsChange 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/recentAlerts 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/staleDays 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/staleSet 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-fieldsYour own device fields.
returns { fields }
guard the whole field list in one reply (no paging)
Session + view_devices
POST/user-management/devices/custom-fieldsReplace 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-invitesEmail 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-pkgUpload 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-pkgWhether 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-pkgsEvery 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/downloadDownload 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-stateWhich 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/ownerData-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/ownerData-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/suggestionsNew-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-reservationConfirm 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-suggestionMark 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-tokensList 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-tokensIssue 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/revokeRevoke 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-enrollmentApprove 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-enrollmentReject 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/approveApprove 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-reenrollLet 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/policyRead 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/policyChange 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/assignAssign 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/assignAssign 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/catalogThe 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/commandsA 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/commandsQueue 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/commandsQueue 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/:commandIdCancel 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/retireRetire 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/unretireBring 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/:idDelete 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/groupsDevice 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/groupsCreate 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/previewCount 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/:idRename 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/:idDelete 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/usageThe 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/devicesThe 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/membersAdd 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/:deviceIdRemove 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-removeRemove 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/profilesConfiguration 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/profilesCreate 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/:idChange 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/:idDelete 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/deployDeploy 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-deployDeploy 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/rolloutStart 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/rolloutThe 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/rolloutsThe 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/rolloutChange 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/promoteMove 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/pausePause 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/resumeResume 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/rollbackRoll 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/statusApple 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-codeCreate 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/pushcertUpload 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/lockRemote-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/wipeRemote-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/softwareSoftware 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-keysWhich 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/revealReveal 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/rotateAsk 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-rolloutsAgent 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/holdHold 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

}

MethodPathPurposeAuth
GET/identity-admin/api-tokensList 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/scopesThe 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-tokensCreate 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/revokeRevoke 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/allEvery 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-revokeRevoke 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 }

MethodPathPurposeAuth
GET/auth/sso/statusWhether 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/loginStart 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/callbackOIDC 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/pendingThe 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/loginStart a SAML sign-in. The same handler as /auth/sso/login.
returns as /auth/sso/login
Public (rate-limited)
POST/auth/sso/saml/acsSAML 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/metadataService-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/configRead 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/configSave 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-metadataParse 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": []

}

MethodPathPurposeAuth
GET/scim/v2/ServiceProviderConfigSCIM 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/UsersList 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/UsersCreate 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/:idRead 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/GroupsList 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/:idRead 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/tokensList 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/tokensIssue 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/revokeRevoke 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/rebindMove 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/ownershipCount of SCIM-provisioned users and groups that have no owning connection.
returns 200 { unowned: { users, groups } }
Session + manage_security
GET/user-management/scim-provisioning/settingsRead 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"]

MethodPathPurposeAuth
GET/user-management/access-reviewsList 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-reviewsStart 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/closeClose 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-queueThe 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/reviewersThe 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-detailThe 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/decideKeep 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/reassignHand 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/schedulesRecurring 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/schedulesCreate 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/liveCurrent 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/revokeRevoke 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-requestsRequest 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/mineYour 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-requestsAll 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/decideApprove 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-fieldsThe 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-fieldsReplace 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/departmentsThe 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/departmentsAdd 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

MethodPathPurposeAuth
GET/user-management/users/:id/exportExport 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/templateDownload 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/previewValidate 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/commitCreate 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/resendResend 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/revokeRevoke 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/bulkChange 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-passwordForce 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-passwordThe 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-availabilityWhether 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/onboardStart 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/inviteInvite 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/inspectThe 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/acceptSet 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-planPreview 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/moverStart 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" }

MethodPathPurposeAuth
GET/user-management/logs/forwardingList 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/forwardingAdd 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/testSend 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/accessWhether 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/accessGrant 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/revokeRevoke 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/mailboxSet 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/diagnosticsBuild 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.

MethodPathPurposeAuth
POST/compliance/reports/generateBuild 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/downloadDownload 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/schedulesList 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/schedulesCreate 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/runRun 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/runsThe 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/holdPut 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/shareCreate 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/shareList 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/:linkIdRevoke 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/viewsRecent 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/savedSaved 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/archiveArchive 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.

MethodPathPurposeAuth
GET/compliance/reports/compliance/policiesThe 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/policiesAdd 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/ackSign 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/risksThe 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/risksAdd 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/vendorsThe 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/vendorsAdd 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/soaThe 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-twoHow 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/environmentThe 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/environmentReplace 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/sealSeal 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/evidenceEvidence 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/evidenceAttach 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-centerRead 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-centerChange 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/affectedThe 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.csvThe 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-sharesCreate 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-requestsEvidence 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)." }

MethodPathPurposeAuth
GET/shared/trustYour 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/:tokenA 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/:runIdVerify 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/:tokenThe 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/requestsAn 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.

MethodPathPurposeAuth
GET/compliance/reports/backupsList 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-auditThe 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-archiveWrite-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-requestRequest 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-requestRequest 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-scheduleSet 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-cancelCancel 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-channelRead the release channel.
returns 200 { channel: 'stable' | 'main', channels: string[], default: string, ... }.
Session + view_system
PUT/compliance/reports/release-channelChange 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.

MethodPathPurposeAuth
GET/compliance/data-export/jobsList 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/:idOne 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/jobsStart 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/linkMint 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/:tokenDownload 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.

MethodPathPurposeAuth
GET/auth/security-lab/scan-scheduleRead 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-scheduleSet 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-catalogThe 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/activeThe 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/verifyRe-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/trendsPosture 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/:idEdit 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/:idDisable 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/verifyRun 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/issuesFindings 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/summaryIssue 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/:idOne 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/issuesThe 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/:idUpdate 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/exceptionAccept 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/exceptionsList 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/exceptionsEvery 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/approveApprove 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/revokeRevoke 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/archiveArchive 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/findingsRetired: 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-scanMatch 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-scanScan 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-intelVulnerability 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/refreshRefresh 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-statusFreshness 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-importLoad 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-summaryThe 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-evidenceScan-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-exposureExposure 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-pathsAttack 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/:idOne 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-assetsThe 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/:idOne asset from the combined inventory.
returns 200 the asset | 404 "Asset not found".
Session + view_security_findings | manage_compliance
POST/auth/security-lab/assets/discoverRun 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/:idOne discovered asset.
returns 200 the asset | 404 "Asset not found".
Session + view_security_findings | manage_compliance
PATCH/auth/security-lab/assets/:id/criticalitySet 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/remediationsRemediation 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/remediationOpen 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/handoffMark 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/retestRe-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/ticketingRead 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/ticketingChange 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.

MethodPathPurposeAuth
GET/user-management/employeesThe 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/:userIdEdit 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/revealReveal 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-contactsReplace 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-changesRecord 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/:eventIdCancel 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/departureThe 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/meYour 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/meUpdate 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-teamThe 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-settingsRead 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-settingsChange 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/retentionSet 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/compensationA 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/revealReveal 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/compensationAdd 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/voidVoid 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/documentsThe 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/documentsCreate 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/contentUpload 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/:docIdRename 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/:docIdDelete 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/downloadDownload 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/documentsYour 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/documentsA 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/summaryPeople 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-exportBuild 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": "..." }

MethodPathPurposeAuth
GET/user-management/leave/meYour 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/previewHow 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/requestsRequest 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/cancelCancel 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/approvalsThe 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/decideApprove 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/approveApprove 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/declineDecline 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/calendarWho 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/setupThe 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/typesAdd 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/calendarsAdd 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/:userIdOne 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/adjustAdjust 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"

}

]

}

MethodPathPurposeAuth
GET/user-management/goals/mineYour 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/goalsAdd 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/templatesThe 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/mineYour 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/selfSave 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/shareThe 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/feedbackThe 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/cyclesList 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/cyclesCreate 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/launchLaunch 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/progressWho 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/calibrationThe 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/reviewerAssign 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/:reviewIdRead 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/mineThe 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/respondAnswer 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/surveysAll 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/surveysCreate 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/openOpen 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/resultsThe 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

}

]

}

MethodPathPurposeAuth
GET/user-management/training/mineYour 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/completeMark 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/quizAnswer 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/certificateUpload 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/:userIdOne 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/teamWhat 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/coursesList 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/coursesCreate 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/libraryThe 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/adoptAdd 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/assignAssign 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/statusWho 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/rosterEvery 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/batchChange 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-completionsRecord 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/settingsRead 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-suggestionsPeople 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/videoUpload 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/fileWatch 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/scormImport 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/launchGet 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/pathsList 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/pathsCreate 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/membersAdd 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/progressPer 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.

MethodPathPurposeAuth
GET/user-management/training/phishing/campaignsList 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/campaignsCreate 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/templatesThe 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/submitSend 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/approveApprove 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/sendSend 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/resultsPer-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/settingsRead 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/reportReport 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/:tokenAfter 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/:tokenThe 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.

MethodPathPurposeAuth
GET/compliance/reports/compliance/policy-templatesThe 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/adoptAdd 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/approveApprove 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/documentThe 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/mineThe 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-documentWhat 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-progressSave 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/ackSign 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-documentThe 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/teamWhat 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/statusWho 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/remindRemind 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/rosterThe 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.pdfEverything 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.pdfYour 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.pdfThe 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/exemptionsRecorded 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-retentionHow 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/coverageSigning 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-mapWhich 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)

Related reference