HTTP server
mercurio serve runs an HTTP notification API in the same binary. Clients
send a message to the URLs in the request (stateless) or to a
configuration stored on the server under a key (stateful). There is no web
UI.
mercurio serve # listens on 127.0.0.1:8000
mercurio serve --bind 0.0.0.0:8000 -v # all interfaces, warnings too
There is no TLS: bind to localhost or put a reverse proxy in front. Turn on authentication before exposing it. The install page has a container image and a systemd unit.
Settings
Every setting is a flag of mercurio serve and an environment variable
(named as in apprise-api); the flag wins. Boolean values are true when they
start with y, t, 1, e, a or +. --bind ADDR (default
127.0.0.1:8000) sets the listen address. From mercurio serve --help:
--config-dir <DIR> Where stored configurations are kept (relative to the working directory) [env: APPRISE_CONFIG_DIR=] [default: mercurio-config]
--stateful-mode <MODE> `hash`, `simple` or `disabled`: layout of stored configurations [env: APPRISE_STATEFUL_MODE=] [default: hash]
--secret-key <KEY> Salt of `hash` file names [default: built-in] [env: SECRET_KEY]
--stateless-mode <YES|NO> `no` refuses stateless `POST /notify` [env: APPRISE_STATELESS_MODE=] [default: yes]
--stateless-urls <URLS> URLs used by `POST /notify` when the request gives none [env: APPRISE_STATELESS_URLS]
--config-lock <YES|NO> Hide configuration content and management from everyone but the administrator [env: APPRISE_CONFIG_LOCK=] [default: no]
--admin <YES|NO> Offer the `GET /cfg` key list (`simple` mode only) [env: APPRISE_ADMIN=] [default: yes]
--config-max-length <KIB> Largest `config` accepted by `/add`, in KiB [env: APPRISE_CONFIG_MAX_LENGTH=] [default: 512]
--upload-max-memory-size <MIB> Upper bound of `--config-max-length`, in MiB [env: APPRISE_UPLOAD_MAX_MEMORY_SIZE=] [default: 3]
--recursion-max <N> `include` levels followed, and the highest `X-Apprise-Recursion-Count` accepted [env: APPRISE_RECURSION_MAX=] [default: 1]
--allow-templates <YES|NO> Honour `template:` sections and `${NAME}` markers [env: APPRISE_ALLOW_TEMPLATES=] [default: yes]
--default-format <FORMAT> `text`, `html` or `markdown` for requests without `format` [env: APPRISE_DEFAULT_FORMAT=]
--deny-services <LIST> Services that can not be used (one scheme names the whole service) [env: APPRISE_DENY_SERVICES=] [default: windows,dbus,gnome,macosx,syslog]
--allow-services <LIST> When set, the only services that can be used (the deny list is ignored) [env: APPRISE_ALLOW_SERVICES=]
--attach-size <MIB> Largest attachment in MiB; `0` turns attachments off [env: APPRISE_ATTACH_SIZE=] [default: 200]
--max-attachments <N> Attachments per request; `0` means no limit [env: APPRISE_MAX_ATTACHMENTS=] [default: 6]
--attach-allow-url <PATTERNS> Attachment URLs that may be downloaded [env: APPRISE_ATTACH_ALLOW_URL=] [default: *]
--attach-reject-url <PATTERNS> Attachment URLs that are always refused (checked first) [env: APPRISE_ATTACH_REJECT_URL=] [default: "127.0.* localhost*"]
--webhook-mapping-max-depth <N> Deepest nested path a `?:a.b=dest` mapping rule may read [env: APPRISE_WEBHOOK_MAPPING_MAX_DEPTH=] [default: 5]
--webhook-url <URL> `http(s)://` URL that receives the result of every notification [env: APPRISE_WEBHOOK_URL]
--storage-dir <DIR> Persistent storage for services (tokens, sessions) [default: CONFIG_DIR/store] [env: APPRISE_STORAGE_DIR=]
--storage-mode <MODE> `auto` or `flush` write to the storage directory; `memory` keeps nothing [env: APPRISE_STORAGE_MODE=] [default: auto]
--stateless-storage <YES|NO> Stateless sends use persistent storage too [env: APPRISE_STATELESS_STORAGE=] [default: no]
--log-level <LEVEL> Default detail of returned logs [env: LOG_LEVEL=] [default: INFO]
--log-format <FORMAT> Server log on stderr: `text`, or `json` for one JSON object per line [env: LOG_FORMAT=] [default: text] [possible values: text, json]
--http-redirects <YES|NO> `no` stops following HTTP redirects for URLs without `?redirect=` [env: APPRISE_HTTP_REDIRECTS=] [default: yes]
--prune-enabled <YES|NO> `no` turns off the daily clean-up of old persistent storage and unused logins [env: APPRISE_PRUNE_ENABLED=] [default: yes]
--prune-interval-seconds <N> Seconds between clean-ups (at least 2; the first runs at start-up) [env: APPRISE_PRUNE_INTERVAL_SECONDS=] [default: 86400]
--prune-timeout-seconds <N> Longest a clean-up may run, in seconds (kept below the interval) [env: APPRISE_PRUNE_TIMEOUT_SECONDS=] [default: 28800]
--auth-prune-seconds <N> Age in seconds after which a login without a configuration is removed [env: APPRISE_AUTH_PRUNE_SECONDS=] [default: 2592000]
--emojis <YES|NO> `yes` replaces `:shortcode:` emoji unless a URL says `?emojis=no` (also `-j`); `no` never does; unset lets each URL decide [env: APPRISE_INTERPRET_EMOJIS=]
--stream-worker-count <N> Live streams sending at the same time [env: APPRISE_STREAM_WORKER_COUNT=] [default: 4]
--worker-count <N> Workers: each takes 128 requests in progress at once, more get `503` [env: APPRISE_WORKER_COUNT=] [default: 4]
--worker-timeout <SECONDS> Seconds a request head, and each piece of its body, may take to arrive [env: APPRISE_WORKER_TIMEOUT=] [default: 30]
--stream-queue-size <N> Further live streams admitted; more get `503` [env: APPRISE_STREAM_QUEUE_SIZE=] [default: 8]
--auth-required <YES|NO> Turn on Basic Auth [env: APPRISE_AUTH_REQUIRED=] [default: no]
--user <NAME> Administrator user name (may be empty) [env: APPRISE_USER=]
--password <PASSWORD> Administrator password; enables the administrator login [env: APPRISE_PASSWORD]
--basic-auth-realm <REALM> Realm shown in login prompts [env: APPRISE_BASIC_AUTH_REALM=] [default: "Mercurio API"]
URL and host rules (APPRISE_ATTACH_*_URL) are separated by spaces or
commas. A rule is a host (*.example.com, cdn.net:8080), a URL with an
optional path prefix (https://example.com/files/, example.com/files/),
or internal for every address that is not public (loopback, private,
link-local). * matches any run of characters, ? one.
Limits: a request body up to APPRISE_ATTACH_SIZE plus 1 MiB (at least 8 MiB; 413
beyond), 256 MiB of request bodies
being read at once (a request is charged its Content-Length, 1 MiB when it
has none; more get 503 with Retry-After: 1), APPRISE_WORKER_COUNT times
128 requests in progress (512 by default; more get 503 with
Retry-After: 1). A request head, and each piece of a body, must arrive
within APPRISE_WORKER_TIMEOUT seconds (30 by default).
Request log
Every request is one line on stderr, with the fields that apply:
2026-10-20T13:53:41.630353Z INFO request: method="POST" route="/notify/{key}" status=424 duration_ms=35.555 remote=127.0.0.1 key="k1" apprise_id="chain-1" bytes_in=41 services=2 failed=1 error="One or more notification could not be sent" detail="skipped bogus://x/...: unsupported URL scheme: bogus://"
route is the matched path (absent for a 404), key the Config ID
used, services and failed count the services notified, error is the
message of an error answer and detail lists problems that did not fail
the request. A live stream logs stream=true; its results are not in the
line. A /status or /metrics request that succeeds (the container’s
health check, a metrics scrape) is not logged. -v adds warnings, -vv
everything at INFO, -vvv debug output.
--log-format json (LOG_FORMAT=json) writes one JSON object per line
instead, with the same fields as keys, for log collectors:
{"timestamp":"2026-10-20T13:53:41.630353Z","level":"INFO","method":"POST","route":"/notify/{key}","status":424,"duration_ms":"35.555","remote":"127.0.0.1","key":"k1","services":2,"failed":1,"error":"One or more notification could not be sent","target":"request"}
Endpoints
/status, /status/{key} GET health check: OK or the problems found (JSON with Accept: application/json)
/details GET supported services as JSON; ?all=yes includes disabled ones
/metrics GET request counters in the Prometheus text format
/notify POST stateless: send to the URLs in the request
/notify/{key} POST send through the stored configuration
/add/{key} POST store a configuration
/get/{key}, /cfg/{key} POST return the stored configuration
/del/{key} POST delete the stored configuration (and its login)
/move/{key} POST move a configuration and its login to the free key `to`
/json/urls/{key} GET the stored URLs, their tags and template names
/cfg GET the stored keys (simple mode)
/auth/{key} GET, POST, DELETE read, set or remove the key's login
Keys are 1 to 128 letters, digits, _ or -; a path with any other key is
404. Paths work with or without a trailing slash. A known path with the
wrong method returns 405 with an Allow header.
/details describes each service as Apprise’s details() does: URL
templates, tokens, args and kwargs, plus a setup_url that links to
its page in this book.
/add, /get, /del, /move, /json/urls and /auth also work without
the key in the path: send it in an X-Apprise-Config-ID header instead,
which keeps it out of access logs. The header wins over a key in the path;
an invalid header is 400.
Requests
Every POST accepts form fields (application/x-www-form-urlencoded), a JSON
object (Content-Type: application/json) or multipart/form-data (text
parts are fields, file parts attachments).
urls /notify, /add URLs separated by commas or whitespace; a JSON list works too
body notify message body; required unless there is an attachment
title notify message title
type notify info (default), success, warning, failure
format notify input format: text, html, markdown
tag, tags notify tag expression
template notify ${NAME} values: a JSON object, template[name]=value fields or NAME=VALUE strings
attach, attachment, attachments notify http(s):// URLs to download; in JSON also {url, filename} or {base64, filename} objects
config /add TEXT or YAML configuration
format /add text, yaml, or empty / auto to detect
to (JSON), from + to (form) /move destination key (form: also the source)
Plain text bodies
A notification can also be the whole request body. The content type sets
its format: text/plain, text/html or text/markdown; ?format= wins.
curl -X POST -H 'Content-Type: text/plain' -H 'X-Apprise-Tag: devops' \
--data-raw 'Deployment completed' http://127.0.0.1:8000/notify/demo
Headers
X-Apprise-Title the title
X-Apprise-Type the type
X-Apprise-Tag the tag expression
X-Apprise-Template template values as URL-encoded pairs (api_key=abc&room=ops)
X-Apprise-Config-ID the key, instead of the path (kept out of access logs)
X-Apprise-Log-Level detail of the returned logs: trace, debug, info, warning, error, critical
X-Apprise-Recursion-Count servers this call already went through; above --recursion-max is 406
X-Apprise-ID id of the call chain, passed on by apprise:// targets
When a field comes from several places, the first found wins: the payload,
then the header, then the query string (?title=, ?type=, ?format=,
?tag=). Header values must be ASCII.
Field mapping
Query parameters starting with : rename payload fields before they are
read, for senders that can not use these names:
| Rule | Effect |
|---|---|
?:subject=title | the subject field becomes title |
?:debug= | the debug field is removed |
?:type=warning | type is set to warning |
?:event.items[0].text=body | a nested value (dots and [index], at most 5 steps) becomes body; a missing path is 400 |
curl -X POST -H 'Content-Type: application/json' \
-d '{"subject": "CPU", "event": {"state": "critical"}}' \
'http://127.0.0.1:8000/notify/demo?:subject=title&:event.state=body'
Alertmanager and Grafana webhooks
A JSON payload with an alerts list and a status, and no body, is read
as an Alertmanager or Grafana webhook, so either can post straight to
/notify/{key} (or to /notify with urls):
| Field | From |
|---|---|
title | Grafana’s title; otherwise Alertmanager’s default subject, e.g. [FIRING:2] HighCPU (prod critical) |
body | Grafana’s message; otherwise one line per alert, [FIRING] name: summary, with the description below it |
type | success once resolved; while firing, the highest severity label: critical, error or page give failure, info gives info, anything else warning |
A type (or title, body) in the payload, the query or an X-Apprise-*
header still wins, and ?tag= picks the stored entries as usual:
# alertmanager.yml
receivers:
- name: mercurio
webhook_configs:
- url: http://127.0.0.1:8000/notify/ops?tag=oncall
Tags
tag | Notifies |
|---|---|
| absent | stored configuration: entries without tags; stateless: everything |
devops | entries tagged devops |
devops critical (also &, +) | entries tagged both |
dev, qa (also |) | entries tagged dev or qa |
leaders teamA, leaders teamB | (leaders and teamA) or (leaders and teamB) |
all | everything |
Entries tagged always are included whenever a tag is given. A JSON list is
a list of alternatives; a nested list is an AND group. Characters other than
letters, digits, _, -, : and the separators are 400.
Attachments
Multipart file parts become attachments (type from the part, else the file
name). URLs are downloaded by the server after the URL rules allow them;
anything that is not http(s):// (a server-local path, file://) is
refused, so clients can never read files on the server. Any problem with an
attachment is 400 Bad Attachment (the reason is logged).
Responses
The response format follows Accept, and, without one (or with */*), the
request’s content type:
- JSON (
application/json): errors are{"error": "..."}; successes of/add,/del,/moveare{"error": null}; - otherwise plain text.
Notifications answer with their log: the library’s own lines for this
request (DEBUG - -> HTTP 200, WARNING - ..., URLs redacted) and one line
per service (2026-10-17 12:00:00,000 - INFO - Sent json:// (JSON) notification.),
in the order they happened and at or above the request’s log level
(X-Apprise-Log-Level, default LOG_LEVEL); a JSON failure is
{"error":"One or more notifications could not be sent","details":[[level, time, message], ...]}.
With Accept: text/html the log is an HTML list (<ul class="logs">).
Live progress
?stream=yes or Accept: text/event-stream answers with server-sent
events while sending: a log event per log line as it happens (the same
lines as above), : heartbeat comments every 15 s while waiting and one
final result event.
: connected
event: log
data: {"asctime":"2026-10-17 12:00:00,000","level":"INFO","message":"Sent json:// (JSON) notification.","service":"json"}
event: result
data: {"status":"SUCCESS"}
The status is SUCCESS, PARTIAL, FAILURE, NOMATCH or TIMEOUT.
Streams beyond the stream limits get 503 with
Retry-After: 15.
Result webhook
With APPRISE_WEBHOOK_URL, every notification POSTs
{"source": "<client address>", "status": 0 | 1, "output": [[level, time, message], ...]}
to that URL (credentials in the URL become Basic Auth).
Stored configurations
/add/{key} stores either urls (saved as TEXT, only the URLs that parse)
or a config in TEXT or YAML. A configuration with no usable URL is still
saved; YAML that can not be read at all is 400. /add answers with
X-Apprise-Config-Created, X-Apprise-Config-MTime and
X-Apprise-Timezone headers (UTC).
APPRISE_STATEFUL_MODE | Files |
|---|---|
hash | <dir>/<h[0..2]>/<h[2..]>.text or .yaml, gzip compressed, h = sha224(SECRET_KEY + key) |
simple | <dir>/<key>.cfg or <key>.yml, plain text |
disabled | none; stateful endpoints answer 403 |
Beside each entry: .<name>.created (creation time) and .<name>.lock
(the key’s login). The layout is the one the existing API servers use, so
their configuration directory can be mounted as it is (with the same
SECRET_KEY in hash mode).
/get returns YAML as text/yaml, TEXT as text/plain or
{"format", "config"} as JSON; 204 when nothing is stored. /del returns
200; 204 when nothing was stored. /notify/{key} without a stored
configuration is 404.
/json/urls/{key} lists every entry:
{
"tags": ["devops", "home"],
"urls": [
{"id": "1a2b3c4d", "service_name": "JSON", "enabled": true,
"url": "json://127.0.0.1:8766/ops", "retry": 0, "optional": false,
"tags": ["devops"],
"tag_details": [{"name": "devops", "priority": 0, "token": "devops", "exact": "0:devops"}],
"template": {}}
]
}
?privacy=yes hides everything after the host of each URL. template maps
each ${NAME} of the entry to its configuration default (null with
privacy or APPRISE_CONFIG_LOCK). ?tag= filters, comma separated.
Stored configurations go through the library’s configuration loader: tags,
groups, ${NAME} templates (values from the request, the configuration
defaults, then APPRISE_TEMPLATE_<NAME>) and include, which may only
point at http(s)://.
Authentication
With APPRISE_AUTH_REQUIRED=yes every request needs Basic Auth:
- the administrator (
APPRISE_USER/APPRISE_PASSWORD) may do everything; - a key’s own login may use that key (in the path or
X-Apprise-Config-ID),/statusand/details; - anything else is
401withWWW-Authenticate.
Each key has one access mode:
| Access | Effect |
|---|---|
user | its login may read, change and clear the configuration; tags optional; may send stateless notifications with X-Apprise-Config-ID |
locked | its login may only notify, with a specific tag (not all); content hidden |
public | anyone may POST /notify/{key} with a specific tag; everything else needs a login |
disabled | only the administrator may use the key (403 otherwise) |
# The administrator gives a key its own login; the key's user may later
# change the password (not the access). GET reads it, DELETE removes it.
curl -u admin:pw -H 'Content-Type: application/json' \
-d '{"access": "user", "username": "alice", "password": "s3cret"}' http://127.0.0.1:8000/auth/team
APPRISE_CONFIG_LOCK turns user into locked without changing what is
saved. It allows reading or changing configuration content only to the
administrator. Logins are stored as salted PBKDF2-SHA256 digests;
successful checks are remembered for five minutes. Without
APPRISE_AUTH_REQUIRED, saved logins are ignored and /auth answers 403.
Status codes
| Code | Meaning |
|---|---|
| 200 | sent, saved, deleted, moved or the requested data |
| 204 | nothing stored for the key (/get, /cfg/{key}, /del, /json/urls) |
| 400 | bad payload, key, header, tag, template, format or attachment; no URLs; no body and no attachment |
| 401 | Basic Auth missing or wrong |
| 403 | refused by a mode, the lock or the key’s access |
| 404 | unknown path or key; no stored configuration for /notify/{key} |
| 405 | known path, wrong method |
| 406 | X-Apprise-Recursion-Count above the limit |
| 409 | /move to a key that is taken |
| 413 | request body over the limit (attachment size plus 1 MiB, at least 8 MiB) |
| 417 | /status found a problem (CONFIG_PERMISSION_ISSUE, STORE_PERMISSION_ISSUE) |
| 424 | at least one notification failed; nothing matched the tag |
| 500 | the configuration or login could not be saved or read |
| 503 | too many requests or live streams in progress |