Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

RuleEffect
?:subject=titlethe subject field becomes title
?:debug=the debug field is removed
?:type=warningtype is set to warning
?:event.items[0].text=bodya 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):

FieldFrom
titleGrafana’s title; otherwise Alertmanager’s default subject, e.g. [FIRING:2] HighCPU (prod critical)
bodyGrafana’s message; otherwise one line per alert, [FIRING] name: summary, with the description below it
typesuccess 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

tagNotifies
absentstored configuration: entries without tags; stateless: everything
devopsentries 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)
alleverything

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, /move are {"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_MODEFiles
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
disablednone; 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), /status and /details;
  • anything else is 401 with WWW-Authenticate.

Each key has one access mode:

AccessEffect
userits login may read, change and clear the configuration; tags optional; may send stateless notifications with X-Apprise-Config-ID
lockedits login may only notify, with a specific tag (not all); content hidden
publicanyone may POST /notify/{key} with a specific tag; everything else needs a login
disabledonly 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

CodeMeaning
200sent, saved, deleted, moved or the requested data
204nothing stored for the key (/get, /cfg/{key}, /del, /json/urls)
400bad payload, key, header, tag, template, format or attachment; no URLs; no body and no attachment
401Basic Auth missing or wrong
403refused by a mode, the lock or the key’s access
404unknown path or key; no stored configuration for /notify/{key}
405known path, wrong method
406X-Apprise-Recursion-Count above the limit
409/move to a key that is taken
413request body over the limit (attachment size plus 1 MiB, at least 8 MiB)
417/status found a problem (CONFIG_PERMISSION_ISSUE, STORE_PERMISSION_ISSUE)
424at least one notification failed; nothing matched the tag
500the configuration or login could not be saved or read
503too many requests or live streams in progress