Skip to content

The REST API

chrisholloway5 edited this page Sep 10, 2026 · 7 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.

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.

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 → API & monitoring.

The Control Panel's API and monitoring page, where the REST listener's port, bind address and certificate files are set, alongside the metrics listener and ManageSieve

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/me* and /api/v1/session 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

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 the route under /api/v1/apikeys?"}
    G -- yes --> U["401 - a key learns nothing about key management"]
    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?"}
    J -- yes --> F4["403 - the queue / the quarantine is server-wide"]
    J -- no --> K{"IP ranges, certificates, rules, logs, backup, settings or update?"}
    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_.

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, then the least recently used if still full
        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 1000 sessions; expired go first, then least recently used A script cannot fill memory with sign-ins
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"}

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 / add one
PUT / DELETE /api/v1/ports/<id> admin, full key Replace / delete a listener. 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
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

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.

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

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 {"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 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 ReferenceRestApiPort, 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.

Clone this wiki locally