-
Notifications
You must be signed in to change notification settings - Fork 3
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.
[Settings]
RestApiPort=8443
RestApiBindAddress=127.0.0.1Restart 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 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
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.
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/statusOmit 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.
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/statussequenceDiagram
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
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"]
Source: RestApiServer::Authorize_, RestApiServer::IsMutatingRoute_,
RestApiServer::IsSelfServiceRoute_.
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
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"}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.
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.
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}]# 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"}.
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.
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.
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 statuslines 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"}.
# 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/passwordPresent 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"}.
# 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/sessioncurl -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/installstateDiagram-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
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.
| 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.
| 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.
| 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 |
- 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,RestApiPrivateKeyFileand every other key, with defaults and timing. - Settings Overview — the same keys in the shape an administrator edits them.
# 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.
hMailServer 6.3.2 · AGPL-3.0-or-later · Repository · Report a documentation error
Hmail Server — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- The Control Panel
- Your First Domain and Mailbox
- Connecting a Mail Client
- DNS for Your Domain
2. Secure it
3. Operate it
- Monitoring and Health
- Backup and Restore
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Routes and Relays
- The COM API and Scripting
- The REST API
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey