Admin API
The administrative REST API: how to reach it, authentication, every endpoint with its method, path and purpose, answer shapes and errors.
The administrative API is the only way anything on Nixt Server is administered. versealx-server admin is a client of it; so is any script you write. Every change goes through one path that checks the caller’s role, makes the change and writes the audit line in the same transaction, so a change cannot happen without its record.
Reaching the API
| Way in | Address | Who you are |
|---|---|---|
| The local socket | The Unix socket at [admin] socket, /var/lib/versealx-server/admin.sock on a node set up with init | The operator, with no token. The socket is created with mode 0600 for the service user, so only that user — or root — can open it. |
| HTTPS | https://mail.example.com/api/v1/…, only when [listeners.admin] is set | Whoever the bearer token says. |
Both answer the same API. Paths begin with /api/v1.
The operator’s console
The console’s System pages — the cluster, disaster recovery and links — are the operator’s, and the operator is reached only from inside the server. On the server, as the service user, open the console on this machine:
sudo -u versealx versealx-server console serve --from /srv/versealx-console
--from is where a console release is installed (versealx-server console install). The command prints an address like http://127.0.0.1:43127/#operator=…. Open it in a browser on the server, or from your own machine through an SSH tunnel to that port:
ssh -L 43127:127.0.0.1:43127 admin@mail.example.com
In a cloud, your provider’s session manager port forwarding does the same. The console listens on the loopback address only and passes its calls to the local socket. The key in the address works until you stop the command with Ctrl-C; without it, nothing is answered. --port picks the port.
With versealx-server admin
sudo -u versealx versealx-server admin get tenants --config /etc/versealx-server/versealx-server.toml
| Part | Meaning |
|---|---|
get, post, put, patch or delete | The HTTP method, in any case. |
| The path | With or without /api/v1/: tenants/1/domains and /api/v1/tenants/1/domains are the same. |
key=value pairs | For get and delete, query parameters. For the other methods, fields of a JSON body. |
--socket <path> | A socket other than the one in the configuration. |
--config <file> | Where to find the configuration, and so the socket. A missing or unreadable file is not fatal; the default socket is used. |
Values in a body are typed: true and false become booleans, null becomes null, a value made only of digits becomes a number, and a value starting with [ or { that parses as JSON is sent as that JSON. Everything else is a string. Quote pairs that contain spaces or shell characters.
The command prints the answer’s JSON, indented, and exits 0 for a success, 1 when the API answered with an error (4xx or 5xx), and 2 when the call could not be made at all.
| Message | Cause |
|---|---|
cannot reach the admin socket at <path>: <reason> followed by is the server running, and does this account own it? | The server is not running, the admin role is off, or you are not the service user. |
the admin socket did not answer | No answer within 30 seconds. |
| The usage text | The method or path is missing, or the method is not one of the five. |
With curl over the socket
sudo -u versealx curl --unix-socket /var/lib/versealx-server/admin.sock http://localhost/api/v1/tenants
Over HTTPS
[listeners.admin]
bind = "0.0.0.0:443"
With this table, the API answers on the shared HTTPS listener beside JMAP. Requests need an Authorization: Bearer <token> header carrying a token issued by this server for the versealx-admin audience; the token’s scopes name the caller’s role. See Roles and the audit log.
For a script or an identity provider — anything that cannot sit at the terminal and cannot open the socket — mint a provisioning token. It goes in the same Authorization: Bearer header, and the API treats it exactly as it treats any other credential carrying those scopes.
Requests and answers
- Request bodies are JSON, at most 256 KiB.
- Successful answers are JSON:
200with the thing,201with a newly created thing, or204with no body. - Lists are wrapped as
{"items": [...]}. - Times are Unix milliseconds unless a field says otherwise.
- Reads are not audited unless refused; every change, successful, failed or refused, is.
The OpenAPI document
vsx admin get openapi.json
GET /api/v1/openapi.json returns an OpenAPI 3.1 description of every endpoint, including the Settings schema that lists every runtime setting. It needs no credentials. Endpoints only the operator may call are marked x-operator-only.
Endpoints
Provisioning tokens
The credential a machine authenticates with. See Provisioning with SCIM.
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /tenants/{tenant}/provisioning-tokens | 200 | The tenant’s tokens: label, scope, tail (the last four characters of the secret), createdAt, expiresAt, lastUsedAt and expired. Never a secret. |
POST | /tenants/{tenant}/provisioning-tokens | 201 | Mint one. Body: label, scope (administrative scopes, space separated), days (1 to 365, 90 by default). The answer carries secret, once and only here. You cannot grant a scope you do not hold. |
DELETE | /tenants/{tenant}/provisioning-tokens/{id} | 204 | Revoke one. It stops working immediately. |
GET | /tenants/{tenant}/classifier | 200 | What the classifier has learnt: mode, learntSpam, learntNotSpam, the minimum of each it needs, and whether it is scoring. |
DELETE | /tenants/{tenant}/classifier | 204 | Forget everything the classifier has learnt. |
GET | /tenants/{tenant}/rules | 200 | The filter’s rules, in the order they run, with version, updatedAt and by. Tenant 0’s are the server’s, which run first. |
PUT | /tenants/{tenant}/rules | 200 | Replace the rules: rules, and the version you read. 409 if it has changed since; 400 naming the rule if the list cannot run. See Rules. |
GET | /tenants/{tenant}/rules/history | 200 | Every version of the rules, newest first. |
GET | /tenants/{tenant}/quarantine | 200 | Quarantined mail: account, address, message, size, receivedAt, quarantinedAt, sender, reason and queueId for each, with more when there were others. account=<id> for one account, paged with after; limit up to 1000. Never the subject or the message. A domain administrator sees their own domains. |
POST | /tenants/{tenant}/quarantine/{account}/{message}/release | 200 | Move a message into the account’s inbox as new mail. |
DELETE | /tenants/{tenant}/quarantine/{account}/{message} | 204 | Delete a quarantined message. Not available to helpdesk. |
Tenants
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /tenants | 200 | Every tenant. Operator only. |
POST | /tenants | 201 | Make a tenant. Body: name (1 to 64 characters, unique whatever its case). Operator only. |
GET | /tenants/{tenant} | 200 | The tenant: id, name, suspended, created_at. |
PATCH | /tenants/{tenant} | 200 | Suspend the tenant or bring it back; the operator only. Body: suspended (boolean). |
Domains
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /tenants/{tenant}/domains | 200 | The tenant’s domains. A domain administrator sees only the domains they hold. |
POST | /tenants/{tenant}/domains | 201 | Add a domain. Body: name, in any spelling; stored in ASCII form. It arrives unverified, with its token. |
GET | /tenants/{tenant}/domains/{name} | 200 | One domain, with its token and ownership. |
POST | /tenants/{tenant}/domains/{name}/verify | 200 | Mark the domain verified, and check its ownership again at once. |
GET | /tenants/{tenant}/domains/{name}/dkim | 200 | The domain’s DKIM keys: selector, algorithm, record to publish, when made, whether retired. Never the private half. |
POST | /tenants/{tenant}/domains/{name}/dkim | 201 | Make a new RSA and Ed25519 pair. Body: optional selector. Answers with items and the records to publish. |
DELETE | /tenants/{tenant}/domains/{name}/dkim/{selector} | 200 | Retire one key. It signs nothing more. |
Accounts
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /tenants/{tenant}/accounts | 200 | Every principal: users, groups, aliases and resources. A domain administrator sees those in their domains. |
POST | /tenants/{tenant}/accounts | 201 | Create a principal. Body: address (required), displayName, kind (user, group, alias or resource), and for a group members and restriction (anyone, tenant or members), for an alias target. |
GET | /tenants/{tenant}/accounts/{id} | 200 | One principal. |
PATCH | /tenants/{tenant}/accounts/{id} | 200 | Set a principal’s status. Body: status (active, disabled or deprovisioned). |
DELETE | /tenants/{tenant}/accounts/{id} | 204 | Deprovision a principal. Its mail is kept. |
PUT | /tenants/{tenant}/accounts/{id}/password | 204 | Give the account a new password. Body: password. The audit log records the change, never the password. |
Domains and accounts shows each of these with examples and answers.
Settings
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /tenants/{tenant}/settings | 200 | The settings document: version, updated_at, by, settings. Tenant 0 is the whole server’s, which only the operator reaches. |
PUT | /tenants/{tenant}/settings | 200 | Replace the document. Body: settings (object, required) and version (optional; refused with 409 if not current). |
PATCH | /tenants/{tenant}/settings | 200 | Change some settings. Body: the settings by key, null to remove one, and optional version. |
GET | /tenants/{tenant}/settings/history | 200 | Every version, newest first: version, updated_at, by. Query: limit (20 unless given, at most 200). |
GET | /tenants/{tenant}/settings/history/{version} | 200 | One earlier version, whole. |
Runtime settings lists every key.
Queue and trace
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /queue | 200 | Every message still in flight, soonest due first, at most 200. Query: tenant. |
GET | /queue/{id} | 200 | One message in flight, with each recipient’s state and last reply. Query: tenant. |
GET | /trace | 200 | The messages an address sent or received on a day. Query: address and day (both required), tenant. |
GET | /trace/{id} | 200 | One message’s whole story. Query: tenant. |
tenant is the caller’s own tenant unless an operator names one. For /queue, an operator who names none sees every tenant’s messages; for /trace, an operator must name one. See Message trace for the fields.
Reports
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /reports | 200 | DMARC aggregate and TLS reports other servers sent about a domain, for a day. Query: domain and day (both required), kind (dmarc or tls). |
See Email authentication for the fields.
Audit log
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /audit | 200 | The audit log, oldest first, with written, the count of every line ever written. Query: tenant, after (carry on after this id), limit (at most 200). |
Documentation
| Method | Path | Success | Purpose |
|---|---|---|---|
GET | /openapi.json | 200 | The OpenAPI document. No credentials needed. |
Errors
Every error is an RFC 9457 problem document, with the content type application/problem+json:
{
"detail": "example.org is already taken",
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
detail says what went wrong in words you can act on. It never says more about the server than the caller is entitled to know: a queue id or trace id that belongs to someone else gets the same 404 as one that does not exist.
When the problem is at one place in what you sent, errors says where, as RFC 9457 §3 writes it: each entry repeats the detail and adds a pointer into the body, such as #/until, or #/delegates/1 for the second delegate in a list. A form can show the words beside the field they are about. A problem about the request as a whole has no errors.
{
"detail": "`until` is later than now: a hold that has already ended holds nothing",
"errors": [
{
"detail": "`until` is later than now: a hold that has already ended holds nothing",
"pointer": "#/until"
}
],
"status": 400,
"title": "Bad request",
"type": "about:blank"
}
| Status | title | Typical detail | What to do |
|---|---|---|---|
| 400 | Bad request | `name` must be text, `day` is YYYY-MM-DD, the body is not JSON: <reason>, that is larger than an administrative request may be | Fix the request. |
| 401 | Unauthorized | no credentials were offered, that token has expired, that token is not for the administrative API, that token is for another tenant, that token was refused, an operator token cannot belong to a tenant | Send a valid token, or use the local socket. |
| 403 | Forbidden | that belongs to another tenant, that domain is not one of yours, that is somebody else's, a <role> may not do that | The caller’s role does not reach this. |
| 404 | Not found | no such collection, no such tenant, account <id> does not exist, this server answers under /api/v1 | Check the path and ids. |
| 405 | Method not allowed | that is not something you can do to <thing> | Use a method the path takes. |
| 409 | Conflict | <name> is already taken, <address>: that domain is not this tenant's, version <n> is no longer current; it is <m> now. Read it again and retry. | Change what you asked for, or read the current state and retry. |
| 500 | Server error | The store’s own message | Look at the server’s log. |
Requests that change something and are refused or fail are recorded in the audit log with the reason.
Something unclear or out of date on this page? Tell us.