Skip to content

The REST API

Christopher Holloway edited this page Oct 21, 2026 · 24 revisions

The REST API

This page began as a chapter of the 6.2.10 manual and has been corrected for 6.2.24, then checked and extended against the 6.2.28 source on 8 September 2026. The Control Panel's pages are grouped differently now, so the paths below use today's groups; the TLS ports 465, 993 and 995 exist only after you create them on the TCP/IP ports page (a fresh install seeds 25, 587, 110 and 143); and everything added since 6.2.10 is in Changes-Since-6210. Where a value here disagrees with the Settings Reference, which is generated from the code, the reference is right. What 6.3.4 added - the credentials, the sign-in methods and the route families - was read again against master 540347500 on 24 September 2026, and against 92b8f9f63 on 26 September 2026, before the last batch the release, 6.3.4 (27 September 2026, schema 6104), carries: the route families of eDiscovery, mail in to a webhook, mailbox encryption at rest, rooms and equipment, the message store's measurement and the webmail's select-all, and the counts in section 5. Brought up on 5 October 2026 to 6.3.6 (6 October 2026, schema 6108), for what 6.3.6 changes on this page. 6.3.7, the latest release (17 October 2026, schema 6108), is not yet reflected here.

A modern alternative to COM for automation, and the only option from a non-Windows machine. Everything on this page was read out of Server/Common/Util/RestApiServer.cpp and Server/Common/Util/HttpServer.cpp; the exhaustive route-by-route reference is APIs Reference section 4. Two command-line clients speak this API rather than COM - hmctl for any shell and a PowerShell module - and they are on Command Line.

1. Turning it on

[Settings]
RestApiPort=8443
RestApiBindAddress=127.0.0.1

Restart the service (or call COM Application.Reinitialize). Both keys are read once, when the listener is constructed at server start, so editing them without a restart changes nothing. Their editors are on Monitoring & troubleshooting → Monitoring & alerts, on its REST API tab.

Since 6.3.4, the file edit above works only once. From schema 6042 the [Settings] keys are stored in the database and hMailServer.INI is a copy. A key the server has never stored is taken in from the file at the next start — so on a fresh install, editing the file before the first start works. A key it has stored is not taken from the file: at the next start the stored value is used, the edit is named in the error log as HM5804, and the line is put back to the stored value where the service may write the file — under NT SERVICE\hMailServer, and under the Linux packages' hmailserver user, to either of which the file is read-only, the edit stays in the file (The Service Account). Change a stored RestApiPort on the Control Panel page above, over COM with Settings.SetIniSetting, or — where neither is available — in a [SettingsOverride] section of the same file, which is applied over the store at every start and reported as HM5807 each time.

This matters most on Linux, where the REST API is the administration interface. The packaged /etc/hmailserver/hMailServer.ini ships RestApiPort=0 and RestApiBindAddress=127.0.0.1 under [Settings], so the first start stores both; after that, changing the port in [Settings] turns nothing on. Put the lines under [SettingsOverride] instead:

[SettingsOverride]
RestApiPort=8080

The container image's HM_REST_PORT and related variables write [Settings] too, so on a container whose database already holds the key, the variable is undone the same way.

The Control Panel's Monitoring & alerts page on its REST API tab, where the REST listener's port, bind address and certificate files are set, with the Open the Control Deck and Open REST API keys buttons; the metrics listener is on the Metrics tab, and ManageSieve on Connections & protocols → Protocols

The listener does not always start. It refuses in three named ways, and each one says so once in the application log:

What is wrong Log line What you see from a client Fix
[Security] AdministratorPassword is empty RestApi: Refusing to start - the administrator password is not set. Connection refused; nothing is listening Set an administrator password (Control Panel, or COM Settings.SetAdministratorPassword)
RestApiBindAddress is not loopback and no certificate is configured RestApi: Refusing to start - TLS certificate is required unless bound to 127.0.0.1 or ::1. Set RestApiCertificateFile and RestApiPrivateKeyFile. Connection refused Set RestApiCertificateFile + RestApiPrivateKeyFile, or enable ACME, or bind loopback
The configured certificate cannot be loaded, or its key does not match RestApi: Refusing to start - the shared TLS configuration could not be applied to the configured certificate. (preceded by HM5113 naming the file) Connection refused Fix the PEM paths and permissions
Nothing is wrong RestApi: Listening on 127.0.0.1:8443 (http, loopback only). or ... (https). It answers —

The exact loopback literals that exempt the listener from TLS are 127.0.0.1, localhost and ::1. Not 127.0.0.2, not ::ffff:127.0.0.1 — the check is a string comparison against those three, deliberately, because it is a security gate.

flowchart TD
    A["Service start, RestApiPort read"] --> B{"Is RestApiPort greater than zero?"}
    B -- no --> Z["No listener. This is the shipped default."]
    B -- yes --> C{"Is AdministratorPassword set?"}
    C -- no --> R1["Refuse: the administrator password is not set"]
    C -- yes --> D{"RestApiCertificateFile and RestApiPrivateKeyFile both set?"}
    D -- yes --> G["HTTPS"]
    D -- no --> E{"Do ACME fullchain.pem and privkey.pem both exist?"}
    E -- yes --> F["Use the ACME certificate. Logs: RestApi: Using the ACME certificate for HTTPS."]
    F --> G
    E -- no --> H{"Is the bind address 127.0.0.1, localhost or ::1?"}
    H -- yes --> I["Plain HTTP, loopback only"]
    H -- no --> R2["Refuse: TLS certificate is required"]
    G --> J["TLS context from SslContextInitializer, then a TLS 1.2 floor"]
    J --> K["Listening"]
    I --> K
Loading

Source: Application.cpp (the ACME substitution and the start order), RestApiServer::Start.

TLS configuration is shared with the mail protocols. The context is built by SslContextInitializer::InitServer, so the cipher list, the protocol toggles, the DH parameters and TlsKeyExchangeGroups (including the hybrid post-quantum groups) apply here exactly as they do to SMTP and IMAP. One thing is deliberately not shared: a TLS 1.2 minimum is applied on top, so opening TLS 1.0 up for an ancient mail client does not open it up for the management API.

2. Credentials

Three, and they do not overlap.

Credential Header Reaches Restrictable Where it comes from
Administrator password Authorization: Basic base64("Administrator:<password>") (user name case-insensitive) Everything except /api/v1/me* and /api/v1/session No — full authority, by design [Security] AdministratorPassword
API key Authorization: Bearer hmapi_<64 hex> Everything except key management, /api/v1/settings/ini*, /api/v1/me* and /api/v1/session — and, since 6.3.4, /api/v1/audit*, /api/v1/alerts*, /api/v1/webhooks*, the compromised-account review, /api/v1/settings/changed, /api/v1/server/settings/pending, an account's export, the address erasure and the second factor's enrolment — each refused with 401 Yes — scope, domains, source addresses, expiry Monitoring & troubleshooting → REST API keys, or POST /api/v1/apikeys
An account's own Authorization: Basic base64("<address>:<password>"), or the hmailsession cookie /api/v1/me* and /api/v1/session only n/a The mailbox password, or one of its app passwords

Since 6.3.4, the three overlap in one place and gain two uses. An account that holds an administrator role (schema 6047: serveradmin, domainadmin, helpdesk, auditor or a custom one) reaches the administrative routes its role's permissions allow, in the domains it reaches, with its own address and password; a route its role may not call answers 403 naming the role and the permission it lacks. An API key holds a role too, and may name a sender (send_from, with ceilings a minute and a day), which is the only credential POST /api/v1/send takes; a key whose role holds provision is the only credential /scim/v2 takes. And a one-time recovery code, in X-hMailServer-Recovery-Code, is taken wherever the authenticator's code is asked for. Roles.md, SendApi.md, Scim.md.

The two Basic forms are told apart before either password is tried: any user name but administrator is treated as a mailbox address and goes down AccountLogon::Logon, the same path an IMAP logon takes. So an account is never tested against the administrator password, and the administrator's name is never tested against the accounts.

When a second factor is enrolled on the administrator credential ([Security] AdministratorTotpSecret), the code goes in an extra header:

curl -u Administrator:your-admin-password \
     -H "X-hMailServer-OTP: 123456" \
     http://127.0.0.1:8443/api/v1/status

Omit it and you get a 401 whose body and headers say what is missing — the only 401 on this listener that distinguishes itself from any other:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="hMailServer"
X-hMailServer-OTP: required
Cache-Control: no-store

{"error":"authentication failed","second_factor":"required"}

A missing code is not counted towards the auto-ban (the password was right; the client simply had not been told). A wrong code is.

API keys are the credential for scripts

Read-only by default, expiring by default (90 days; there is no "never expires"), and optionally narrowed to named domains and to a source address, range or CIDR. Only the SHA-256 of a key is stored, in hMailServerApiKeys.ini beside hMailServer.ini, and the file is re-read on every authentication attempt — so deleting a section revokes a key on the next request with no restart.

# Mint one. Administrator password only: a key can never mint or revoke keys.
curl -u Administrator:your-admin-password \
     -H "Content-Type: application/json" \
     -d '{"label":"backup monitor","scope":"readonly","expires":"2027-01-01 00:00:00"}' \
     http://127.0.0.1:8443/api/v1/apikeys
{"id":"3f9a1c4b5d6e7f80","label":"backup monitor","scope":"readonly","domains":"",
 "expires":"2027-01-01 00:00:00","allowed_from":"","key":"hmapi_7c1f...c92a"}

key is the clear text, returned once and never recoverable. Store it now.

curl -H "Authorization: Bearer hmapi_7c1f...c92a" http://127.0.0.1:8443/api/v1/status

3. What happens to one request

sequenceDiagram
    autonumber
    participant C as Client
    participant S as HttpServer accept loop
    participant N as HttpConnection
    participant R as ProcessRequest_
    participant A as Authorize_
    participant H as Handler

    C->>S: TCP connect
    S->>S: accept_filter_ - is this address in the refused set?
    alt refused, or 64 connections already open
        S-->>C: connection closed, no response
    else accepted
        S->>N: Start, arm the 300 s connection timer
        opt TLS configured
            N->>C: TLS handshake, inside the 30 s request deadline
        end
        C->>N: request line and headers
        N->>N: cap head at 64 KB, reject NUL, parse Content-Length
        opt POST to /api/v1/me/messages or /api/v1/me/drafts
            N->>N: raise the cap to 16 MB and the deadline to 300 s
        end
        N->>C: 100 Continue, only if the client asked and the length passed the cap
        C->>N: body
        N->>R: HttpRequest with raw head plus body
        R->>R: Authenticate_ - Bearer, then Basic, then the session cookie
        alt no credential matched
            R-->>C: 401 authentication failed
        else authenticated
            R->>R: CSRF check for cookie-authenticated writes
            R->>R: rate budget, 200 requests per 10 s for this credential
            R->>R: ParseRoute_ turns method plus path into a RouteKind
            R->>A: Authorize_ with the caller and the route
            alt allowed
                A-->>R: allowed
                R->>H: run the handler
                H-->>R: HttpResponse
            else forbidden
                A-->>R: 403 with the reason
            else key touched /api/v1/apikeys
                A-->>R: 401, never 403
            end
        end
        R-->>N: status, JSON body, Cache-Control no-store
        N-->>C: response, connection kept alive unless it was a refusal
    end
Loading

Sources: HttpServer::Accept_, HttpConnection::OnHead_ / OnBody_, RestApiServer::ProcessRequest_, RestApiServer::Authenticate_, RestApiServer::Authorize_.

Two things in that diagram are worth pulling out.

Authorisation happens in exactly one place. ParseRoute_ turns the method and path into a RouteKind; Authorize_ decides on the kind; only then is a handler called. A handler cannot be reached by a credential nobody checked, and a new endpoint cannot be added without appearing in Authorize_.

Read-only is decided by route kind, not by HTTP method. A route that changed something under a GET could not slip past a read-only key by being spelled harmlessly.

flowchart TD
    A["Authorize_ decides on the caller and the route"] --> B{"Is this a self-service route?"}
    B -- yes --> C{"Is the caller an account?"}
    C -- yes --> OK1["Allowed"]
    C -- no --> F1["403 - this endpoint answers to an accounts own credentials"]
    B -- no --> D{"Is the caller an account?"}
    D -- yes --> F2["403 - an accounts credentials reach only /api/v1/me"]
    D -- no --> E{"Is the caller the administrator?"}
    E -- yes --> OK2["Allowed - full authority, nothing narrows it"]
    E -- no --> G{"Is it key management, settings/ini, or - since 6.3.4 - audit or alerts?"}
    G -- yes --> U["401 - a key learns nothing about these, not even that they exist"]
    G -- no --> H{"Read-only key on a mutating route?"}
    H -- yes --> F3["403 - this api key is read-only"]
    H -- no --> I{"Does the key have a Domains list?"}
    I -- no --> OK3["Allowed"]
    I -- yes --> J{"Is it a queue or quarantine route, or the volume or storage report?"}
    J -- yes --> F4["403 - the queue / the quarantine / that report is server-wide"]
    J -- no --> K{"IP ranges, certificates, rules, logs, backup, settings, update, groups or remote domains?"}
    K -- yes --> F5["403 - that resource is server-wide"]
    K -- no --> L{"Does the route name a domain or an address?"}
    L -- no --> OK4["Allowed - status, tlsa, srv and the domain list filter in their handlers"]
    L -- yes --> M{"Is that domain in the keys list?"}
    M -- yes --> OK5["Allowed"]
    M -- no --> F6["403 - this api key is not permitted for that domain"]
Loading

Source: RestApiServer::Authorize_, RestApiServer::IsMutatingRoute_, RestApiServer::IsSelfServiceRoute_, RestApiServer::IsApiKeyRoute_. The report routes of 6.3.4 take one more branch the diagram folds into K and M: a domain-restricted key that names no domain is not refused but confined to its own domains by the handler, as the domain listing is.

4. Browser sessions: the self-service portal, and the Control Deck

New in 6.2.28. GET /portal serves a static sign-in page compiled into the server; its script signs in and then talks to /api/v1/me. The page and its script are the only unauthenticated routes besides the WebAdmin shell at /.

sequenceDiagram
    autonumber
    participant B as Browser
    participant P as RestApiServer
    participant L as AccountLogon
    participant T as Session table in memory

    B->>P: GET /portal
    P-->>B: static HTML, CSP script-src 'self', no inline script
    B->>P: GET /portal.js
    P-->>B: static script
    Note over B: the person types address and password
    B->>P: POST /api/v1/session, Authorization Basic address:password
    P->>L: Logon - the same path, lockout and auto-ban as IMAP
    alt refused
        L-->>P: no account
        P-->>B: 401 authentication failed
    else accepted
        L-->>P: account
        P->>T: drop expired sessions; a fifty-first of this holder's ends its least recently used; a full table takes from the largest holder (6.3.6)
        P->>T: store SHA-256 of a fresh 32-byte token with the account id
        P-->>B: 201 plus Set-Cookie hmailsession=... HttpOnly SameSite=Strict Max-Age=43200 [Secure]
    end
    Note over B: the password is not kept - the cookie is the credential from here on
    B->>P: GET /api/v1/me, Cookie hmailsession=...
    P->>T: constant-time compare, check 30 min idle and 12 h absolute
    P->>P: re-read the account from the database on every request
    P-->>B: 200 the account's own state
    B->>P: PUT /api/v1/me/vacation, Cookie plus X-Requested-With hMailServer
    P-->>B: 200
    B->>P: DELETE /api/v1/session
    P->>T: forget this session
    P-->>B: 200 plus a clearing Set-Cookie
Loading

Source: RestApiServer::HandleSessionCreate_, AuthenticateSession_, HandleSessionDelete_, AuthenticateAccount_.

The cookie's rules, all enforced server-side:

Rule Value Why
Token 32 random bytes as hex; only its SHA-256 is kept, in memory The table leaks nothing if it is read
Idle ceiling 30 minutes
Absolute ceiling 12 hours from sign-in A captured cookie is useful for a bounded time, not for as long as the victim keeps clicking
Table size 1,000 sessions, and since 6.3.6 at most 50 per account or the administrator; a full table takes from the largest holder, and the administrator's sessions do not count towards the thousand A script cannot fill memory with sign-ins, and one person cannot sign others out (up to 6.3.5 a full table dropped its least recently used session whoever held it)
A session cannot start a session POST /api/v1/session with a cookie → 403
Account re-read every request Deactivated or deleted → refused and its sessions dropped
Password change Ends the account's other sessions
Listener stop Every session is dropped
CSRF A cookie-authenticated request that is not GET or HEAD must carry X-Requested-With: hMailServer SameSite=Strict closes it once; a header no browser adds cross-origin closes it twice

The CSRF refusal is verbatim:

{"error":"a request that changes something must carry X-Requested-With: hMailServer when it is authenticated by a session cookie"}

Since 6.3.4, a session is minted four more ways, all through the same POST /api/v1/session and so under the same table, the same ceilings and the same device list:

  • A passkey (WebAuthn, schema 6077): the page asks POST /api/v1/passkeys/challenge for a challenge - thirty-two random bytes, five minutes, spent on first use - and presents the browser's assertion. An account enrols one from its Security page, and the administrator from the Deck; enrolling asks for the password again, and the one-time code where one is enrolled.
  • OpenID Connect from the one issuer the administrator names (/portal/oidc/start, the authorization code with PKCE, the id_token verified against the issuer's keys) or SAML 2.0 from an identity provider that speaks only that (/portal/saml/start, /portal/saml/acs, the Response's signature verified over its exclusive canonical form): each ends in a one-use ticket presented where the password would be. Neither makes a mailbox unless auto-provisioning is on, and neither opens the Deck but for an address the administrator listed. IMAP, POP3 and SMTP still take the password.
  • A second account in the same page: POST /api/v1/session?bearer=1 answers the session as a token in the body, which the page keeps in memory and sends as Authorization: Bearer; it is never minted for the administrator.
  • After a forgotten password: POST /api/v1/password-reset always answers 202 with one sentence, and a link to the confirmed recovery address opens /portal/reset.

Every session is also a row the account and the administrator can see and revoke (/api/v1/me/devices, /api/v1/accounts/<address>/devices, schema 6053), and the idle and lifetime ceilings, one session per device and binding to the address are a policy of the server, a domain or an account. PasskeysAndSingleSignOn.md, SecondAccount.md, AccountRecovery.md, DevicesAndSessions.md.

5. The route table

Full detail — request bodies, every error code, the domain and read-only rules per route — is in APIs Reference section 4. This is the map.

Method Endpoint Credential Does
GET /api/v1/status admin, key Server status and session counts
GET / POST /api/v1/domains admin, key; POST needs a full key that is not domain-restricted List domains (a domain-restricted key sees only its own; each entry is name, active, postmaster) / create a domain from {name, active, postmaster}
PUT / DELETE /api/v1/domains/<name> admin, full key; DELETE never a domain-restricted one Switch a domain on or off and set its postmaster ({"active":bool, "postmaster":...}, active required) / delete it with its accounts, aliases, lists, domain aliases and directories
GET / POST /api/v1/domains/<name>/accounts admin, key; POST needs a full key List / create accounts
PUT / DELETE /api/v1/accounts/<address> admin, full key (the account's domain) Update any subset of active, password, max_size_mb, first_name, last_name, forwarding, signature and admin_level ("server", and a server administrator's account, only for a credential that is not domain-restricted) / delete an account
GET / POST /api/v1/domains/<name>/aliases admin, key; POST needs a full key List / create a domain's aliases (name, value, active)
DELETE /api/v1/aliases/<address> admin, full key (the alias's domain) Delete an alias
GET / POST /api/v1/domains/<name>/lists admin, key; POST needs a full key List / create distribution lists
DELETE /api/v1/lists/<address> admin, full key Delete a distribution list
GET /api/v1/domains/<name>/dkim admin, key A domain's DKIM signing configuration
GET /api/v1/queue admin, key (never a domain-restricted one) The delivery queue
POST /api/v1/queue/<id>/retry admin, full key Retry a queued message
DELETE /api/v1/queue/<id> admin, full key Delete a queued message
GET /api/v1/quarantine admin, key (never domain-restricted) Quarantined messages
POST /api/v1/quarantine/<id>/release admin, full key Release one to its recipients
DELETE /api/v1/quarantine/<id> admin, full key Delete one
GET /api/v1/archive?... admin, key (must name one of its domains) Search the archive index; /archive/<id> one entry; POST/DELETE /archive/<id>/hold legal hold
GET / POST /api/v1/ipranges admin, key (never domain-restricted); POST needs a full key List / create IP ranges; DELETE /api/v1/ipranges/<id>
GET / POST /api/v1/certificates admin, key (never domain-restricted); POST needs a full key The SSL certificates (never a key password) / add one by name and file paths, with an optional write-only key password; a file that is not there is a 400
DELETE /api/v1/certificates/<id> admin, full key Delete a certificate; 409 while a port binds it
GET / POST /api/v1/ports admin, key (never domain-restricted); POST needs a full key The listeners: protocol, address, port, connection_security, certificate_id, and since 6.3.6 smtp_authentication (on, off, honeypot; SMTP listeners only) / add one
PUT / DELETE /api/v1/ports/<id> admin, full key Replace / delete a listener (a PUT that leaves smtp_authentication out writes on, 6.3.6). Port changes take effect on a restart in place (/api/v1/server/reinitialize) or a service restart
POST /api/v1/server/reinitialize admin, full key (never domain-restricted) Stop every service, reload the configuration and start them again in the same process - what the Control Panel's Reinitialize does. Answers 202 before it happens; poll /api/v1/status until it answers again
GET / POST /api/v1/rules admin, key (never domain-restricted); POST needs a full key The global rules with their criteria and actions / create one; a rule acts on the next message delivered
PUT / DELETE /api/v1/rules/<id> admin, full key Replace the whole rule (criteria and actions in array order) / delete it
GET / POST /api/v1/routes admin, key (never domain-restricted); POST needs a full key The SMTP routes with their address lists / create one
PUT / DELETE /api/v1/routes/<id> admin, full key Replace the whole route (the relay password is kept when absent) / delete it
GET /api/v1/logs, /api/v1/logs/<name>?lines=N admin, key (never domain-restricted) Log files and a log's tail
GET / POST /api/v1/backup admin, key / full key Backup status / start a backup
GET / PUT /api/v1/settings, /api/v1/settings/antispam, /api/v1/settings/logging admin, key (never domain-restricted); PUT needs a full key Each group whole on GET; PUT any subset of its keys, validated as the Control Panel validates them and applied only when every key is accepted. The relay password is write-only; the OpenAPI document names the keys that take effect on restart. 6.3.6 adds auto_ban_widen_by_reverse_name to /settings, and refuse_helo_not_fqdn, refuse_mail_from_domain_not_fqdn and surbl_too_many_domains_score to /settings/antispam
GET /api/v1/tlsa admin, key Publish-ready DANE TLSA records
GET /api/v1/srv admin, key Publish-ready client-discovery SRV records
GET /api/v1/metrics/history?metric=&range=24h|7d|30d admin, key One metric's history
GET / POST / DELETE /api/v1/apikeys[/<id>] administrator password only Manage API keys
GET /api/v1/openapi.json admin, key The OpenAPI description
GET / POST /api/v1/groups admin, full key for POST (never domain-restricted) The account groups a folder permission can name as one principal / create one. 6.3.4.
GET / PUT / DELETE /api/v1/groups/<id> admin, full key Read, rename, delete a group. 6.3.4.
GET / POST /api/v1/groups/<id>/members admin, full key The group's accounts / add one by address or id. DELETE .../members/<account> removes one. A membership is a pair, so it is its own collection rather than a property of either side. 6.3.4.
POST /api/v1/accounts/<address>/messages/<id>/copy admin, key scoped to the address's domain Copy the message into another folder of the same account (folder_id in the body): the file is copied, the row takes the destination's next UID, every session on that folder is told, and the source is left as it is. 6.3.4.
POST /api/v1/rules/match admin, key (never domain-restricted) Try one rule criterion (match_value, match_type, test_value) and answer what the delivery path would decide. Nothing is read or written - the probe the Control Panel's rule editor needs. 6.3.4.
GET / PUT / DELETE /api/v1/settings/ini, /api/v1/settings/ini/<name> administrator password only (a key of any scope gets 401) The [Settings] keys one at a time. In 6.3.3 a PUT writes the file and a DELETE removes its line. Since 6.3.4 both go to the settings store in the database first and bring the file's copy into line where the service may write the file (under NT SERVICE\hMailServer, and under the Linux packages' hmailserver user, the row alone - The Service Account); a DELETE is now the only way to return a stored key to its default - where the service may not write the file and the file has the line, the line stays and the row is kept holding the key's default, or the removal is refused for PasswordPepper and a key this build does not read - because a line deleted by hand is written back where the service may write the file, and the row kept either way. In 6.3.3 the list answers key names only; since 6.3.4 it answers each with its value too, a secret's as (set)
POST /api/v1/calendar/expand admin, key (never domain-restricted) Expand one iCalendar object's recurrence over a window without storing it - what CalDAV's calendar-query would compute. 6.3.4.
GET / POST /api/v1/remote-domains admin, key (never domain-restricted); POST needs a full key The remote domain policies / create one: outbound and inbound TLS, size and rate limits, replies and forwards, the recipient callout (Routes and Relays §14.2a). 6.3.4.
PUT / DELETE /api/v1/remote-domains/<id> admin, full key Replace the whole policy (a field left out takes the create's default) / delete it. 6.3.4.
GET /api/v1/remote-domains/effective?domain=<name> admin, key (never domain-restricted) The record that governs a delivery to that domain - exact over pattern, longer pattern over shorter, inactive skipped; 404 when none does. 6.3.4.
POST /api/v1/remote-domains/verification-cache/clear admin, full key (never domain-restricted) Forget every remembered callout verdict; answers how many. 6.3.4.
GET /api/v1/reports, /api/v1/reports/<section> admin, key - read-only is enough; a domain-restricted key sees its own domains and is refused volume and storage What happened, per domain and per day: traffic, failures, senders, recipients, mailboxes, volume, storage, summary; from, to, domain, top, format=csv (Monitoring and Health §16.6a). 6.3.4.
GET /api/v1/audit, /api/v1/audit/verify administrator password only Who changed what, with filters and paging / walk the hash chain (Monitoring and Health §16.7b). 6.3.4.
GET / PUT /api/v1/alerts/rules, /api/v1/alerts/rules/<condition> administrator password only The alert rules / change or create one; the webhook secret is write-only. 6.3.4.
GET / POST /api/v1/alerts/events, /api/v1/alerts/test, /api/v1/alerts/run administrator password only What has fired / raise a condition on request / run one alerting pass now (Monitoring and Health §16.7a). 6.3.4.
GET /deck-lang/<code>.json none One language's catalogue for the Control Deck, read from WebAdmin/languages/<code>.json; 404 for a code that is not a file there. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/calendars[/<id>[/shares/<address>]], /api/v1/me/calendar/events[/<id>[/reply]], /api/v1/me/tasks[/<id>], GET /api/v1/me/freebusy account The webmail's calendars and tasks: several calendars with colours, shared with an account or a group as reader, writer or delegate; a window's events occurrence by occurrence; an invitation answered as the owner or a delegate; free-busy for the caller's own domain. 6.3.4.
GET / POST /api/v1/me/messages/<id>/invitation, .../invitation/reply account The invitation a message carries, and the answer mailed to the organiser (iMIP). 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/contacts[/<id>], /api/v1/me/contacts/<id>/photo, /api/v1/me/contacts/groups[/<id>[/members[/<ref>]]], /api/v1/me/address-books[/<id>[/shares[/<target>]]] account The address book (6.3.3), with collected addresses as suggestions until kept, a card's photo, contact groups and several address books shared with an account or a domain since 6.3.4.
GET / PUT /api/v1/me/directory, /api/v1/me/directory/me[/photo], /api/v1/me/directory/<id>/photo, /api/v1/me/directory/<address>/smime account The organisation's directory of the reader's own domain, the reader's own entry, and a colleague's certificates from the domain's S/MIME authority. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/fetch-accounts[/<id>[/download]], /api/v1/me/fetch-accounts/import, /api/v1/me/sender-addresses[/<id>[/verify|/resend]], /api/v1/me/masked-addresses[/<id>] account The person's other accounts and the import with a sign-in to Gmail or Outlook.com; addresses elsewhere proved with a code and sent as; masked addresses. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/subscriptions, /api/v1/me/brand-logo, /api/v1/me/recoverable[/<id>[/restore]], /api/v1/me/confidential[/<id>/revoke], /api/v1/me/offline[/messages], /api/v1/me/lists[/<address>], POST /api/v1/me/recall, GET /api/v1/me/export.zip account Subscriptions and a sender's BIMI logo; deleted items put back; messages that expire; the offline window; managed lists joined; a message recalled from the accounts on this server that have not read it; everything held for the account as one archive. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/recovery[/codes|/resend], /api/v1/me/openpgp[/<fingerprint>|/publish], /api/v1/me/openpgp/keys/..., /api/v1/me/openpgp/lookup, /api/v1/me/protected[/<id>[/content]], /api/v1/me/zero-access[/rewrap], /api/v1/me/smime/enroll, /api/v1/me/smime/issued/<serial>[/confirm] account (never an app password for the recovery, key and zero-access changes) The recovery address and one-time codes; the keys the server publishes and the webmail's own OpenPGP ring; password-protected messages; zero-access; a key signed by the domain's S/MIME authority. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/devices[/<id>], /api/v1/me/push[/subscriptions[/<id>]|/test], /api/v1/me/sender-list[/<id>], /api/v1/me/quarantine-digest account The clients that have signed in, and revoking one; Web Push; the reader's own safe and blocked senders; the quarantine digest. 6.3.4.
GET / POST / PUT / DELETE /api/v1/me/preferences, /identities, /storage, /files[/<id>[/content]], /scheduled[/<id>], /drafts/<id>/schedule, /folders/<id>[/empty|/export], /messages/<id>/html|receipt|snooze|source|unsubscribe, /app-passwords/<id>, /sessions[/<id>], /smime[/chain|/own|/recipients] account The webmail's own routes of 6.3.3; APIs Reference §4 has each.
GET / POST / DELETE /api/v1/passkeys[/<id>], POST /api/v1/passkeys/options; POST /api/v1/passkeys/challenge, GET /api/v1/signin-methods the credential a session answers to (an API key is refused); the last two need none Passkeys enrolled and removed; a passkey sign-in's challenge; what the sign-in page may offer. 6.3.4.
POST / GET /api/v1/password-reset[/complete], /portal/reset, /recovery/verify none A forgotten password, answered 202 in one sentence whatever the address; the reset page; a recovery address confirmed. 6.3.4.
GET / POST /portal/oidc/start, /portal/oidc/callback, /portal/saml/start, /portal/saml/acs, /portal/saml/finish, /saml/metadata none Single sign-on by OpenID Connect or SAML 2.0 into the webmail and the Deck, ending in a one-use ticket for POST /api/v1/session. 6.3.4.
GET / POST /confidential/<token>, /api/v1/protected/<token>[/reply], /portal/protected/<token>, /lists/... none The reading pages of a message that expires and of a password-protected one; the list manager's pages. 6.3.4.
GET /api/v1/help, /api/v1/help/<page> none The documents built into the server, behind every help button. 6.3.4.
GET / POST / DELETE /api/v1/setup/checklist, /api/v1/setup/sample-domain admin, key; writes a full key The first-hour checklist judged by the server; the sample domain made and removed. 6.3.4.
GET / POST / DELETE /api/v1/server/drain; POST /api/v1/diagnostics/run admin, key (never domain-restricted); writes a full key Maintenance mode entered, read and left; the diagnostic tests run. 6.3.4.
GET /api/v1/server/settings/pending, /api/v1/settings/changed administrator password only The saved settings not yet running; what this server has changed from stock. 6.3.4.
GET / POST /api/v1/settings/environment, /api/v1/settings/levels, /api/v1/settings/profiles[/<name>[/apply]|/revert|/discard|/decline] admin, key (never domain-restricted); writes a full key What the environment manages; the settings with levels; the four profiles, a diff, an apply with its confirmation, and the way back. 6.3.4.
POST /api/v1/audit/<id>/revert administrator password, or a role holding manage_settings; never a key A setting put back from its audit row. 6.3.4.
GET / POST / DELETE /api/v1/administrator/second-factor, POST /api/v1/addresses/erase, /api/v1/accounts/<address>/export[.zip] administrator password only (reading the second factor: any key) The administrator's second factor; an address's traces erased; a mailbox or everything held on an account exported. 6.3.4.
GET / POST / PUT / DELETE /api/v1/accounts/<address>/rules[/<id>], /api/v1/domains/<name>/rules[/<id>] admin, key (the account's or domain's own); writes a full key An account's own rules, and a domain's own. 6.3.4.
GET / PUT / POST / DELETE /api/v1/accounts/<address>/directory[/photo], .../address-books[/<id>/shares/<target>], .../recovery[/codes], .../devices[/<id>], .../push, .../openpgp[/<fingerprint>], .../move, .../password, .../unlock, .../support-session admin, key scoped to the address's domain; writes a full key An account's directory entry, address books, recovery, clients, Web Push, published keys, rename or move, a password reset and an unlock; a support session is 6.3.3's, the rest 6.3.4.
GET / PUT / POST / DELETE /api/v1/review[/<id>[/release]], /api/v1/accounts/<address>/review[/unlock] administrator password, or a role holding the permission; never a key Messages held by compromised-account detection; an account's state and its lock. 6.3.4.
GET / POST / PUT / DELETE /api/v1/roles[/<name>], /api/v1/role-assignments[/<address>], /api/v1/message-trace[/address/<address>|/queue/<id>] admin, key; writes a full key Administrator roles and who holds them; the message trace. 6.3.4.
GET / POST / PUT / DELETE /api/v1/sender-list[/<id>], /api/v1/domains/<domain>/sender-list[/<id>], /api/v1/greylisting-bypass[/<id>], /api/v1/domain-policy-list[/<id>], /api/v1/journal-rules[/<id>] admin, key (the server's lists never domain-restricted); writes a full key The server's and a domain's allow and block lists; the greylisting bypass list; the domain white and black list; journaling and dual-delivery rules. 6.3.4.
GET / POST / DELETE /api/v1/domains/<domain>/dkim/rotation[/start|/check|/switch|/retire|/next], /api/v1/dkim/rotation/run, /api/v1/domains/<domain>/bounces, .../suppressions[/<address>], /api/v1/bounces/classify|expire, /api/v1/domains/<domain>/complaints, /api/v1/complaints/rates|expire, /api/v1/domains/<domain>/spam-traps[/hits], /api/v1/spam-traps/<address>, /api/v1/spam-traps/hits, /api/v1/reputation[/check], /api/v1/session-policy, /api/v1/domains/<domain>/session-policy admin, key; writes a full key DKIM rotation, bounces and the suppression list, complaints, spam traps, the reputation watch and the session policy. 6.3.4.
GET / POST / DELETE /api/v1/domains/<domain>/dmarc-reports[/summary|/<id>], .../tls-reports, /api/v1/domain-reports/expire, /api/v1/domains/<domain>/smime-ca[/rotate|/issued/<serial>] admin, key scoped to the domain; writes a full key The DMARC and TLS-RPT reports a domain receives; a domain's S/MIME authority. 6.3.4.
GET / POST / PUT / DELETE /api/v1/webhooks[/<name>|/deliveries|/run|/test] administrator password, or a role holding the permission; never a key Outbound webhooks and their delivery queue. 6.3.4.
GET / POST /api/v1/lists/<address>/members[/<member>/approve|/reset-bounces], /api/v1/lists/<address>/archive[/<id>] admin, key scoped to the list's domain; writes a full key Who a list delivers to now; a request to join approved; a member's bounces cleared; a managed list's archive. 6.3.4.
GET / POST / PUT / DELETE /api/v1/logs/sessions[/<id>], /api/v1/queue/shaping[/<destination>], /api/v1/sandbox/held[/<queue_id>[/release]], /api/v1/warmup[/<id>[/history]] admin, key (never domain-restricted); writes a full key A connection followed live; outbound shaping's state; messages held for a sandbox; IP warm-up schedules. 6.3.4.
GET / POST / DELETE /api/v1/migrations[/<id>[/start|/pause|/resume|/summary.csv|/jobs[/<job>[/retry|/cancel|/signin|/report]]]] admin, key; writes a full key A migration of jobs, one per mailbox, from IMAP, Google Workspace or Microsoft 365. 6.3.4.
GET / POST /api/v1/backup/archives, /api/v1/backup/<archive>/mailboxes/<address>[/restore], /api/v1/backup/restore-mailbox/<job>, POST /api/v1/backup/restore admin, key (never domain-restricted); writes a full key The archives; one mailbox or one message restored, and the job's outcome; a whole archive restored. 6.3.4.
POST / GET /api/v1/send, /api/v1/send/<queue_id> an API key naming a sender and holding send_through_api - nothing else Mail sent as an application, and what became of each recipient. 6.3.4.
GET / POST / PUT / PATCH / DELETE /scim/v2/Users[/<id>], /scim/v2/Groups[/<id>], /scim/v2/ServiceProviderConfig, /scim/v2/Schemas, /scim/v2/ResourceTypes an API key holding provision - nothing else SCIM 2.0 provisioning of accounts, aliases and lists by the organisation's identity provider. 6.3.4.
GET / POST /api/v1/discovery/cases, /api/v1/discovery/cases/<id>, POST .../<id>/close a role holding view_discovery to read (the auditor role holds it), manage_discovery to change (serveradmin); the administrator password; a key whose role holds them eDiscovery: the cases, one case whole with its searches, holds and exports, and a case closed with every live hold released. On the audit trail. 6.3.4 (schema 6097)
POST / GET /api/v1/discovery/cases/<id>/searches, .../searches/<search>, POST .../searches/<search>/export, GET .../exports/<export> as above; a search or export that read the archive also needs view_archive, and a download view_mailbox A search of every named mailbox, its deleted items and the archive by person, date, words and attachment, queued and paged; its hits exported as EML and mbox in a zip with a manifest of SHA-256s; the zip downloaded (413 past 500 MB). 6.3.4
POST / DELETE /api/v1/discovery/cases/<id>/holds, .../holds/<hold> manage_discovery; a confined credential holds nothing outside its domains A legal hold on an account, a domain or the archived copies a search matched - it stops deletion, retention, a purge, a move to another domain and a full restore - and its release. 6.3.4
GET / POST / PUT / DELETE /api/v1/inbound-webhooks, /api/v1/inbound-webhooks/<address> view_configuration to read; manage_inbound_webhooks to change (only serveradmin holds it as shipped, and a key whose role holds it); scoped to the route's domain Mail in to a webhook: an address, or a whole hosted domain, routed to a URL, the mail that arrives posted there as SendGrid-style form fields or one JSON document, signed. The URL is answered whole only to a credential that may change it; the secret never. 6.3.4 (schema 6100)
GET / POST / DELETE /api/v1/inbound-webhooks/deliveries, .../deliveries/<id>, POST .../deliveries/<id>/retry, GET .../deliveries/<id>/message, POST /api/v1/inbound-webhooks/run as above; the kept message also needs view_mailbox; run a credential that reaches every domain The queue of posts, one removed or tried again, the message a delivery kept, and one pass now. 6.3.4
GET / PUT / DELETE / POST /api/v1/accounts/<address>/mailbox-encryption, POST .../mailbox-encryption/recover, /api/v1/domains/<domain>/mailbox-recovery admin, key scoped to the account's or the domain's own domain; writes a full key Mailbox encryption at rest: the account's state, switched on or off, its key forfeited (?confirm=forfeit), a forgotten password recovered with the domain's passphrase; the domain's recovery key made, replaced or removed. On the audit trail. 6.3.4 (schema 6099)
GET /api/v1/me/mailbox-encryption account Whether the account's own mailbox is encrypted at rest; read-only. 6.3.4
GET / PUT /api/v1/accounts/<address>/booking-policy admin, key scoped to the address's domain; PUT a full key A room's or a piece of equipment's booking policy and its delegates (the account's kind is set on PUT /api/v1/accounts/<address>). 6.3.4 (schema 6101)
GET / POST /api/v1/store, POST /api/v1/store/measure admin, key (never domain-restricted); measure a full key holding manage_server The message store's last measurement - what the messages are, what their files take, what compression and de-duplication save - and a measurement started now. 6.3.4 (schema 6098)
POST /api/v1/me/folders/<id>/bulk account One action - flags, a move or a delete - over many messages of a folder, the webmail's select-all, done by the server in bounded steps and continued by before_uid. 6.3.4
GET / PUT / POST /api/v1/portal/branding, /api/v1/portal/files, POST /api/v1/scheduled/run, POST /api/v1/settings/cache/clear, /settings/indexing/index|clear, /settings/scripting/check|reload admin, key (branding readable by anyone); writes a full key The webmail's branding and file-link policy, the send-later pass, the caches, the index and the event script. 6.3.3. POST /api/v1/settings/breach-check/test and /settings/push/vapid are 6.3.4.
GET / POST / PUT / DELETE /api/v1/dns-blacklists, /api/v1/surbl-servers, /api/v1/whitelist-addresses, /api/v1/blocked-senders, /api/v1/incoming-relays, /api/v1/greylisting-white-addresses and /api/v1/blocked-attachments, each with /<id>; /api/v1/domains/<domain>/domain-aliases[/<name>]; POST /api/v1/sieve/evaluate admin, key (the lists never domain-restricted; a domain's aliases scoped to it); writes a full key The anti-spam lists and the blocked attachments; a domain's other names; a Sieve script tried against a message without storing anything. 6.3.3.
GET /portal-lang/<code>.json, /portal-smime.js none The webmail's catalogues, and its S/MIME module. 6.3.3.

New in 6.2.28: GET /api/v1/update and POST /api/v1/update/check|download|install (the live update), GET /portal (the self-service sign-in page) and the account's own routes under /api/v1/me (state, password, automatic reply, quarantine, folders, messages, search, send, drafts, attachments, settings, Sieve filters) with POST/DELETE /api/v1/session for browser sessions. Since 6.3.4, each folder in GET /api/v1/me/folders also carries uidnext beside uidvalidity: the folder's current UID plus one, which is what IMAP STATUS reports as UIDNEXT and the first REST route to report a folder's current UID at all.

How many routes. The OpenAPI document the server emits is the count to trust. It is made of the path literals in RestApi*.cpp and of the settings groups, whose paths RestApiSettings.cpp writes from its group tables (OpenApiPath), so that no literal shows them: /api/v1/settings and its named groups, each a GET and all but directories a PUT - nine paths and 17 operations in 6.3.3 and at 3233b0cc4, thirteen and 25 at master 540347500. Counted both ways together, the document names 146 paths and 219 path-and-method operations in 6.3.3, 167 paths and 246 operations at commit 3233b0cc4, 15 September 2026 (163 and 242 of them under /api/), 417 paths and 588 operations at master 540347500, 24 September 2026, and 441 paths and 621 operations in 6.3.4 (92b8f9f63; 413 and 585 under /api/; the rest are /scim/v2, the sign-in, reset and reading pages, the list manager's pages and the static catalogues and scripts). The path literals alone give 137 and 202, 158 and 229, 404 and 563, and 428 and 596; the thirteen settings groups are the same thirteen, 25 operations, in 6.3.4. The twenty-four paths 6.3.4 adds over 540347500 are the rows above tagged 6.3.4 that name eDiscovery, the inbound webhooks, mailbox encryption at rest, the booking policy, the store and the folder's bulk action. This table is the map, not the census; APIs Reference §4 has a row per route.

Path-parsing rules that catch people out: routing is exact-match on method and path after the query string is stripped; ids in queue, quarantine, IP-range, archive and /api/v1/me/... paths must be 1–18 decimal digits and greater than zero, or the route is simply unknown and answers 404; a trailing slash is not the same path.

6. Worked examples

Every example uses the loopback, plain-HTTP shape — the one a fresh RestApiPort=8443 + RestApiBindAddress=127.0.0.1 gives you. With RestApiCertificateFile/RestApiPrivateKeyFile set (or an ACME certificate present), change http:// to https://. Bodies below are the shapes the format strings in RestApiServer.cpp produce, pretty-printed for reading; the server sends them on one line.

Status and inventory

curl -u Administrator:your-admin-password http://127.0.0.1:8443/api/v1/status
{"version":"6.2.28","state":3,"processedMessages":10412,"spamMessages":318,
 "virusesRemoved":4,"sessions":{"smtp":2,"imap":11,"pop3":0}}

state is 0 Unknown, 1 Stopped, 2 Starting, 3 Running, 4 Stopping. There is no uptime field, whatever the OpenAPI summary says.

curl -u Administrator:your-admin-password http://127.0.0.1:8443/api/v1/domains
[{"name":"example.com","active":true},{"name":"example.net","active":true}]

Accounts

# List
curl -u Administrator:pw http://127.0.0.1:8443/api/v1/domains/example.com/accounts

# Create - only "address" and "password" are read; the account is created active,
# with its INBOX, and the password is hashed with PreferredHashAlgorithm
curl -u Administrator:pw -H "Content-Type: application/json" \
     -d '{"address":"newuser@example.com","password":"S0me-Long-Passphrase"}' \
     http://127.0.0.1:8443/api/v1/domains/example.com/accounts

# Delete
curl -u Administrator:pw -X DELETE \
     http://127.0.0.1:8443/api/v1/accounts/newuser@example.com
{"address":"newuser@example.com","created":true}

Failure shapes: 400 when a field is missing, when the address is not in the named domain, or when the account limit check refuses it (its own message is passed through); 404 {"error":"domain not found"}; 409 {"error":"account already exists"}.

The delivery queue

curl -u Administrator:pw http://127.0.0.1:8443/api/v1/queue
{"count":1,"messages":[
  {"id":81234,"created":"2026-09-08 09:12:44","from":"sales@example.com",
   "recipients":"buyer@slow-host.example","next_try":"2026-09-08 10:12:44",
   "locked":false,"tries":3}
]}
curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/queue/81234/retry   # {"retried":true}
curl -u Administrator:pw -X DELETE http://127.0.0.1:8443/api/v1/queue/81234       # {"deleted":true}

Retry resets the next-try time and kicks delivery. A 404 means the id is not a message in Delivering state and not one held for ETRN — including an id that has just been delivered.

DNS records you can publish

curl -u Administrator:pw http://127.0.0.1:8443/api/v1/tlsa
curl -u Administrator:pw http://127.0.0.1:8443/api/v1/srv

/api/v1/tlsa returns one record per configured certificate file, already formatted: _25._tcp.<host>. IN TLSA 3 1 1 <hex>. /api/v1/srv derives _imaps, _imap, _pop3s, _pop3, _submissions and _submission from the configured TCP/IP ports (port 25 is never advertised, loopback-bound ports are skipped), adds _autodiscover._tcp when the web-services HTTPS listener is actually running and AutoconfigEnabled=1, and emits a record set for every active domain.

Logs and the backup

curl -u Administrator:pw http://127.0.0.1:8443/api/v1/logs
curl -u Administrator:pw "http://127.0.0.1:8443/api/v1/logs/hmailserver_2026-09-08.log?lines=50"
curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/backup   # 202 {"started":true}
curl -u Administrator:pw http://127.0.0.1:8443/api/v1/backup           # poll for status

lines defaults to 200 and is capped at 2000. The name must be a bare *.log file name with no path — anything else is 400 {"error":"not a log file name"}.

The account's own routes

# What the mailbox owner sees about themselves
curl -u alice@example.com:her-password http://127.0.0.1:8443/api/v1/me
{"address":"alice@example.com","domain":"example.com","active":true,
 "quota":{"limit_mb":250,"used_bytes":48213004},
 "vacation":{"enabled":false,"active":false,"subject":"","message":"",
             "expires":false,"expires_date":""},
 "password_changed":"2026-06-02 08:31:10","second_factor":false,"directory_linked":false}
# Turn an automatic reply on. PUT takes the whole state at once.
curl -u alice@example.com:her-password -X PUT \
     -H "Content-Type: application/json" \
     -d '{"enabled":true,"subject":"Away","message":"Back on the 20th.",
          "expires":true,"expires_date":"2026-09-20"}' \
     http://127.0.0.1:8443/api/v1/me/vacation

# Change the password. "current" must be the account password itself,
# not an app password, even if an app password is what signed you in.
curl -u alice@example.com:her-password -X POST \
     -H "Content-Type: application/json" \
     -d '{"current":"her-password","new":"a-better-passphrase"}' \
     http://127.0.0.1:8443/api/v1/me/password

Present the administrator password or an API key to any of these and you get 403 {"error":"this endpoint answers to an account's own credentials, not to the administrator password or an api key"}. Present an account's credentials anywhere else and you get 403 {"error":"an account's credentials reach only the account's own endpoints under /api/v1/me"}.

A browser session, by hand

# Sign in; keep the cookie
curl -c cookies.txt -u alice@example.com:her-password \
     -X POST http://127.0.0.1:8443/api/v1/session
# {"address":"alice@example.com","idle_seconds":1800,"lifetime_seconds":43200}

# Read with the cookie alone
curl -b cookies.txt http://127.0.0.1:8443/api/v1/me

# Write with the cookie - the CSRF header is required
curl -b cookies.txt -X PUT -H "X-Requested-With: hMailServer" \
     -H "Content-Type: application/json" -d '{"enabled":false}' \
     http://127.0.0.1:8443/api/v1/me/vacation

# Sign out
curl -b cookies.txt -X DELETE -H "X-Requested-With: hMailServer" \
     http://127.0.0.1:8443/api/v1/session

The live update (new in 6.2.28)

curl -u Administrator:pw http://127.0.0.1:8443/api/v1/update              # the verdict
curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/update/check
curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/update/download
curl -u Administrator:pw -X POST http://127.0.0.1:8443/api/v1/update/install
stateDiagram-v2
    [*] --> NotChecked
    NotChecked: 0 not checked since the service started
    UpToDate: 1 the running version is the latest on the channel
    Available: 2 a newer release exists
    Downloaded: 3 its installer is downloaded and Sigstore-verified
    Installing: 4 handed to hMailServer.Updater.exe
    Failed: 5 the last check failed, lastError says why

    NotChecked --> UpToDate: check finds nothing newer
    NotChecked --> Available: check finds a newer release
    NotChecked --> Failed: the feed could not be read
    UpToDate --> Available: a later check
    Available --> Downloaded: POST /update/download verifies it
    Available --> Failed: download or verification failed
    Downloaded --> Installing: POST /update/install
    Installing --> [*]: the service stops and starts
    Failed --> Available: a later check succeeds
Loading

Source: Common/Util/UpdateChecker.h state enumerators; the same numbers appear on COM Status.UpdateState; since 6.3.6 two more, 6 while the service prepares an administrator's install and 7 when it is ready, and where the service runs as NT SERVICE\hMailServer, POST /update/install answers 409 update_install_by_administrator - Install update in the Control Panel on the server installs it, with one Windows prompt. POST /update/check reads the feed whether or not UpdateCheckEnabled is on. POST /update/download fetches the installer and its Sigstore bundle into <DataDirectory>\Updates, verifies them, and deletes anything that fails — nothing is run. Only POST /update/install runs it, and the service stops and starts during the update.

6.3.4, the install drains first: it enters maintenance mode (POST /api/v1/server/drain does the same by hand, and DELETE leaves it), so the listeners are closed and delivery paused before the helper stops the service, and it refuses with "already draining" while somebody else's drain is in progress. A rolled-back update's outcome is reported as soon as the service is back. On Linux the install applies too: the server writes a request into /run/hmailserver, and a root path unit runs /usr/lib/hmailserver/hmailserver-update, which verifies the package again as root, installs it and rolls back when the service does not come back (Installing on Linux). Since 6.3.4 the default feed is https://updates.progressiverobot.com, which GitLab Pages serves from the project's releases. Up to 6.3.3 it is https://api.github.com/repos/Progressiverobot/hmailserver/releases/latest, which has answered 404 since GitHub suspended the project's account on 18 September 2026, so such a server's check fails with The update feed answered HTTP 404. until UpdateFeedUrl names https://updates.progressiverobot.com/releases.json.

7. Every status code this listener can return

Code When Body What to do
200 The call worked route-specific JSON —
201 Something was created: an account, a list, an IP range, a key, a draft, a session the created thing —
202 POST /api/v1/backup accepted; it runs on the maintenance queue {"started":true} Poll GET /api/v1/backup
400 Malformed request line, a NUL byte anywhere, a missing or bad JSON field {"error":"..."} Read the message; it names the field
401 No credential, wrong password, unknown/expired/out-of-network key — deliberately indistinguishable. Also any key touching /api/v1/apikeys, and since 6.3.4 any key on the other routes that answer to the administrator password alone (section 2); on /scim/v2 any credential but a key, in SCIM's shape {"error":"authentication failed"} Check the credential; check the log, which says which kind failed
403 Authenticated but not permitted: read-only key on a mutating route, domain-restricted key out of its domains or on a server-wide route, wrong credential class for /api/v1/me, missing CSRF header {"error":"<the reason>"} The reason is a fixed sentence and is accurate
404 Unknown route, or a named object that does not exist {"error":"not found"} or e.g. {"error":"account not found"} Check the path shape and the id rules
409 It already exists, or the state forbids it (account exists, a backup is running, a directory-linked password, a recently used password) {"error":"..."} —
411 The request used Transfer-Encoding — a body with no declared length {"error":"length required"} Send Content-Length; this server does not de-chunk
413 Head + body over 64 KB, or over 16 MB on the two routes that carry attachments {"error":"request too large"} Split the request; the connection is closed
429 Over 200 requests in the current 10-second window for this credential {"error":"too many requests"} with Retry-After: 10 Wait the window out; it is fixed, so waiting works
500 A handler threw, or the server could not do the thing {"error":"internal error"} or a specific message Check the application log
503 A subsystem is not running (the backup manager) {"error":"..."} —

100 Continue is sent when a client asks for it and the declared length has already passed the cap. Any other status a handler tried to return is rewritten to 500 — deliberately, because a wrong number in a response line is worse than an honest server error.

Every API response carries Content-Type: application/json and Cache-Control: no-store. The exceptions are the static pages /, /index.html, /portal, /portal.js and the attachment download, which serve their own media types and are also no-store.

8. Limits, and what a client feels when it hits one

Limit Value Applies to Symptom
Request size 64 KB head + body every route 413, connection closed
Large-request exception 16 MB and a 300 s deadline POST /api/v1/me/messages, POST /api/v1/me/drafts only 413 above that
Request deadline 30 s absolute, from when the server starts waiting to the last byte out; the TLS handshake counts every other route connection closed with no response
Connection lifetime 300 s, whatever it is doing every connection connection closed
Requests per connection 1000 keep-alive clients the connection is closed after the 1000th response
Concurrent connections 64, enforced at accept before the TLS handshake the listener connection closed; the log says how many have been refused
Worker threads 4 the listener requests queue
Rate 200 requests per fixed 10 s window per credential each credential separately 429 + Retry-After: 10; one log line per credential per window
Auto-ban shared with SMTP/IMAP/POP3 accounting; loopback peers exempt failed authentications after the ban trips, the address is refused at accept for 5 minutes

The rate budget is per credential and not per source address, on purpose: rotating source addresses must not multiply what one leaked key can spend, and two honest keys behind one NAT must not be able to starve each other.

9. Troubleshooting

Symptom Most likely cause Check
Connection refused, nothing in the log about the REST API RestApiPort is still 0, or the service was not restarted after the edit (since 6.3.4 the key is marked reinitialize: Apply now, or POST /api/v1/server/reinitialize) The startup log; RestApi: Listening on ... is the only proof it is up
Refusing to start - the administrator password is not set Fresh install with no administrator password Set one, then restart
Refuses to start on 0.0.0.0 TLS is mandatory off loopback Set the certificate pair, enable ACME, or bind 127.0.0.1
It started on 0.0.0.0 after ACME issued Expected. ACME reinitialises the application after writing fullchain.pem, and the REST listener picks it up then RestApi: Using the ACME certificate for HTTPS.
401 on a key that worked yesterday The default lifetime is 90 days and there is no "never expires" GET /api/v1/apikeys shows expired per key; the log names the label
401 from a key that is definitely correct It is being presented from an address outside AllowedFrom, or the store record's Hash is not 64 lower-case hex and is being ignored The application log distinguishes these; the response never does
403 this api key is read-only Scope defaults to read-only when a create request does not name one, and any Scope value other than the literal full reads as read-only Re-issue with "scope":"full"
403 on /api/v1/queue from a working key The key has a Domains list; the queue is server-wide and is refused outright rather than narrowed wrongly Use an unrestricted key for queue work
401, not 403, when a key touches /api/v1/apikeys Deliberate — a key must learn nothing about key management, not even whether a verb exists Use the administrator password
403 with the X-Requested-With message from a browser The request is cookie-authenticated and changes something Add X-Requested-With: hMailServer
429 with Retry-After: 10 200 requests in ten seconds on one credential Slow the poll down; a once-a-second status poll is 5% of the budget
Sudden "connection refused" after several bad passwords The auto-ban tripped and the address is refused at accept for five minutes Wait, or connect from loopback, which is exempt

10. Where to go next

  • APIs Reference — every route with its request and response shape, the API key store format and its fail-closed rules, the web-services and metrics listeners, and the COM object model.
  • Settings Reference — RestApiPort, RestApiBindAddress, RestApiCertificateFile, RestApiPrivateKeyFile and every other key, with defaults and timing.
  • Settings Overview — the same keys in the shape an administrator edits them.

Domains

# Create - the name is checked as the Control Panel checks it (a valid domain name,
# not one a domain alias already has); every other setting takes the default a new
# domain gets there. "active" defaults to true; "postmaster" is optional.
curl -u Administrator:pw -H "Content-Type: application/json" \
     -d '{"name":"example.com","active":true,"postmaster":"postmaster@example.com"}' \
     http://127.0.0.1:8443/api/v1/domains

# Switch off (and on again); "postmaster" changes only when it is named
curl -u Administrator:pw -X PUT -H "Content-Type: application/json" \
     -d '{"active":false}' http://127.0.0.1:8443/api/v1/domains/example.com

# Delete, with everything in it
curl -u Administrator:pw -X DELETE http://127.0.0.1:8443/api/v1/domains/example.com
{"name":"example.com","active":true,"postmaster":"postmaster@example.com"}

Failure shapes: 400 when name (POST) or active (PUT) is missing, or when the save is refused (the Control Panel's own sentence is passed through: "The domain name you have entered is not a valid domain name.", "A domain alias with this name already exists."); 404 {"error":"domain not found"}; 409 {"error":"domain already exists"}. Creating and deleting a domain are server-wide: a key restricted to named domains is refused with 403 even for its own name, as is a read-only key for all three. The name cannot be changed over the API; renaming stays with COM. Released in 6.3.0.

Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally