Skip to content

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/:

PathMethodAuthNotes
/healthGETnoneLiveness probe. Returns {"status": "ok"} and nothing else.
/GETBearer JWTReturns {"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 ​

EndpointWhy
POST /api/v1/auth/loginThe caller has no token yet.
POST /api/v1/auth/mfa/verify-loginSecond step of login, consumes the MFA challenge token.
POST /api/v1/auth/refreshExchanges a still-valid token for a new one.
GET /healthLiveness check.
POST /api/v1/probes/heartbeatSelf-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-samplesSelf-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 ​

  1. POST /api/v1/auth/login with {"email": "...", "password": "..."}.
  2. 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"]
  }
}
  1. 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 }
  1. Finish with POST /api/v1/auth/mfa/verify-login and {"mfa_token": "<MFA_TOKEN>", "token": "<6-digit TOTP or backup code>"}. The response has the same shape as the step 2 token response. The mfa_token is single use and is retired on success.
  2. 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.

ClaimTypeDescription
substringUser id.
emailstringAccount email.
namestringDisplay name.
rolestringadmin or user.
permissionsarray of stringCopied from the account at login time.
expnumberExpiry, seconds since the Unix epoch.
jtistringUnique 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 ​

MethodPathBodyResult
POST/api/v1/auth/logoutnoneRevokes the presented token server side. Later use returns 401 Token has been revoked.
GET/api/v1/auth/validate-tokennone{"valid": true, "user_id": "...", "email": "...", "role": "..."}
GET/api/v1/auth/menoneProfile of the token's user.
POST/api/v1/auth/change-passwordcurrent_password, new_passwordChanges the caller's own password.
GET/api/v1/auth/mfa/statusnone{"enabled": false}
POST/api/v1/auth/mfa/setupnone{"secret": "...", "qr_code_base64": "..."} (PNG, base64). Not active until enabled.
POST/api/v1/auth/mfa/enabletokenActivates MFA and returns {"backup_codes": [...]}. Shown once.
POST/api/v1/auth/mfa/disablepasswordTurns 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:

  1. The token needs the read permission (or role admin). Otherwise 403 with required: "read".
  2. If the (method, path) is on the admin list below, the role must be admin. Otherwise 403 with required: "admin".
  3. If the method is not GET, HEAD or OPTIONS, the token needs write (or role admin), unless the (method, path) is on the read-sufficient list below. Otherwise 403 with required: "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 ​

MethodPath
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 ​

MethodPathReason
POST/api/v1/auth/change-passwordOwn password.
POST/api/v1/auth/logoutOwn session.
POST/api/v1/auth/mfa/setup, /enable, /disableOwn MFA enrolment.
POST/api/v1/analysis/chat and /api/v1/analysis/chat/streamQuestion answering. Action tools inside chat check the caller's write permission themselves.
POST/api/v1/rag/queryRetrieval only.
POST/api/v1/semantic/find-semantic-matchCompute only.
POST/api/v1/mapping/suggestSuggestions only.
POST/api/v1/orchestration/planPlans, 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:

HeaderMeaning
X-Request-IDEchoes your X-Request-ID request header, or a generated UUID.
X-Correlation-IDEchoes your X-Correlation-ID request header, or a generated UUID.
X-Response-TimeServer time for the request, for example 0.042s.
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-ResetSee Rate limits.
Cache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache, Expires: 0Responses 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-PolicyStandard 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:

ParameterTypeDefaultDescription
pageinteger11-based page number. Values below 1 become 1.
page_sizeinteger10Items 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.

ShapeWhenExample
{"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 ​

StatusMeaning in this API
200Success. Job start endpoints also return 200, not 202.
201Resource created, on routes that declare it (for example POST /api/v1/projects/, POST /api/v1/incidents/owners).
307Trailing-slash redirect.
400Bad input that passed schema validation but failed a business rule, a rejected license token (license_rejected), or a failed connection test or registration.
401Missing, malformed, expired, revoked or wrong credential. Includes WWW-Authenticate: Bearer.
403Authenticated but not allowed: missing permission or role, or a license block (license_cap_exceeded, license_blocked).
404Unknown resource id.
409Conflict 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.
422Request validation failure.
429Rate limited, or too many failed logins.
500Unexpected error.
501The feature is not available in this deployment.
503A 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 ​

Conditiondetail
No Authorization header, or it is not Bearer <token>Not authenticated
Bad signature, malformed token, or expired tokenInvalid token
Token was logged out or already refreshedToken has been revoked
Token is the MFA challenge, not a real access tokenMFA verification required
Token has no sub claimInvalid token payload
Wrong email or password at loginIncorrect email or password

Rate limits ​

Two sliding-window limits apply to every HTTP request except the exempt ones. Counters are shared across API replicas.

LimitDefaultEnvironment variables
Global, all callers combined6000 requests per 60 secondsMMUNE_RATE_LIMIT_GLOBAL_CALLS, MMUNE_RATE_LIMIT_GLOBAL_PERIOD
Per caller18000 requests per 3600 secondsMMUNE_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:

WhereLimit
Standard install, on /api/Responses may take up to 660 seconds; a connection must be established within 75 seconds.
Cloud LLM callsMMUNE_LLM_TIMEOUT_SECONDS, default 30 seconds per attempt.
Local LLM callsMMUNE_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 (or MMUNE_CORS_ORIGINS if that is unset). With neither set, the defaults are http://localhost:5173, http://localhost:3000, http://127.0.0.1:5173 and http://127.0.0.1:3000. The origin of MMUNE_API_URL is 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, including X-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_ONLY is explicitly false, 0 or no. When they are offered, a caller without write is refused per tool call.
  • Orchestration steps whose action name contains apply, execute, write, remediate, heal, delete, migrate or restore are 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 with 403 when 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, missing or invalid, 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; check allows_monitoring in the status response.
  • GET /api/v1/license/status (any authenticated user) returns the state, max_systems, systems_used, expires_at, grace_until, allows_registration and allows_monitoring.
  • POST /api/v1/license (admin) takes {"token": "<LICENSE_JWT>"}, applies it without a restart, and returns {"message": "License applied", "status": {...}}. A rejected token returns 400 with {"detail": {"code": "license_rejected", "reason": "..."}}.
  • GET /api/v1/license/attestation (admin) returns a signed usage report, or 400 with code no_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 ​

StartId returned asPoll
POST /api/v1/discovery/scanscan_idGET /api/v1/discovery/status/{scan_id}
POST /api/v1/integrations/discoverscan_idGET /api/v1/discovery/status/{scan_id}
POST /api/v1/integrations/discovered/{discovered_id}/introspect and POST /api/v1/integrations/introspect-allscan_idGET /api/v1/discovery/status/{scan_id}
POST /api/v1/guardian/scan (admin)job_idGET /api/v1/guardian/status/{job_id}
POST /api/v1/orchestration/executeexecution_idGET /api/v1/orchestration/status/{execution_id}
POST /api/v1/reports/auditreport_idGET /api/v1/reports/status/{report_id}
POST /api/v1/lineage/syncjob_idGET /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:

FieldTypeDescription
statusstringPENDING, RUNNING, COMPLETED or FAILED.
logsarray of stringLog lines from cursor onward.
next_cursorintegerPass this as cursor on the next poll to receive only new lines.
resultsobject or nullSet when the job completes.
errorstring or nullSet when the job fails.
created_at, updated_atstringISO 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-12

If 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" | jq

Python:

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
done

Python:

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.

PrefixPurposeReference page
/api/v1/authLogin, MFA, refresh, logout, profile, password change.Security and platform (flow summarised above)
/api/v1/auditAdmin-only read access to the compliance audit trail.Security and platform
/api/v1/projectsProject records: list, create, update, delete, start, stop, logs.Agents and workflows
/api/v1/discoverySchema scans, scan status, discovered schemas, first findings, re-introspection.Integrations and discovery
/api/v1/discovery-agentsRegister and manage deployed discovery agents; agent heartbeat, config and push endpoints.Integrations and discovery
/api/v1/healthDetailed component health, LLM health, SAP alert health.Health and monitoring
/api/v1/monitoringAlert rules, metrics, AI usage and budgets, detection metrics, poll interval, real-time WebSocket.Health and monitoring
/api/v1/mappingGenerate and suggest mappings, saved mappings, mapping feedback.Mapping, lineage and semantic
/api/v1/orchestrationGenerate and execute AI orchestration plans, job status, artifact download.Agents and workflows
/api/v1/analysisChat about the deployment, including the SSE stream.Agents and workflows
/api/v1/guardianSecurity scans (admin) and scan status.Security and platform
/api/v1/complianceRead-only compliance posture, history and findings.Security and platform
/api/v1/statsDaily history counts for registered integrations and issues.Health and monitoring
/api/v1/probesProbe node heartbeat and listing.Health and monitoring
/api/v1/lineageLineage graph, edge ingestion, impact analysis.Mapping, lineage and semantic
/api/v1/lineage/syncTrigger lineage sync from discovered integrations (async or inline).Mapping, lineage and semantic
/api/v1/maskingGenerate, list and apply data masking policies.Security and platform
/api/v1/reportsPassive Audit reports and impact-cost configuration.Security and platform
/api/v1/agentsAgent execution history and statistics.Agents and workflows
/api/v1/workflowsRegister, start, inspect and cancel workflows.Agents and workflows
/api/v1/dashboardOverview, agent health, errors, performance and learning figures.Health and monitoring
/api/v1/logsRead and (admin) clear the global agent log.Security and platform
/api/v1/integrationsSupported providers, discovery, configure, test, register, introspect, list registered.Integrations and discovery
/api/v1/integrations/oauthOAuth authorize and callback flows for integrations.Integrations and discovery
/api/v1/licenseLicense status, apply a renewal (admin), attestation (admin).Security and platform
/api/v1/semanticBusiness-intent extraction, semantic matching, intent registry.Mapping, lineage and semantic
/api/v1/driftSchema drift detection, reports, value drift.Health and monitoring
/api/v1/reconciliationCross-system reconciliation pairings and findings.Health and monitoring
/api/v1/watchdogWatchdog start and stop, sessions, status, flow and CDC views.Health and monitoring
/api/v1/healingHealing mode config (admin to change), connection health, remapping suggestions.Health and monitoring
/api/v1/containmentContainment config (admin to change), quarantine marks, revalidation.Health and monitoring
/api/v1/recommendationsEstate advisor recommendations and scans.Health and monitoring
/api/v1/duplicatesDuplicate detection, checks, resolution.Mapping, lineage and semantic
/api/v1/ragDocument ingest, retrieval query and document management.Agents and workflows
/api/v1/ticketingTicketing provider config, test and ticket creation.Health and monitoring
/api/v1/incidentsIncidents, owners, SLA, root-cause analysis, runbooks.Health and monitoring
/api/v1/eventsGET /cursor: change counters for UI refresh polling.Health and monitoring
/api/v1/notificationsNotification 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-templateList 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).