Appearance
mmune API overview
What this page covers
This page is the foundation for anyone writing a client against the mmune HTTP API: how to reach it, how to authenticate, what each role may call, how requests and responses are shaped, how errors look, how long-running work is tracked, and which part of the API serves what. The five reference pages (listed in the router map at the end) document individual endpoints. WebSocket and streaming endpoints are on WebSockets and streaming.
Prerequisites: a running mmune deployment you can reach over HTTP, and an account on it from your administrator (see account and access). The examples use https://mmune.example.com as the host. The curl examples also assume jq is installed. The Python examples use the requests package and the TypeScript examples use the built-in fetch (Node 18 or later, or any browser).
Base URL and versioning
All application routes live under /api/v1/. There is no other API version, and the version is part of the path, not a header. In a standard install the API is served on the same host and port as the web UI: https://mmune.example.com/api/v1/....
In a standard install, API requests may run for up to 660 seconds before the connection is closed, and a new connection must be established within 75 seconds. Of the WebSocket endpoints, only /api/v1/monitoring/ws is forwarded by a standard install (see WebSockets and streaming).
A few routes sit outside /api/v1/:
| Path | Method | Auth | Notes |
|---|---|---|---|
/health | GET | none | Liveness probe. Returns {"status": "ok"} and nothing else. |
/ | GET | Bearer JWT | Returns {"message": "Welcome to mmune API", "status": "running"}. |
A few collection routes are defined with a trailing slash (for example GET /api/v1/projects/), and requesting them without it returns a 307 redirect. Use the paths exactly as the reference pages list them.
Interactive docs and openapi.json
A deployed mmune does not serve Swagger UI, ReDoc or an openapi.json schema. An anonymous request to /docs, /redoc or /openapi.json gets 401, and an authenticated one gets 404. Use this reference instead.
Authentication
The model in one paragraph
Every HTTP request and every WebSocket handshake must carry a Bearer JWT with at least the read permission, except for the endpoints listed below. The check runs before the request reaches any endpoint and cannot be switched off.
What is exempt from the JWT check
| Endpoint | Why |
|---|---|
POST /api/v1/auth/login | The caller has no token yet. |
POST /api/v1/auth/mfa/verify-login | Second step of login, consumes the MFA challenge token. |
POST /api/v1/auth/refresh | Exchanges a still-valid token for a new one. |
GET /health | Liveness check. |
POST /api/v1/probes/heartbeat | Self-authenticated with the X-Probe-Key header. |
POST /api/v1/discovery-agents/{agent_id}/heartbeat, GET and PUT /api/v1/discovery-agents/{agent_id}/config, POST /api/v1/discovery-agents/{agent_id}/discoveries, POST /api/v1/discovery-agents/{agent_id}/kafka-flow-samples | Self-authenticated with the agent's own registration token. |
Accounts and credentials
Accounts are managed by your administrator. Ask them for an account, and for its email and password, before you start. Service accounts and per-agent API keys are not offered yet, and the HTTP API has no SSO, OIDC or SAML login. For machine-to-machine access, log in with an account your administrator created for the integration and use the token it returns. Give integration scripts an account with only the read permission when read access is enough.
An account has a role (admin or user) and a list of permissions (read, write, admin). A password changed through POST /api/v1/auth/change-password persists across restarts and applies on every API replica.
Login flow
POST /api/v1/auth/loginwith{"email": "...", "password": "..."}.- If the account has no MFA enrolled, the response is the token:
json
{
"access_token": "<TOKEN>",
"token_type": "bearer",
"expires_in": 1800,
"user": {
"id": "1",
"email": "you@example.com",
"name": "Example User",
"role": "admin",
"permissions": ["read", "write", "admin"]
}
}- If the account has MFA enabled, the response is a challenge instead. It authenticates nothing by itself:
json
{ "mfa_required": true, "mfa_token": "<MFA_TOKEN>", "expires_in": 300 }- Finish with
POST /api/v1/auth/mfa/verify-loginand{"mfa_token": "<MFA_TOKEN>", "token": "<6-digit TOTP or backup code>"}. The response has the same shape as the step 2 token response. Themfa_tokenis single use and is retired on success. - Send the token on every request as
Authorization: Bearer <TOKEN>.
Check for the mfa_required key in the login response rather than assuming a token came back.
Token format and lifetime
The access token is a JWT.
| Claim | Type | Description |
|---|---|---|
sub | string | User id. |
email | string | Account email. |
name | string | Display name. |
role | string | admin or user. |
permissions | array of string | Copied from the account at login time. |
exp | number | Expiry, seconds since the Unix epoch. |
jti | string | Unique token id, used for revocation. |
The lifetime is set by your deployment's configuration and defaults to 30 minutes (expires_in is 1800). The MFA challenge token lives 5 minutes. Permissions are fixed at login; a role change applies from the next login.
Refresh
mmune does not issue a separate refresh token. POST /api/v1/auth/refresh takes {"refresh_token": "<TOKEN>"} where the value is your current, unexpired access token. It returns a new access token and revokes the one you sent, so the old token cannot be replayed.
json
{ "access_token": "<NEW_TOKEN>", "token_type": "bearer", "expires_in": 1800.0 }Refresh before expiry. An expired token cannot be refreshed; log in again. The refresh response does not include the user object.
Logout, validation and other auth endpoints
| Method | Path | Body | Result |
|---|---|---|---|
| POST | /api/v1/auth/logout | none | Revokes the presented token server side. Later use returns 401 Token has been revoked. |
| GET | /api/v1/auth/validate-token | none | {"valid": true, "user_id": "...", "email": "...", "role": "..."} |
| GET | /api/v1/auth/me | none | Profile of the token's user. |
| POST | /api/v1/auth/change-password | current_password, new_password | Changes the caller's own password. |
| GET | /api/v1/auth/mfa/status | none | {"enabled": false} |
| POST | /api/v1/auth/mfa/setup | none | {"secret": "...", "qr_code_base64": "..."} (PNG, base64). Not active until enabled. |
| POST | /api/v1/auth/mfa/enable | token | Activates MFA and returns {"backup_codes": [...]}. Shown once. |
| POST | /api/v1/auth/mfa/disable | password | Turns MFA off. Requires the current password. |
Revoked tokens are refused on every API replica. Details of these endpoints belong on Security and platform.
Failed logins
POST /api/v1/auth/login allows 5 failed attempts per (client IP, email) pair in a 60 second window. The sixth attempt gets 429 with Retry-After: 60 and {"detail": "Too many failed login attempts. Try again in a minute."}. A successful login clears the counter. Wrong MFA codes at verify-login are recorded against the same counter. This limit is separate from the general rate limit described below.
Probe key
Probe nodes call POST /api/v1/probes/heartbeat with the shared secret in an X-Probe-Key header instead of a JWT. The secret is the MMUNE_PROBE_KEY environment variable on the server. If it is unset the endpoint answers 503 (Probe enrollment is not configured (MMUNE_PROBE_KEY missing)); a wrong or missing header gets 401 (Invalid probe key). Body: {"node_id": "...", "hostname": "...", "capabilities": {}} and the response is {"status": "ok"}. Listing probes (GET /api/v1/probes) is a normal JWT-protected read.
Discovery agent tokens
A deployed discovery agent authenticates to its own routes with the registration_token minted when an admin calls POST /api/v1/discovery-agents/register. The agent sends it as Authorization: Bearer <registration_token>, and it only works for the agent_id in the path. A human JWT with write permission is also accepted on those routes. A request with no credential gets 401; a human JWT without write gets 403. See Integrations and discovery.
Authorization
Authorization is enforced in two steps.
Step one runs on every request, right after the token is decoded. It applies these checks in order:
- The token needs the
readpermission (or roleadmin). Otherwise403withrequired: "read". - If the
(method, path)is on the admin list below, the role must beadmin. Otherwise403withrequired: "admin". - If the method is not
GET,HEADorOPTIONS, the token needswrite(or roleadmin), unless the(method, path)is on the read-sufficient list below. Otherwise403withrequired: "write".
So any mutating call needs write unless the lists below say otherwise. WebSocket handshakes only need read. License state never blocks authorization, so an administrator can always apply a renewal.
Step two is a per-endpoint check that repeats or tightens the rule for specific routes. For example, GET /api/v1/audit/events and GET /api/v1/audit/events/{event_id} require role admin even though they are GETs. A few GET routes that change state (the OAuth authorize and callback routes) also need write, because the method rule cannot see them.
Admin-only endpoints
| Method | Path |
|---|---|
| PUT | /api/v1/healing/config |
| PUT | /api/v1/containment/config |
| POST | /api/v1/discovery-agents/register |
| DELETE | /api/v1/discovery-agents/{agent_id} |
| DELETE | /api/v1/logs |
| POST | /api/v1/license |
| POST | /api/v1/guardian/scan |
| GET | /api/v1/license/attestation |
Mutating calls a read-only token may make
| Method | Path | Reason |
|---|---|---|
| POST | /api/v1/auth/change-password | Own password. |
| POST | /api/v1/auth/logout | Own session. |
| POST | /api/v1/auth/mfa/setup, /enable, /disable | Own MFA enrolment. |
| POST | /api/v1/analysis/chat and /api/v1/analysis/chat/stream | Question answering. Action tools inside chat check the caller's write permission themselves. |
| POST | /api/v1/rag/query | Retrieval only. |
| POST | /api/v1/semantic/find-semantic-match | Compute only. |
| POST | /api/v1/mapping/suggest | Suggestions only. |
| POST | /api/v1/orchestration/plan | Plans, does not execute. |
Every other POST, PUT, PATCH and DELETE needs write. Several of the read-only POSTs spend LLM budget when a real provider is configured.
Authorization failures
The first-step check returns a machine-readable body:
json
{
"detail": {
"code": "insufficient_permission",
"required": "write",
"message": "Your role cannot do this. Ask an administrator."
}
}required is read, write or admin. Per-endpoint checks return a plain string instead, for example {"detail": "Permission 'write' required"}, {"detail": "Role 'admin' required"} or {"detail": "Admin access required"}. Clients should accept both shapes. A refused write request is recorded in the audit trail.
Request and response conventions
JSON and headers
Requests with a body use Content-Type: application/json. Responses are JSON unless noted on the reference pages. The non-JSON responses are the SSE chat stream, the Markdown runbook download on incidents (text/markdown), the finished report page from GET /api/v1/reports/{report_id} (HTML), and the Prometheus text from GET /api/v1/monitoring/metrics (text/plain; version=0.0.4). The artifact download on orchestration is JSON sent with a Content-Disposition: attachment header, so it is a file download rather than a different body format. Field names are snake_case. Timestamps are ISO 8601 strings, mostly UTC.
Every response carries these headers:
| Header | Meaning |
|---|---|
X-Request-ID | Echoes your X-Request-ID request header, or a generated UUID. |
X-Correlation-ID | Echoes your X-Correlation-ID request header, or a generated UUID. |
X-Response-Time | Server time for the request, for example 0.042s. |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset | See Rate limits. |
Cache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache, Expires: 0 | Responses are not cacheable. |
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block, Strict-Transport-Security: max-age=31536000; includeSubDomains, Referrer-Policy: strict-origin-when-cross-origin, Content-Security-Policy | Standard browser hardening. |
Send an X-Request-ID of your own when you want to correlate a call with server logs. A 401 or 403 also carries these headers.
Pagination
There is no single pagination scheme. Three are in use.
Page-based, used by GET /api/v1/projects/, GET /api/v1/audit/events, GET /api/v1/incidents, GET /api/v1/probes and GET /api/v1/masking:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-based page number. Values below 1 become 1. |
page_size | integer | 10 | Items per page. Clamped to the range 1 to 100. |
These responses wrap the items in a named array and add page and page_size, usually with total (projects, incidents, probes and masking do; the audit events response has no total). Example: {"projects": [...], "total": 3, "page": 1, "page_size": 10}.
Limit-based, on routes such as GET /api/v1/discovery/first-findings (limit 1 to 500, default 50) and GET /api/v1/logs (limit up to 1000, default 100, plus after_id to return only newer rows). Check the reference page for each route's bounds.
Cursor-based, for job logs only: the cursor query parameter on job status endpoints (see Async jobs).
Filtering and sorting
Filters are plain query parameters named for the field, such as status, severity, integration_id, provider, project_id, start_time and end_time (ISO 8601). Only GET /api/v1/projects/ supports the shared search, sort_by and sort_order (asc or desc) parameters, with sort_by one of name, created_at, updated_at. Unknown query parameters are ignored.
Request validation
Request bodies are validated against a schema. A body that fails validation returns 422 (see Errors). Query parameters with bounds, such as limit or cursor, also return 422 when out of range.
Errors
Envelopes
Three envelope shapes exist. A client should handle all three.
| Shape | When | Example |
|---|---|---|
{"detail": <string or object>} | Errors raised by a route, and the 401 and 403 responses from the authentication and authorization checks. This is by far the most common. | {"detail": "Not authenticated"} |
{"detail": [ {...}, ... ]} | Request validation failure, status 422. Each item has type, loc, msg and input. | See below. |
{"error": {"code", "message", ...}} | Rate limiting, and unexpected failures that no route handled itself. | See below. |
Validation error:
json
{
"detail": [
{
"type": "missing",
"loc": ["body", "password"],
"msg": "Field required",
"input": {"email": "you@example.com"}
}
]
}Error envelope for invalid input that no route handled itself:
json
{
"error": {
"code": 400,
"message": "<description of the problem>",
"type": "validation_error",
"timestamp": 1764000000.123,
"request_id": "6f1c0d52-3c1e-4d8a-9a53-0f4f8f6c2b11"
}
}In this envelope, invalid input maps to 400 (validation_error), a refused action to 403 (permission_error), an unreachable dependency to 503 (connection_error, message Service temporarily unavailable), and anything else to 500 (internal_error, message Internal server error). timestamp is epoch seconds and request_id matches the X-Request-ID response header.
A 500 can also arrive as {"detail": "<message>"}.
A few routes answer HTTP 200 with an error key instead of an error status: GET /api/v1/discovery/status/{scan_id} returns {"error": "Scan ID not found"} for an unknown id, GET /api/v1/monitoring/data returns {"error": "..."} on failure, and GET /api/v1/health/sap-alerts returns {"error": "...", "alerts": []} on failure. Check for an error key on those routes.
Status codes
| Status | Meaning in this API |
|---|---|
| 200 | Success. Job start endpoints also return 200, not 202. |
| 201 | Resource created, on routes that declare it (for example POST /api/v1/projects/, POST /api/v1/incidents/owners). |
| 307 | Trailing-slash redirect. |
| 400 | Bad input that passed schema validation but failed a business rule, a rejected license token (license_rejected), or a failed connection test or registration. |
| 401 | Missing, malformed, expired, revoked or wrong credential. Includes WWW-Authenticate: Bearer. |
| 403 | Authenticated but not allowed: missing permission or role, or a license block (license_cap_exceeded, license_blocked). |
| 404 | Unknown resource id. |
| 409 | Conflict with current state, for example a reconciliation pairing that cannot be edited or activated in its current status, a duplicate alert rule or incident owner, a report that is not ready yet, or an incident analysis already in progress. |
| 422 | Request validation failure. |
| 429 | Rate limited, or too many failed logins. |
| 500 | Unexpected error. |
| 501 | The feature is not available in this deployment. |
| 503 | A dependency is unavailable: no real AI provider for POST /api/v1/orchestration/plan and /execute, password store unreachable, or MMUNE_PROBE_KEY unset on probe heartbeat. |
The 401 messages
| Condition | detail |
|---|---|
No Authorization header, or it is not Bearer <token> | Not authenticated |
| Bad signature, malformed token, or expired token | Invalid token |
| Token was logged out or already refreshed | Token has been revoked |
| Token is the MFA challenge, not a real access token | MFA verification required |
Token has no sub claim | Invalid token payload |
| Wrong email or password at login | Incorrect email or password |
Rate limits
Two sliding-window limits apply to every HTTP request except the exempt ones. Counters are shared across API replicas.
| Limit | Default | Environment variables |
|---|---|---|
| Global, all callers combined | 6000 requests per 60 seconds | MMUNE_RATE_LIMIT_GLOBAL_CALLS, MMUNE_RATE_LIMIT_GLOBAL_PERIOD |
| Per caller | 18000 requests per 3600 seconds | MMUNE_RATE_LIMIT_PER_KEY_CALLS, MMUNE_RATE_LIMIT_PER_KEY_PERIOD |
The X-API-Key request header is optional and names the rate-limit bucket. Exempt from both limits: OPTIONS requests and any path starting with /health. These defaults are sized for dashboards that poll every few seconds, not as a public abuse limit.
Successful responses carry X-RateLimit-Limit (the per-caller limit), X-RateLimit-Remaining, and X-RateLimit-Reset (epoch seconds, computed as now plus the per-caller window). When a limit trips, the response is 429 with the error envelope, a Retry-After header in seconds, and the same three headers with X-RateLimit-Remaining: 0:
json
{
"error": {
"code": 429,
"message": "Global rate limit exceeded",
"retry_after": 60
}
}The message is Global rate limit exceeded or Rate limit exceeded for this user. Back off for Retry-After seconds and retry.
Idempotency and timeouts
No endpoint reads an Idempotency-Key header. Repeating a POST repeats its effect: each POST /api/v1/discovery/scan creates a new job with a new id, for example. Make retries safe on your side by polling the job you already started instead of resubmitting it.
mmune sets no per-request timeout on routes. The limits you will meet are:
| Where | Limit |
|---|---|
Standard install, on /api/ | Responses may take up to 660 seconds; a connection must be established within 75 seconds. |
| Cloud LLM calls | MMUNE_LLM_TIMEOUT_SECONDS, default 30 seconds per attempt. |
| Local LLM calls | MMUNE_LOCAL_LLM_TIMEOUT_SECONDS, default 120 seconds. |
Chat requests can make several LLM calls in a row, so POST /api/v1/analysis/chat can run for minutes in the worst case. Set your client timeout above 660 seconds if you use a local model, or use the streaming variant on WebSockets and streaming. Any work that takes longer than a few seconds on other routes is an async job. If you put your own reverse proxy in front of mmune, check its timeouts and WebSocket upgrade settings.
CORS
CORS only matters for browser clients on a different origin from the API. Server-side clients are unaffected. Even 401 and 429 responses carry CORS headers.
- Allowed origins: the comma-separated list in
CORS_ORIGINS(orMMUNE_CORS_ORIGINSif that is unset). With neither set, the defaults arehttp://localhost:5173,http://localhost:3000,http://127.0.0.1:5173andhttp://127.0.0.1:3000. The origin ofMMUNE_API_URLis added when it is set. - Credentials are allowed.
- Allowed methods:
GET,POST,PUT,PATCH,DELETE,OPTIONS. - Allowed request headers:
Content-Type,Authorization,X-Request-ID,X-Correlation-ID,X-API-Key. A custom header outside this list, includingX-Probe-Key, fails a browser preflight. - Exposed response headers:
X-Request-ID,X-Correlation-ID,X-Response-Time. The rate-limit headers are not exposed to browser JavaScript.
Read-only mode
MMUNE_READ_ONLY defaults to true. It is a promise that mmune never writes to a monitored client system, and it does not change the status code of ordinary REST calls. It applies wherever mmune could act on a client system:
- Chat action tools (duplicate scan, advisor scan, SAP scan, watchdog check, orchestration plan proposal) are not offered unless
MMUNE_READ_ONLYis explicitlyfalse,0orno. When they are offered, a caller withoutwriteis refused per tool call. - Orchestration steps whose action name contains
apply,execute,write,remediate,heal,delete,migrateorrestoreare not run. The step result is{"status": "blocked_read_only", "reason": "..."}inside the job result, not an HTTP error. - Script execution is limited to dry runs.
Setting it to false only unlocks these paths; the caller's write permission is still checked.
Licensing effects on the API
mmune verifies a vendor-signed license offline. The license state is one of valid, grace, expired, missing or invalid. The API never returns 402. The effects are:
- The app always starts and every read endpoint keeps working in every state. Collected data stays readable.
- Registering a new integration (
POST /api/v1/integrations/discovered/{discovered_id}/register) is refused with403when the license is missing, invalid, expired, or the system cap is reached. The body is{"detail": {"code": "license_cap_exceeded", "message": "...", "max_systems": 25, "current": 25}}for the cap, or{"detail": {"code": "license_blocked", "message": "..."}}for the other states. Integrations already registered are not affected by a restart. - When the state is
expired,missingorinvalid, the watchdog and AutoPilot background services are stopped, and they restart when a valid license is applied. This is not visible as an HTTP status; checkallows_monitoringin the status response. GET /api/v1/license/status(any authenticated user) returns the state,max_systems,systems_used,expires_at,grace_until,allows_registrationandallows_monitoring.POST /api/v1/license(admin) takes{"token": "<LICENSE_JWT>"}, applies it without a restart, and returns{"message": "License applied", "status": {...}}. A rejected token returns400with{"detail": {"code": "license_rejected", "reason": "..."}}.GET /api/v1/license/attestation(admin) returns a signed usage report, or400with codeno_active_license.
Async jobs
Operations that take longer than a request should wait for start a background job and return an id immediately. Progress is read by polling a status route, not by a push channel.
Start routes and their pollers
| Start | Id returned as | Poll |
|---|---|---|
POST /api/v1/discovery/scan | scan_id | GET /api/v1/discovery/status/{scan_id} |
POST /api/v1/integrations/discover | scan_id | GET /api/v1/discovery/status/{scan_id} |
POST /api/v1/integrations/discovered/{discovered_id}/introspect and POST /api/v1/integrations/introspect-all | scan_id | GET /api/v1/discovery/status/{scan_id} |
POST /api/v1/guardian/scan (admin) | job_id | GET /api/v1/guardian/status/{job_id} |
POST /api/v1/orchestration/execute | execution_id | GET /api/v1/orchestration/status/{execution_id} |
POST /api/v1/reports/audit | report_id | GET /api/v1/reports/status/{report_id} |
POST /api/v1/lineage/sync | job_id | GET /api/v1/discovery/status/{job_id} (results holds the sync result rather than schemas). POST /api/v1/lineage/sync/now runs the sync inline and waits. |
Start responses are HTTP 200 and contain message (some routes), the id, and status (RUNNING or PENDING). The two introspect routes differ: POST /api/v1/integrations/discovered/{discovered_id}/introspect returns message, scan_id and discovered_id, and POST /api/v1/integrations/introspect-all returns message and scan_id, with no status.
Status response
The discovery poller returns:
| Field | Type | Description |
|---|---|---|
status | string | PENDING, RUNNING, COMPLETED or FAILED. |
logs | array of string | Log lines from cursor onward. |
next_cursor | integer | Pass this as cursor on the next poll to receive only new lines. |
results | object or null | Set when the job completes. |
error | string or null | Set when the job fails. |
created_at, updated_at | string | ISO 8601 UTC. |
The other pollers return the same core fields (status, logs, next_cursor, results, error) with small differences: orchestration also returns metadata (including plan_id, project_id and session_id), guardian also echoes job_id, and reports returns report_id, created_at and updated_at with its own status and no results field. The finished report is fetched separately from GET /api/v1/reports/{report_id}. Treat COMPLETED and FAILED as terminal. Poll every 1 to 2 seconds at first and back off; polls count against the rate limit.
Unknown ids return 404 on the guardian, orchestration and reports pollers. The discovery poller returns 200 with {"error": "Scan ID not found"}.
Examples
All examples use the host https://mmune.example.com and placeholder credentials. Replace <PASSWORD> and never commit real tokens.
Log in and get a token
curl:
bash
export MMUNE_URL=https://mmune.example.com
TOKEN=$(curl -s -X POST "$MMUNE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d '{"email": "you@example.com", "password": "<PASSWORD>"}' \
| jq -r '.access_token')
echo "$TOKEN" | cut -c1-12If the account has MFA enabled, .access_token is null and the response contains mfa_required and mfa_token. Finish with:
bash
curl -s -X POST "$MMUNE_URL/api/v1/auth/mfa/verify-login" \
-H 'Content-Type: application/json' \
-d '{"mfa_token": "<MFA_TOKEN>", "token": "<TOTP_CODE>"}'Python (requests):
python
import requests
BASE_URL = "https://mmune.example.com"
def login(email: str, password: str, totp_code: str | None = None) -> str:
resp = requests.post(
f"{BASE_URL}/api/v1/auth/login",
json={"email": email, "password": password},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
if body.get("mfa_required"):
if totp_code is None:
raise RuntimeError("Account has MFA enabled; pass totp_code")
resp = requests.post(
f"{BASE_URL}/api/v1/auth/mfa/verify-login",
json={"mfa_token": body["mfa_token"], "token": totp_code},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
return body["access_token"]
token = login("you@example.com", "<PASSWORD>")TypeScript (fetch):
ts
const BASE_URL = "https://mmune.example.com";
export async function login(
email: string,
password: string,
totpCode?: string,
): Promise<string> {
let res = await fetch(`${BASE_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
let body = await res.json();
if (body.mfa_required) {
if (!totpCode) throw new Error("Account has MFA enabled; pass totpCode");
res = await fetch(`${BASE_URL}/api/v1/auth/mfa/verify-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mfa_token: body.mfa_token, token: totpCode }),
});
if (!res.ok) throw new Error(`MFA failed: ${res.status} ${await res.text()}`);
body = await res.json();
}
return body.access_token as string;
}Make an authenticated GET
GET /api/v1/license/status works for any authenticated user.
curl:
bash
curl -s "$MMUNE_URL/api/v1/license/status" \
-H "Authorization: Bearer $TOKEN" | jqPython:
python
resp = requests.get(
f"{BASE_URL}/api/v1/license/status",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json()["state"])TypeScript:
ts
const res = await fetch(`${BASE_URL}/api/v1/license/status`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const status = await res.json();
console.log(status.state);Example response (values are illustrative):
json
{
"state": "valid",
"max_systems": 25,
"systems_used": 4,
"expires_at": "2027-06-30T00:00:00+00:00",
"allows_registration": true,
"allows_monitoring": true
}The real response has more fields (license_id, customer, grace_until, source, install_id and others).
Start an async job and poll it
This starts a discovery scan (needs write) and polls until it finishes.
curl:
bash
SCAN_ID=$(curl -s -X POST "$MMUNE_URL/api/v1/discovery/scan" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"target": "10.0.0.5", "ports": [5432]}' \
| jq -r '.scan_id')
CURSOR=0
while true; do
RESP=$(curl -s "$MMUNE_URL/api/v1/discovery/status/$SCAN_ID?cursor=$CURSOR" \
-H "Authorization: Bearer $TOKEN")
echo "$RESP" | jq -r '.logs[]?'
CURSOR=$(echo "$RESP" | jq -r '.next_cursor // 0')
STATUS=$(echo "$RESP" | jq -r '.status // "UNKNOWN"')
[ "$STATUS" = "COMPLETED" ] && { echo "$RESP" | jq '.results'; break; }
[ "$STATUS" = "FAILED" ] && { echo "$RESP" | jq -r '.error'; exit 1; }
[ "$STATUS" = "UNKNOWN" ] && { echo "$RESP"; exit 1; }
sleep 2
donePython:
python
import time
def run_discovery_scan(token: str, target: str, ports: list[int]) -> dict:
headers = {"Authorization": f"Bearer {token}"}
start = requests.post(
f"{BASE_URL}/api/v1/discovery/scan",
headers=headers,
json={"target": target, "ports": ports},
timeout=30,
)
start.raise_for_status()
scan_id = start.json()["scan_id"]
cursor = 0
while True:
resp = requests.get(
f"{BASE_URL}/api/v1/discovery/status/{scan_id}",
headers=headers,
params={"cursor": cursor},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
# Unknown ids come back as HTTP 200 with only an "error" key.
if "status" not in body:
raise RuntimeError(body.get("error", "unexpected response"))
for line in body["logs"]:
print(line)
cursor = body["next_cursor"]
if body["status"] == "COMPLETED":
return body["results"]
if body["status"] == "FAILED":
raise RuntimeError(body["error"])
time.sleep(2)
results = run_discovery_scan(token, "10.0.0.5", [5432])TypeScript:
ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function runDiscoveryScan(
token: string,
target: string,
ports: number[],
): Promise<unknown> {
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
const start = await fetch(`${BASE_URL}/api/v1/discovery/scan`, {
method: "POST",
headers,
body: JSON.stringify({ target, ports }),
});
if (!start.ok) throw new Error(`${start.status} ${await start.text()}`);
const { scan_id } = await start.json();
let cursor = 0;
for (;;) {
const res = await fetch(
`${BASE_URL}/api/v1/discovery/status/${scan_id}?cursor=${cursor}`,
{ headers },
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const body = await res.json();
// Unknown ids come back as HTTP 200 with only an "error" key.
if (!body.status) throw new Error(body.error ?? "unexpected response");
for (const line of body.logs) console.log(line);
cursor = body.next_cursor;
if (body.status === "COMPLETED") return body.results;
if (body.status === "FAILED") throw new Error(body.error);
await sleep(2000);
}
}The results object for a discovery scan is {"schemas": [...], "count": N}.
What errors look like
Missing token:
bash
curl -si "$MMUNE_URL/api/v1/license/status"text
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
x-request-id: 6f1c0d52-3c1e-4d8a-9a53-0f4f8f6c2b11
{"detail":"Not authenticated"}A read-only token calling a write route:
bash
curl -s -X POST "$MMUNE_URL/api/v1/discovery/scan" \
-H "Authorization: Bearer $READ_ONLY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"target": "10.0.0.5"}'json
{"detail":{"code":"insufficient_permission","required":"write","message":"Your role cannot do this. Ask an administrator."}}A malformed body returns 422 with the detail array shown in Errors above. A rate-limited call returns 429 with the error envelope shown in Rate limits.
A small client-side helper that turns every shape into one message:
Python:
python
class MmuneApiError(Exception):
def __init__(self, status: int, message: str, request_id: str | None):
super().__init__(f"{status}: {message} (request {request_id})")
self.status = status
self.message = message
self.request_id = request_id
def raise_for_mmune(resp: requests.Response) -> None:
if resp.ok:
return
try:
body = resp.json()
except ValueError:
body = {}
message = resp.text
if "error" in body and isinstance(body["error"], dict):
message = body["error"].get("message", message) # error envelope
elif "detail" in body:
detail = body["detail"]
if isinstance(detail, str):
message = detail
elif isinstance(detail, dict):
message = detail.get("message") or detail.get("code") or str(detail)
elif isinstance(detail, list): # 422 validation errors
message = "; ".join(
f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in detail
)
raise MmuneApiError(resp.status_code, message, resp.headers.get("X-Request-ID"))TypeScript:
ts
export class MmuneApiError extends Error {
constructor(
public status: number,
message: string,
public requestId: string | null,
) {
super(`${status}: ${message} (request ${requestId})`);
}
}
export async function throwForMmune(res: Response): Promise<void> {
if (res.ok) return;
let message = res.statusText;
try {
const body = await res.json();
if (body.error && typeof body.error === "object") {
message = body.error.message ?? message; // error envelope
} else if (typeof body.detail === "string") {
message = body.detail;
} else if (Array.isArray(body.detail)) {
// 422 validation errors
message = body.detail
.map((e: { loc: (string | number)[]; msg: string }) => `${e.loc.join(".")}: ${e.msg}`)
.join("; ");
} else if (body.detail) {
message = body.detail.message ?? body.detail.code ?? JSON.stringify(body.detail);
}
} catch {
// body was not JSON; keep statusText
}
throw new MmuneApiError(res.status, message, res.headers.get("X-Request-ID"));
}Router map
Every group of routes the API serves, with its full path prefix. Reference pages: Integrations and discovery, Mapping, lineage and semantic, Health and monitoring, Security and platform, Agents and workflows.
| Prefix | Purpose | Reference page |
|---|---|---|
/api/v1/auth | Login, MFA, refresh, logout, profile, password change. | Security and platform (flow summarised above) |
/api/v1/audit | Admin-only read access to the compliance audit trail. | Security and platform |
/api/v1/projects | Project records: list, create, update, delete, start, stop, logs. | Agents and workflows |
/api/v1/discovery | Schema scans, scan status, discovered schemas, first findings, re-introspection. | Integrations and discovery |
/api/v1/discovery-agents | Register and manage deployed discovery agents; agent heartbeat, config and push endpoints. | Integrations and discovery |
/api/v1/health | Detailed component health, LLM health, SAP alert health. | Health and monitoring |
/api/v1/monitoring | Alert rules, metrics, AI usage and budgets, detection metrics, poll interval, real-time WebSocket. | Health and monitoring |
/api/v1/mapping | Generate and suggest mappings, saved mappings, mapping feedback. | Mapping, lineage and semantic |
/api/v1/orchestration | Generate and execute AI orchestration plans, job status, artifact download. | Agents and workflows |
/api/v1/analysis | Chat about the deployment, including the SSE stream. | Agents and workflows |
/api/v1/guardian | Security scans (admin) and scan status. | Security and platform |
/api/v1/compliance | Read-only compliance posture, history and findings. | Security and platform |
/api/v1/stats | Daily history counts for registered integrations and issues. | Health and monitoring |
/api/v1/probes | Probe node heartbeat and listing. | Health and monitoring |
/api/v1/lineage | Lineage graph, edge ingestion, impact analysis. | Mapping, lineage and semantic |
/api/v1/lineage/sync | Trigger lineage sync from discovered integrations (async or inline). | Mapping, lineage and semantic |
/api/v1/masking | Generate, list and apply data masking policies. | Security and platform |
/api/v1/reports | Passive Audit reports and impact-cost configuration. | Security and platform |
/api/v1/agents | Agent execution history and statistics. | Agents and workflows |
/api/v1/workflows | Register, start, inspect and cancel workflows. | Agents and workflows |
/api/v1/dashboard | Overview, agent health, errors, performance and learning figures. | Health and monitoring |
/api/v1/logs | Read and (admin) clear the global agent log. | Security and platform |
/api/v1/integrations | Supported providers, discovery, configure, test, register, introspect, list registered. | Integrations and discovery |
/api/v1/integrations/oauth | OAuth authorize and callback flows for integrations. | Integrations and discovery |
/api/v1/license | License status, apply a renewal (admin), attestation (admin). | Security and platform |
/api/v1/semantic | Business-intent extraction, semantic matching, intent registry. | Mapping, lineage and semantic |
/api/v1/drift | Schema drift detection, reports, value drift. | Health and monitoring |
/api/v1/reconciliation | Cross-system reconciliation pairings and findings. | Health and monitoring |
/api/v1/watchdog | Watchdog start and stop, sessions, status, flow and CDC views. | Health and monitoring |
/api/v1/healing | Healing mode config (admin to change), connection health, remapping suggestions. | Health and monitoring |
/api/v1/containment | Containment config (admin to change), quarantine marks, revalidation. | Health and monitoring |
/api/v1/recommendations | Estate advisor recommendations and scans. | Health and monitoring |
/api/v1/duplicates | Duplicate detection, checks, resolution. | Mapping, lineage and semantic |
/api/v1/rag | Document ingest, retrieval query and document management. | Agents and workflows |
/api/v1/ticketing | Ticketing provider config, test and ticket creation. | Health and monitoring |
/api/v1/incidents | Incidents, owners, SLA, root-cause analysis, runbooks. | Health and monitoring |
/api/v1/events | GET /cursor: change counters for UI refresh polling. | Health and monitoring |
/api/v1/notifications | Notification channel config, delivery attempts, test delivery. | Health and monitoring |
/api/v1/agent/state/... | Read, resume and delete agent execution checkpoints. | Agents and workflows, WebSockets and streaming |
/api/v1/agent/cancel/..., /api/v1/agent/cancel-status/... | Cancel an agent execution and read its cancel status. | Agents and workflows, WebSockets and streaming |
/api/v1/workflows/templates, /api/v1/workflows/from-template | List workflow templates and create a workflow from one. | Agents and workflows, WebSockets and streaming |
Routes outside these prefixes: GET /, GET /health, and GET /api/v1/monitoring/data (current real-time dashboard snapshot, answers 200 with an error key on failure).