The CLI is the recommended interface, but everything it does is exposed over a plain JSON HTTP API. Useful for scripts in languages without a Mantis client, or for testing the trigger path with curl:
On server builds from 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 with Authorization: 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 a scope, 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 only POST /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) returns 403 forbidden, and an enroll key cannot log in to the dashboard. is_admin: true together with scope: "enroll" is rejected at validation.
Enroll keys are the intended credential for MDM / fleet provisioning: you embed one on every managed machine and accept that a curious user will extract it. So an enroll key can only mint a plain tripwire:
  • Body. memo, external_id, response_kind (gif or empty) and dedupe_window_seconds (at most 600). Anything else — expires_at, monitor settings, response_payload, a redirect / HTML / JSON response, self_origins, or an external_id in the mantis:device: namespace — is refused with 403 forbidden.
  • Destinations. Accepted only when every channel:target pair is listed in the server’s MANTIS_ENROLL_DESTINATIONS; otherwise 403, 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_HOUR new keys per hour (default 1000); beyond that it gets 429. 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 an external_id only inside that fleet — see Idempotent creation.
What an extracted enroll key can still do: create noise keys up to the hourly cap, and recover the trigger URL of a key in its own fleet whose 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 and disabled, with memo set to null unless it created the key. No alert routing, no signing secrets. Audited as key.claimed with cross_key: true;
  • an admin — keys of the operators’ own fleet, in full. Adopting a key another fleet created needs "adopt": true in the body (audited with adopted: true);
  • anyone else — 409 conflict with no key details, audited as key.claimed with denied: true.
A fleet is all admin keys plus the enroll keys they own, or one non-admin full key plus the enroll keys bound to it with 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:
For a 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.