curl:
main, a temporary authentication dependency failure
returns HTTP 503 with Retry-After: 1. Wait and retry with the same credentials.
Endpoint reference
Management endpoints are API-key authenticated withAuthorization: Bearer mantis_live_....
Dashboard helper endpoints that need browser access also accept the mantis_session
httpOnly cookie. Public trigger, status, health, wallet, and dev-inbox routes
are called out explicitly below.
Parsed request bodies are bounded.
POST /api/keys and
POST /api/api-keys reject JSON bodies over 64 KiB with
413 payload_too_large. Apple Wallet log bodies are capped at 32 KiB, and the
dev inbox capture path is capped at 1 MiB.
Supported installer type values are shell, shell-sudo,
macos-login, macos-boot, macos-wake, macos-network,
linux-boot, linux-wake, linux-network, windows-logon,
windows-wake, windows-network, css-background, js-clone-detector,
nfc-ndef, homeassistant, homeassistant-receiver, and scrypted.
Webhook destinations get an HMAC secret. Outbound raw-webhook deliveries include
X-Mantis-Timestamp and X-Mantis-Signature: sha256=<hex> over
<timestamp>.<json body>. The plaintext secret is only shown on create, replace,
explicit reveal, or rotate responses; normal listing returns a fingerprint. The
secret of a global webhook
destination is revealed or rotated by an admin on the settings page.
API key scope: full vs enroll
Every API key carries ascope, orthogonal to is_admin:
full(default) — behaves as described throughout this page. Subject to the admin / non-admin visibility rules.enroll— create-only. An enroll key may call onlyPOST /api/keys. Every other management route (list/read/update/delete keys — including the ones it created — plus/api/hits/recent,/api/api-keys, the audit log, and any session-reachable route) returns403 forbidden, and an enroll key cannot log in to the dashboard.is_admin: truetogether withscope: "enroll"is rejected at validation.
- Body.
memo,external_id,response_kind(giforempty) anddedupe_window_seconds(at most 600). Anything else —expires_at, monitor settings,response_payload, a redirect / HTML / JSON response,self_origins, or anexternal_idin themantis:device:namespace — is refused with403 forbidden. - Destinations. Accepted only when every
channel:targetpair is listed in the server’sMANTIS_ENROLL_DESTINATIONS; otherwise403, before anything is stored or sent. Unset means an enroll key cannot attach destinations at all, and fleet alerts are routed by global destinations. The response never includes activation error text. - Volume. One enroll key can create at most
MANTIS_ENROLL_KEYS_PER_HOURnew keys per hour (default 1000); beyond that it gets429. Re-claims are not counted. - Fleet. Each enroll key belongs to one fleet, set when it is minted (
owner_api_key_id, default: the minting admin). It can re-claim anexternal_idonly inside that fleet — see Idempotent creation.
external_id it knows or guesses (alert routing is never included, and the memo is null unless it created the key). Knowing a machine’s trigger URL lets someone fire false alarms or step around the tripwire, so treat serial numbers as guessable and watch the audit log: each such claim is recorded as key.claimed with cross_key: true.
See the Kandji recipe in the product repo’s deploy/kandji/.
Idempotent creation
POST /api/keys also accepts an optional external_id (1–128 chars, matching
^[A-Za-z0-9][A-Za-z0-9._:-]*$) stored on a unique column. When supplied, a
repeat POST with the same external_id returns the existing key —
"reused": true with HTTP 200 instead of 201 — rather than minting a
duplicate. The other body fields (memo, destinations, …) apply only when the
row is actually created; a later claim never changes what an operator
configured. Keys created without an external_id are unaffected (unique
constraint treats NULLs as distinct).
This is the mechanism the fleet-enrollment flow relies on — one key per machine
serial, so re-running enrollment on a reimaged machine reuses its key instead of
littering the list. External ids are guessable and unique across the whole
instance, so a repeat POST only resolves inside the caller’s fleet:
- the key’s creator — the key as it could read it anyway, with
"created_by_caller": true; - an enrollment-scoped key of the same fleet — the reduced shape: trigger
URL,
public_id,external_id, expiry anddisabled, withmemoset tonullunless it created the key. No alert routing, no signing secrets. Audited askey.claimedwithcross_key: true; - an admin — keys of the operators’ own fleet, in full. Adopting a key
another fleet created needs
"adopt": truein the body (audited withadopted: true); - anyone else —
409 conflictwith no key details, audited askey.claimedwithdenied: true.
owner_api_key_id. Enroll keys minted
before 0.3.0 have no recorded owner and count as the operators’.
A disabled or expired key is never handed back: the claim returns
409 conflict, so enrollment fails visibly instead of arming a device with a
tripwire that cannot fire. A claim that races a concurrent delete also returns
409 conflict; retry.
One case adds to an existing key: when an enrollment-scoped key re-claims a key
that an enroll key created, any approved destination in the request that the
key lacks is attached. A serial claimed early by someone holding the enroll key
therefore still ends up routed once the real device enrolls.
Notification rows in hit listings
Every hit returned by/api/keys/:id/hits and /api/hits/recent carries a
notifications array — one row per destination the hit fanned out to, with
channel, status, attempts, max_attempts, next_attempt_at,
succeeded_at, last_error, target and destination_scope.
destination_scope is key for a destination attached to the key itself,
global for an instance-wide global destination,
and unknown when the destination has since been deleted. target is null
unless the caller may see it: admins always may; a non-admin key owner sees
only the targets of the key’s own destinations. Global-destination targets are
Slack / Discord / Teams / Home Assistant webhook URLs configured by an admin —
they are credentials, so a non-admin never receives them. last_error follows
the same rule: a caller who may not see the target gets the HTTP status only
(for example HTTP 500 (details visible to admins)). The dashboard hit feed and
mantis hits render a placeholder for redacted rows.
A row whose destination was removed before delivery ends as aborted.
Response kinds for the trigger endpoint
Webhook payload
key.url is the trigger URL: requesting it fires the key again, so never render it as a link or let a chat client preview it. Link key.dashboard_url, the key’s page in the dashboard, instead. Home Assistant payloads carry the same pair as key_url and dashboard_url, plus the hit’s referer.
Webhook payloads also include a parsed host_context object when the hit came from one of our installer snippets:
shell-sudo hit you’d see "source": "shell-sudo", "sudo_cmd": "apt update --quiet". For a linux-network hit, "source": "linux-network", "network_interface": "wlan0". Fields not relevant to the installer are null.
host_context is null for hits that didn’t include X-Mantis-* headers (e.g., a file/folder key, or a regular curl to the URL).
Webhooks are sent through a Postgres-backed retry queue (no Redis required). On failure the notification is retried with exponential backoff: 1m, 5m, 30m, 2h, 12h (each with ±20% jitter), giving up after 5 attempts. Delivery state is tracked on each hit’s notifications array.