Skip to content

Security and platform API reference ​

What this page covers ​

This page documents every endpoint in seven areas of the mmune API: authentication (/api/v1/auth), audit log (/api/v1/audit), Guardian security scans (/api/v1/guardian), compliance posture (/api/v1/compliance), masking policies (/api/v1/masking), licensing (/api/v1/license) and Passive Audit reports (/api/v1/reports). For each one you get the method, full path, purpose, required permission, input fields, response fields, status codes and a curl example. Worked Python and TypeScript examples for the common tasks are at the end of the page.

Prerequisites: a running mmune installation reachable at a base URL (this page uses https://mmune.example.com), and an account that can log in. Read API overview first for the conventions that apply to every endpoint: Bearer authentication, the error envelope, request IDs, rate limiting and pagination. This page only repeats the parts that differ per endpoint.

Endpoint index ​

MethodPathPurpose
POST/api/v1/auth/loginExchange email and password for an access token, or an MFA challenge
POST/api/v1/auth/refreshExchange an unexpired token for a new access token
GET/api/v1/auth/meRead the caller's profile
POST/api/v1/auth/change-passwordChange the caller's password
POST/api/v1/auth/logoutRevoke the caller's current token
GET/api/v1/auth/validate-tokenCheck that a token is valid
POST/api/v1/auth/mfa/setupStart TOTP enrollment
POST/api/v1/auth/mfa/enableFinish TOTP enrollment and receive backup codes
POST/api/v1/auth/mfa/disableTurn MFA off (needs the password)
GET/api/v1/auth/mfa/statusReport whether MFA is enabled
POST/api/v1/auth/mfa/verify-loginSecond login step for MFA accounts
GET/api/v1/audit/eventsList audit log events with filters (admin)
GET/api/v1/audit/events/{event_id}Fetch one audit event (admin)
POST/api/v1/guardian/scanStart a sensitive-data scan job (admin)
GET/api/v1/guardian/status/{job_id}Poll a scan job and read its results
GET/api/v1/guardian/scan/statusSummary of the last completed scan
GET/api/v1/compliance/postureLatest score per compliance framework
GET/api/v1/compliance/historyRecent scan runs
GET/api/v1/compliance/findingsPer-finding detail for the latest scan
POST/api/v1/masking/generateBuild a masking policy from a scan
GET/api/v1/maskingList masking policies
POST/api/v1/masking/applyApply a policy to rows you supply
GET/api/v1/license/statusCurrent license state, usage and expiry
POST/api/v1/licenseApply a vendor-signed license token (admin)
GET/api/v1/license/attestationSigned usage report for renewal (admin)
POST/api/v1/reports/auditStart Passive Audit report generation
GET/api/v1/reports/status/{report_id}Poll report generation
GET/api/v1/reports/impact-cost-configRead impact cost assumptions
PUT/api/v1/reports/impact-cost-configSet or clear impact cost assumptions
GET/api/v1/reports/{report_id}Download the finished report as HTML

How access is decided on these routers ​

Access control happens in layers, and a request has to pass all of them.

  1. Every route requires a valid Bearer JWT with the read permission (or the admin role), with these exceptions: POST /api/v1/auth/login, POST /api/v1/auth/mfa/verify-login, POST /api/v1/auth/refresh and GET /health need no token, and the probe heartbeat (POST /api/v1/probes/heartbeat) and discovery-agent calls (/api/v1/discovery-agents/{agent_id}/...) authenticate with their own key. A missing or bad token gets 401 Not authenticated or Invalid token.
  2. The role admin is required for POST /api/v1/license, GET /api/v1/license/attestation and POST /api/v1/guardian/scan. A non-admin gets 403 with body {"detail": {"code": "insufficient_permission", "required": "admin", "message": "Your role cannot do this. Ask an administrator."}}.
  3. Any mutating method (POST, PUT, PATCH, DELETE) requires the write permission or the admin role, except for a short list a read-only user may call. On these routers that list is POST /auth/change-password, POST /auth/logout, POST /auth/mfa/setup, POST /auth/mfa/enable and POST /auth/mfa/disable. A failure returns 403 with "required": "write".
  4. Individual endpoints can add a further check, shown below as "Route check". It returns 403 with a plain string detail such as Permission 'write' required or Role 'admin' required.

Accounts are managed by your administrator. Service accounts and per-agent API keys are not offered yet, so integrations log in as a regular user account. Every Bearer token must be sent as Authorization: Bearer <TOKEN>.

Pagination, where an endpoint has it, uses page (1-based, default 1) and page_size (default 10, silently clamped to 1 through 100). Both are described in API overview.


Auth router: /api/v1/auth ​

Access tokens are JWTs. They are valid for 30 minutes by default, so expires_in is normally 1800.

POST /api/v1/auth/login ​

Exchanges an email and password for an access token. If the account has MFA enabled, it returns a short-lived MFA challenge instead and you finish with POST /api/v1/auth/mfa/verify-login.

Auth: none (public).

Request body (JSON):

FieldTypeRequiredDescription
emailstring (email format)yesAccount email.
passwordstringyesAccount password.

Response 200 without MFA:

FieldTypeDescription
access_tokenstringJWT for the Authorization header.
token_typestringAlways bearer.
expires_innumberLifetime in seconds.
userobjectid, email, name, role, permissions.

Response 200 with MFA enabled:

FieldTypeDescription
mfa_requiredbooleanAlways true.
mfa_tokenstringPurpose-restricted JWT that only /mfa/verify-login accepts.
expires_innumberLifetime in seconds, 300.

Status codes: 200 success; 401 Incorrect email or password (with WWW-Authenticate: Bearer); 422 malformed body; 429 Too many failed login attempts. Try again in a minute. with header Retry-After: 60, returned after 5 failed attempts within 60 seconds for the same client IP and email pair (failed MFA codes count toward the same limit); 503 Password store temporarily unavailable. Failed and successful logins are written to the audit log.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "<PASSWORD>"}'

POST /api/v1/auth/refresh ​

Exchanges a still-valid token for a new access token. There is no separate refresh token type: the login response contains only access_token, and the field named refresh_token takes that same unexpired access token. The token you send is revoked after a successful call, so each token can be exchanged once. An expired token cannot be refreshed; log in again.

Auth: none (public).

Request body (JSON):

FieldTypeRequiredDescription
refresh_tokenstringyesA currently valid access token.

Response 200: access_token (string), token_type (bearer), expires_in (number, seconds). The new token carries the same sub, email, name, role and permissions as the old one.

Status codes: 200 success; 401 Invalid refresh token (bad signature or expired), 401 Refresh token has already been used or revoked; 422 malformed body.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "<TOKEN>"}'

GET /api/v1/auth/me ​

Returns the caller's profile.

Auth: any valid token with read.

Response 200: id, email, name, role (strings), permissions (array of strings), is_active (boolean).

Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/auth/me -H "Authorization: Bearer <TOKEN>"

POST /api/v1/auth/change-password ​

Changes the caller's password. The new password is stored durably, so it survives restarts and applies on every replica.

Auth: any valid token with read (self-service).

Request body (JSON):

FieldTypeRequiredDescription
current_passwordstringyesThe password in force now.
new_passwordstringyesThe replacement password.

Response 200: {"message": "Password changed successfully"}.

Status codes: 200; 401 Current password is incorrect; 503 Password store temporarily unavailable.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/change-password \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"current_password": "<OLD_PASSWORD>", "new_password": "<NEW_PASSWORD>"}'

POST /api/v1/auth/logout ​

Revokes the Bearer token used for the call, so it cannot be used again even though it has not expired. The event is audit logged.

Auth: any valid token with read (self-service). No body.

Response 200: {"message": "Logged out successfully"}. Status codes: 200; 401.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/logout -H "Authorization: Bearer <TOKEN>"

GET /api/v1/auth/validate-token ​

Confirms the Bearer token is valid and returns who it belongs to.

Auth: any valid token with read.

Response 200: valid (always true), user_id, email, role. Status codes: 200; 401 (Token has been revoked, MFA verification required for an unfinished MFA token, Invalid token, which is also what an expired token returns).

bash
curl -s https://mmune.example.com/api/v1/auth/validate-token -H "Authorization: Bearer <TOKEN>"

POST /api/v1/auth/mfa/setup ​

Starts TOTP enrollment by generating and storing a pending secret. MFA is not enforced at login until /mfa/enable succeeds. Calling this again before enabling replaces the pending secret.

Auth: any valid token with read (self-service). No body.

Response 200: secret (string, the TOTP secret for manual entry) and qr_code_base64 (string, a base64-encoded PNG QR code for an authenticator app).

Status codes: 200; 401.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/mfa/setup -H "Authorization: Bearer <TOKEN>"

POST /api/v1/auth/mfa/enable ​

Finishes enrollment by proving the authenticator app works. Returns the only copy of the backup codes.

Auth: any valid token with read (self-service).

Request body (JSON):

FieldTypeRequiredDescription
tokenstringyesCurrent code from the authenticator app. One 30-second step either side is accepted.

Response 200: backup_codes (array of 10 strings, 8 uppercase hex characters each). They are shown once. Each works once in place of a TOTP code at /mfa/verify-login.

Status codes: 200; 400 No MFA enrollment in progress. Call /mfa/setup first., 400 Invalid verification code; 401.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/mfa/enable \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"token": "123456"}'

POST /api/v1/auth/mfa/disable ​

Turns MFA off. A valid session is not enough; the password is required again.

Auth: any valid token with read (self-service).

Request body (JSON):

FieldTypeRequiredDescription
passwordstringyesThe caller's current password.

Response 200: {"message": "MFA disabled"}. Status codes: 200; 401 Incorrect password.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/mfa/disable \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"password": "<PASSWORD>"}'

GET /api/v1/auth/mfa/status ​

Reports whether MFA is enabled for the caller.

Auth: any valid token with read. Response 200: {"enabled": true} or {"enabled": false}. Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/auth/mfa/status -H "Authorization: Bearer <TOKEN>"

POST /api/v1/auth/mfa/verify-login ​

Second step of login for an MFA account. Exchanges the mfa_token from /login plus a TOTP code or an unused backup code for a real access token. The mfa_token is single use and expires after 5 minutes.

Auth: none (public). The mfa_token in the body is the credential.

Request body (JSON):

FieldTypeRequiredDescription
mfa_tokenstringyesValue returned by /login.
tokenstringyesTOTP code, or a backup code.

Response 200: same shape as the non-MFA /login response (access_token, token_type, expires_in, user).

Status codes: 200; 400 MFA is not enabled for this account; 401 MFA session expired or invalid — please log in again, 401 Invalid MFA session, 401 Invalid MFA code, 401 Account not found.

bash
curl -s -X POST https://mmune.example.com/api/v1/auth/mfa/verify-login \
  -H "Content-Type: application/json" \
  -d '{"mfa_token": "<MFA_TOKEN>", "token": "123456"}'

Audit router: /api/v1/audit ​

A read-only view of the audit trail. Entries are created for authentication events and for changes made through the API.

GET /api/v1/audit/events ​

Lists audit events, newest first. Admin only.

Auth: Route check Role 'admin' required (non-admin gets 403 with that string).

Query parameters:

NameTypeRequiredDescription
pageintegerno1-based page number. Default 1.
page_sizeintegernoItems per page. Default 10, clamped to 1 through 100.
event_typestringnoOne audit event type: user_login, user_logout, user_login_failed, password_change, mfa_enabled, mfa_disabled, access_granted, access_denied, permission_changed, role_assigned, role_removed, data_read, data_created, data_updated, data_deleted, data_exported, data_imported, migration_started, migration_completed, migration_failed, migration_paused, migration_resumed, migration_cancelled, schema_discovered, schema_analyzed, schema_mapped, schema_validated, config_changed, system_setting_changed, integration_configured, security_alert, encryption_key_rotated, certificate_expired, suspicious_activity, compliance_check, audit_requested, data_retention_applied, gdpr_request, system_started, system_stopped, backup_created, backup_restored or error_occurred.
user_idstringnoMatch the event's user_id. Login events record the email in username and leave user_id empty, so filter those by scanning username in the results.
resource_typestringnoMatch resource_type, for example auth or api.
severitystringnoOne of info, low, medium, high, critical.
start_timedatetime (ISO 8601)noOnly events at or after this time.
end_timedatetime (ISO 8601)noOnly events at or before this time.

Response 200:

FieldTypeDescription
eventsarrayEvent objects, see below.
pageintegerPage returned.
page_sizeintegerEffective page size after clamping.

There is no total count. Fetch the next page until events is empty or shorter than page_size.

Event object fields: event_id, event_type, severity, timestamp (ISO 8601), user_id, username, ip_address, resource_type, resource_id, action, status, details (object), metadata (object).

Status codes: 200; 400 for an unknown event_type or severity value (error envelope with type: validation_error); 401; 403.

bash
curl -s "https://mmune.example.com/api/v1/audit/events?event_type=user_login_failed&severity=medium&page=1&page_size=50" \
  -H "Authorization: Bearer <TOKEN>"

GET /api/v1/audit/events/ ​

Fetches one audit event. Admin only.

Auth: Route check Role 'admin' required.

Path parameters: event_id (string, required), the event_id field of an event.

Response 200: one event object with the fields listed above. Status codes: 200; 401; 403; 404 Audit event not found: <id>.

bash
curl -s https://mmune.example.com/api/v1/audit/events/<EVENT_ID> -H "Authorization: Bearer <TOKEN>"

Guardian router: /api/v1/guardian ​

Guardian scans the schema mmune already holds for columns that look like personal or regulated data. Scans run as background jobs.

POST /api/v1/guardian/scan ​

Starts a scan job and returns immediately.

Auth: admin role. A non-admin gets 403 with required: admin and no job is created.

Request body (JSON), all fields optional:

FieldTypeDefaultDescription
targetstring"all"Stored in the job metadata.
metadataobject{}Schema to scan, shaped {"tables": [{"name": "t", "columns": [{"name": "c", "type": "TEXT"}]}]}. If tables is missing or empty, the scan uses the schema persisted during integration introspection.
samplesobject{}Optional sample values keyed "table.column", each a list. Only the first 5 values per key are used.

Response 200: {"job_id": "<uuid>", "status": "PENDING"}.

Status codes: 200; 401; 403; 422 malformed body. A scan that runs longer than 1800 seconds is marked FAILED.

bash
curl -s -X POST https://mmune.example.com/api/v1/guardian/scan \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"target": "all"}'

GET /api/v1/guardian/status/ ​

Returns the state, logs and results of a scan job. Poll until status is COMPLETED or FAILED.

Auth: any valid token with read.

Path parameter: job_id (string, required). Query parameter: cursor (integer, default 0, minimum 0), the next_cursor from the previous call, so you receive only new log lines.

Response 200:

FieldTypeDescription
job_idstringThe job ID.
statusstringPENDING, RUNNING, COMPLETED or FAILED.
logsarray of stringsLog lines from cursor onward.
next_cursorintegerPass as cursor on the next call.
resultsobject or nullScan report once complete: summary (total_fields, flagged_fields, risk_score), findings (array), scan_degraded (boolean, true when no LLM batch succeeded and only the pattern checks ran). See GET /api/v1/compliance/findings for the findings.
errorstring or nullFailure reason when status is FAILED.

Status codes: 200; 401; 404 Job not found. Job ids expire; use the compliance endpoints for stored results.

bash
curl -s "https://mmune.example.com/api/v1/guardian/status/<JOB_ID>?cursor=0" \
  -H "Authorization: Bearer <TOKEN>"

GET /api/v1/guardian/scan/status ​

Summary of the most recent completed scan.

Auth: any valid token with read.

Response 200: findings_count (integer, the scan's flagged field count, 0 if no scan exists), last_scan (ISO 8601 string or null).

Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/guardian/scan/status -H "Authorization: Bearer <TOKEN>"

Compliance router: /api/v1/compliance ​

Read-only. These endpoints report automated monitoring over controls mmune can observe. The response says so itself: the disclaimer field reads Automated posture monitoring over observable controls — not a certification audit. Do not present the scores as certification results.

All three endpoints need a valid token with read and have no further route check.

GET /api/v1/compliance/posture ​

Returns the latest assessment for each framework plus the auto-scan scheduler state. There is no single numeric score field. Compute a score as controls_passed / controls_total per framework.

Response 200:

FieldTypeDescription
frameworksobjectKeys among GDPR, HIPAA, SOC2, PCI-DSS. A framework with no stored assessment is absent.
frameworks.<name>.frameworkstringFramework name.
frameworks.<name>.statusstringno_gaps, warning, gaps_found or not_assessed.
frameworks.<name>.controls_totalintegerNumber of controls evaluated (5 per framework).
frameworks.<name>.controls_passedintegerControls with status pass.
frameworks.<name>.controlsarrayItems with id, title, status (pass, warning, fail, not_assessed) and evidence (string).
frameworks.<name>.scan_job_idstring or nullScan the assessment followed.
frameworks.<name>.assessed_atstring or nullISO 8601 time of assessment.
auto_scanobjectenabled, interval_hours, scan_running, next_scheduled_at, last_trigger_reason. If the scheduler cannot be read this is {"enabled": false}.
disclaimerstringFixed text described above.

Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/compliance/posture -H "Authorization: Bearer <TOKEN>"

GET /api/v1/compliance/history ​

Lists recent scan runs, newest first.

Query parameters: limit (integer, default 20, range 1 through 100).

Response 200: {"scans": [...]} where each item has job_id, created_at (ISO 8601 or null), total_fields, flagged_fields, risk_score (number), degraded (boolean).

Status codes: 200; 401; 422 if limit is out of range.

bash
curl -s "https://mmune.example.com/api/v1/compliance/history?limit=10" -H "Authorization: Bearer <TOKEN>"

GET /api/v1/compliance/findings ​

Per-finding detail for the newest scan.

Query parameters: limit (integer, default 200, range 1 through 1000), the maximum number of findings returned.

Response 200:

FieldTypeDescription
scanobject or nullLatest scan metadata (job_id, created_at, total_fields, flagged_fields, risk_score, degraded). null if no scan has run.
findingsarrayItems with id, table, column, pii_types (array of strings), sensitivity (HIGH, MEDIUM or LOW), suggested_action, reasoning.
summaryobjecthigh, medium, low, total, counted over all findings of the scan, not just the rows returned under limit.

Status codes: 200; 401; 422 if limit is out of range.

bash
curl -s "https://mmune.example.com/api/v1/compliance/findings?limit=500" -H "Authorization: Bearer <TOKEN>"

Masking router: /api/v1/masking ​

A masking policy is a list of per-column rules derived from the findings of one Guardian scan. Applying a policy transforms rows you send; mmune does not rewrite data in your databases.

POST /api/v1/masking/generate ​

Builds and stores a policy from a finished scan, one rule per finding.

Auth: Route check Permission 'write' required (admin passes), plus the general write check.

Request body (JSON):

FieldTypeRequiredDefaultDescription
scan_job_idstringyesnonejob_id of a completed Guardian scan.
project_idstringno"system"Project the policy is stored under.

Response 200: policy_id (string) and rules (array). Each rule has table, column, pii_type, sensitivity, action (the suggested action in upper case, REDACT if none) and method. method is hash when the suggested action is HASH, partial when it is MASK, and redact otherwise.

Status codes: 200; 400 with detail set to a list of error strings when scan_job_id is unknown or policy generation fails; 401; 403; 422 malformed body.

bash
curl -s -X POST https://mmune.example.com/api/v1/masking/generate \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"scan_job_id": "<JOB_ID>"}'

GET /api/v1/masking ​

Lists stored policies, newest first.

Auth: any valid token with read. Query parameters: page (default 1), page_size (default 10, clamped to 1 through 100).

Response 200: policies (array of policy_id, scan_job_id, project_id, rules), total (integer, all policies), page, page_size.

Status codes: 200; 401.

bash
curl -s "https://mmune.example.com/api/v1/masking?page=1&page_size=20" -H "Authorization: Bearer <TOKEN>"

POST /api/v1/masking/apply ​

Applies a stored policy to rows in the request and returns the masked copies. A rule only acts on a row if the row has a key equal to the rule's column. null values are left as they are.

Auth: Route check Permission 'write' required, plus the general write check.

Request body (JSON):

FieldTypeRequiredDescription
policy_idstringyesA policy_id from /generate or the list.
rowsarray of objectsyesRows to mask, each a JSON object keyed by column name.

Methods applied:

MethodResult
redactThe value becomes null.
hashA one-way hash of the value, shown as 16 hex characters.
partialValues of 5 or more characters keep the first 2 and last 2 characters with * between them. Shorter values become all *.

Response 200: {"rows": [...]} in the same order as the input.

Status codes: 200; 401; 403; 404 Policy not found; 422 malformed body.

bash
curl -s -X POST https://mmune.example.com/api/v1/masking/apply \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"policy_id": "<POLICY_ID>", "rows": [{"ssn": "123-45-6789", "email": "a@example.com"}]}'

License router: /api/v1/license ​

Enforcement is fully offline. A license is a vendor-signed Ed25519 JWT checked against public keys built into the image. Tokens are read, in order of precedence, from the database (applied through this API), the file at MMUNE_LICENSE_FILE (default /var/lib/mmune/license/mmune.license), then the MMUNE_LICENSE environment variable. The first one that verifies and satisfies its binding wins. The state is re-evaluated at startup and every hour.

License states ​

StateWhen it occursEffect
validA token verifies and the effective time is at or before exp.Everything runs. New integrations can be registered until systems_used reaches max_systems. At the cap, registration returns 403 license_cap_exceeded.
gracePast exp but within grace_days after it.Same as valid: registration (within the cap) and monitoring continue. grace_days_remaining in the status counts down. A warning event is emitted on entering grace.
expiredPast exp plus grace_days.New registrations are refused with 403 license_blocked. The hourly check stops the watchdog, AutoPilot and the reconciliation runner. The HTTP API itself, including login, reads and this license router, keeps answering.
missingNo token found in the database, file or environment.max_systems is 0. Registrations are refused with 403 license_blocked. Watchdog and AutoPilot do not start. Apply a token to recover.
invalidA token exists but none verify, or a bound license names a different install_id.max_systems is 0, reason explains why. Same effect as missing.

Notes that apply to all states. Integrations already in the registered state are exempt from the cap check, so a restart at or over the cap still boots, and over-cap integrations keep being monitored; the cap only blocks net-new registrations. The registration endpoint that enforces this is POST /api/v1/integrations/discovered/{discovered_id}/register. A renewal applied during expired or grace restarts monitoring in the same request, with no restart of the service.

GET /api/v1/license/status ​

Current license state, usage and expiry. The call also lines the running monitoring services up with the computed state if the hourly check has not noticed a natural expiry yet.

Auth: any valid token with read.

Response 200:

FieldTypeDescription
statestringOne of valid, grace, expired, missing, invalid.
license_idstring or nullLicense jti.
customerstring or nullCustomer slug.
customer_namestring or nullDisplay name.
max_systemsintegerLicensed system cap. 0 means none allowed.
systems_usedintegerRegistered systems counted against the cap.
expires_atstring or nullISO 8601 expiry.
grace_untilstring or nullISO 8601 end of grace.
grace_days_remainingnumber or nullDays until grace_until, only in valid and grace.
featuresarray or nullLicensed feature names, if the license lists any.
binding_modestring or nullfloating or bound.
sourcestring or nullWhere the token was read: db, file or env.
reasonstring or nullWhy the license is invalid or missing.
install_idstringThis installation's ID. A vendor needs it to issue a bound license.
allows_registrationbooleanTrue in valid and grace.
allows_monitoringbooleanTrue in valid and grace.

Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/license/status -H "Authorization: Bearer <TOKEN>"

POST /api/v1/license ​

Applies a new vendor-signed token for a renewal or mid-term upgrade. The signature, audience, claims schema, binding and grace window are all verified first, and a rejected token never deactivates the license in force. The applying user is recorded.

Auth: admin role.

Request body (JSON):

FieldTypeRequiredDescription
tokenstringyesThe license JWT. Minimum length 1. Surrounding whitespace is trimmed.

Response 200: {"message": "License applied", "status": {...}} where status has the same fields as GET /api/v1/license/status.

Status codes: 200; 400 with detail of {"code": "license_rejected", "reason": "<reason>"}; 401; 403; 422 empty or missing token. The reason strings begin with one of malformed_token, missing_kid, unknown_kid, signature_or_claims, claims_schema, unsupported_schema, binding_missing_install_id, binding_mismatch or already_expired.

bash
curl -s -X POST https://mmune.example.com/api/v1/license \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"token": "<LICENSE_JWT>"}'

GET /api/v1/license/attestation ​

A signed usage report; send it to mmune unchanged.

Auth: admin role.

Response 200: attestation (string, the signed report) and instructions (string, how to hand it to mmune). Treat the report as an opaque string.

Status codes: 200; 400 with detail of {"code": "no_active_license", "message": "An attestation requires an active license."} when no active license is in force; 401; 403.

bash
curl -s https://mmune.example.com/api/v1/license/attestation -H "Authorization: Bearer <TOKEN>"

Reports router: /api/v1/reports ​

Passive Audit reports are generated from data mmune has observed, as a background job. A finished report survives a restart.

POST /api/v1/reports/audit ​

Starts report generation.

Auth: Route check Permission 'write' required, plus the general write check.

Request body (JSON), all fields optional:

FieldTypeDefaultDescription
window_startdatetime (ISO 8601)window_end minus 7 daysStart of the observation window.
window_enddatetime (ISO 8601)now, UTCEnd of the observation window.
client_namestringnullName printed on the report.

Response 200: {"report_id": "<uuid>", "status": "PENDING"}.

Status codes: 200; 401; 403; 422 malformed body.

bash
curl -s -X POST https://mmune.example.com/api/v1/reports/audit \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"client_name": "Example Health", "window_start": "2026-09-01T00:00:00Z", "window_end": "2026-09-30T23:59:59Z"}'

GET /api/v1/reports/status/ ​

Reports generation status.

Auth: Route check Permission 'read' required.

Path parameter: report_id. Query parameter: cursor (integer, default 0, minimum 0) for incremental logs.

Response 200: report_id, status (PENDING, COMPLETED or FAILED; the stored status stays PENDING while the job runs), error (string or null), logs (array of strings), next_cursor (integer), created_at, updated_at.

Status codes: 200; 401; 403; 404 Report not found.

bash
curl -s "https://mmune.example.com/api/v1/reports/status/<REPORT_ID>" -H "Authorization: Bearer <TOKEN>"

GET /api/v1/reports/impact-cost-config ​

Reads the cost assumptions the report's impact estimates use, and where each value came from.

Auth: any valid token with read.

Response 200:

FieldTypeDescription
cost_per_recordnumber or nullCost assumed per affected record. null means no dollar figure.
cost_per_record_sourcestringui, env or default.
cost_per_hournumber or nullCost assumed per hour of impact.
cost_per_hour_sourcestringui, env or default.

Status codes: 200; 401.

bash
curl -s https://mmune.example.com/api/v1/reports/impact-cost-config -H "Authorization: Bearer <TOKEN>"

PUT /api/v1/reports/impact-cost-config ​

Sets or clears the cost assumptions. Both fields are always written together, so an omitted or null field is stored as cleared and does not fall back to an environment value. The change persists across restarts and takes effect immediately.

Auth: Route check Permission 'write' required, plus the general write check.

Request body (JSON):

FieldTypeRequiredDescription
cost_per_recordnumber or nullnoMinimum 0.
cost_per_hournumber or nullnoMinimum 0.

Response 200: same shape as the GET response, showing the values now in force.

Status codes: 200; 401; 403; 422 for a negative value.

bash
curl -s -X PUT https://mmune.example.com/api/v1/reports/impact-cost-config \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"cost_per_record": 150, "cost_per_hour": 5000}'

GET /api/v1/reports/ ​

Returns the finished report as an HTML page (content type text/html).

Auth: Route check Permission 'read' required.

Path parameter: report_id.

Status codes: 200 with the HTML body; 401; 403; 404 Report not found; 409 Report is not ready (status: <status>).

bash
curl -s https://mmune.example.com/api/v1/reports/<REPORT_ID> \
  -H "Authorization: Bearer <TOKEN>" -o audit-report.html

Worked examples ​

Each example assumes the base URL https://mmune.example.com and placeholder credentials. Replace them before running. The Python examples need requests; the TypeScript examples use the global fetch available in Node 18 or later and in browsers. Both stop with an error on any non-2xx response instead of continuing.

1. Log in, complete MFA if required, and refresh ​

Python:

python
import requests

BASE = "https://mmune.example.com"


def login(email: str, password: str, totp: str | None = None) -> dict:
    r = requests.post(f"{BASE}/api/v1/auth/login", json={"email": email, "password": password})
    r.raise_for_status()
    body = r.json()
    if body.get("mfa_required"):
        if totp is None:
            raise RuntimeError("Account has MFA enabled; pass a TOTP or backup code")
        r = requests.post(
            f"{BASE}/api/v1/auth/mfa/verify-login",
            json={"mfa_token": body["mfa_token"], "token": totp},
        )
        r.raise_for_status()
        body = r.json()
    return body  # access_token, token_type, expires_in, user


def refresh(current_token: str) -> dict:
    # The refresh endpoint takes a still-valid access token and revokes it.
    r = requests.post(f"{BASE}/api/v1/auth/refresh", json={"refresh_token": current_token})
    r.raise_for_status()
    return r.json()  # access_token, token_type, expires_in


session = login("you@example.com", "<PASSWORD>", totp=None)
token = session["access_token"]
token = refresh(token)["access_token"]  # do this before expires_in elapses
headers = {"Authorization": f"Bearer {token}"}

TypeScript:

typescript
const BASE = "https://mmune.example.com";

interface LoginResult {
  access_token: string;
  token_type: string;
  expires_in: number;
  user: { id: string; email: string; name: string; role: string; permissions: string[] };
}

async function postJson<T>(path: string, body: unknown, token?: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
  return (await res.json()) as T;
}

async function login(email: string, password: string, totp?: string): Promise<LoginResult> {
  const first = await postJson<LoginResult & { mfa_required?: boolean; mfa_token?: string }>(
    "/api/v1/auth/login",
    { email, password },
  );
  if (!first.mfa_required) return first;
  if (!totp) throw new Error("Account has MFA enabled; pass a TOTP or backup code");
  return postJson<LoginResult>("/api/v1/auth/mfa/verify-login", {
    mfa_token: first.mfa_token,
    token: totp,
  });
}

async function refresh(currentToken: string): Promise<string> {
  const r = await postJson<{ access_token: string }>("/api/v1/auth/refresh", {
    refresh_token: currentToken,
  });
  return r.access_token;
}

2. License status, renewal and attestation ​

The renewal and attestation calls need an admin token. In Python, reuse BASE and headers from example 1.

python
import requests

status = requests.get(f"{BASE}/api/v1/license/status", headers=headers)
status.raise_for_status()
s = status.json()
print(s["state"], f'{s["systems_used"]}/{s["max_systems"]}', s["expires_at"], s["install_id"])

# Apply a renewal token received from the vendor.
renewal = requests.post(
    f"{BASE}/api/v1/license",
    headers=headers,
    json={"token": "<LICENSE_JWT>"},
)
if renewal.status_code == 400:
    raise RuntimeError(renewal.json()["detail"]["reason"])  # license_rejected
renewal.raise_for_status()
print(renewal.json()["status"]["state"])

# Signed usage report to send back to the vendor.
att = requests.get(f"{BASE}/api/v1/license/attestation", headers=headers)
att.raise_for_status()
attestation = att.json()["attestation"]
typescript
async function getJson<T>(path: string, token: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
  return (await res.json()) as T;
}

interface LicenseStatus {
  state: "valid" | "grace" | "expired" | "missing" | "invalid";
  max_systems: number;
  systems_used: number;
  expires_at: string | null;
  install_id: string;
  allows_registration: boolean;
  allows_monitoring: boolean;
}

async function licenseFlow(adminToken: string, renewalJwt: string): Promise<string> {
  const status = await getJson<LicenseStatus>("/api/v1/license/status", adminToken);
  console.log(status.state, status.systems_used, status.max_systems);

  const applied = await postJson<{ message: string; status: LicenseStatus }>(
    "/api/v1/license",
    { token: renewalJwt },
    adminToken,
  );
  console.log(applied.status.state);

  const att = await getJson<{ attestation: string; instructions: string }>(
    "/api/v1/license/attestation",
    adminToken,
  );
  return att.attestation;
}

3. Generate the Passive Audit report and read compliance scores ​

Generation is asynchronous: start it, poll the status until it leaves PENDING, then download the HTML. The scores come from GET /api/v1/compliance/posture.

python
import time
import requests

start = requests.post(
    f"{BASE}/api/v1/reports/audit",
    headers=headers,
    json={"client_name": "Example Health"},  # window defaults to the last 7 days
)
start.raise_for_status()
report_id = start.json()["report_id"]

while True:
    st = requests.get(f"{BASE}/api/v1/reports/status/{report_id}", headers=headers)
    st.raise_for_status()
    info = st.json()
    if info["status"] == "COMPLETED":
        break
    if info["status"] == "FAILED":
        raise RuntimeError(info["error"])
    time.sleep(3)

html = requests.get(f"{BASE}/api/v1/reports/{report_id}", headers=headers)
html.raise_for_status()
open("audit-report.html", "w", encoding="utf-8").write(html.text)

posture = requests.get(f"{BASE}/api/v1/compliance/posture", headers=headers)
posture.raise_for_status()
for name, fw in posture.json()["frameworks"].items():
    print(name, fw["status"], f'{fw["controls_passed"]}/{fw["controls_total"]}')
typescript
async function auditReportAndScores(token: string): Promise<void> {
  const { report_id } = await postJson<{ report_id: string; status: string }>(
    "/api/v1/reports/audit",
    { client_name: "Example Health" },
    token,
  );

  for (;;) {
    const info = await getJson<{ status: string; error: string | null }>(
      `/api/v1/reports/status/${report_id}`,
      token,
    );
    if (info.status === "COMPLETED") break;
    if (info.status === "FAILED") throw new Error(info.error ?? "report failed");
    await new Promise((r) => setTimeout(r, 3000));
  }

  const htmlRes = await fetch(`${BASE}/api/v1/reports/${report_id}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!htmlRes.ok) throw new Error(`report download failed: ${htmlRes.status}`);
  const html = await htmlRes.text(); // save or serve as needed

  const posture = await getJson<{
    frameworks: Record<string, { status: string; controls_passed: number; controls_total: number }>;
  }>("/api/v1/compliance/posture", token);
  for (const [name, fw] of Object.entries(posture.frameworks)) {
    console.log(name, fw.status, `${fw.controls_passed}/${fw.controls_total}`);
  }
}

4. Query the audit log ​

Admin token required. This pulls failed logins since a given time, walking pages until one comes back short.

python
import requests

params = {
    "event_type": "user_login_failed",
    "start_time": "2026-10-01T00:00:00Z",
    "page_size": 100,
    "page": 1,
}
events = []
while True:
    r = requests.get(f"{BASE}/api/v1/audit/events", headers=headers, params=params)
    r.raise_for_status()
    batch = r.json()["events"]
    events.extend(batch)
    if len(batch) < params["page_size"]:
        break
    params["page"] += 1

for e in events:
    print(e["timestamp"], e["username"], e["ip_address"], e["status"])
typescript
interface AuditEvent {
  event_id: string;
  event_type: string;
  severity: string;
  timestamp: string;
  username: string | null;
  ip_address: string | null;
  status: string;
}

async function failedLogins(adminToken: string, since: string): Promise<AuditEvent[]> {
  const pageSize = 100;
  const all: AuditEvent[] = [];
  for (let page = 1; ; page++) {
    const qs = new URLSearchParams({
      event_type: "user_login_failed",
      start_time: since,
      page: String(page),
      page_size: String(pageSize),
    });
    const { events } = await getJson<{ events: AuditEvent[] }>(
      `/api/v1/audit/events?${qs}`,
      adminToken,
    );
    all.push(...events);
    if (events.length < pageSize) return all;
  }
}

5. Run a sensitive-data check and mask rows ​

Start a Guardian scan (admin token), wait for it, read the findings, build a masking policy from the scan, then mask rows.

python
import time
import requests

scan = requests.post(f"{BASE}/api/v1/guardian/scan", headers=headers, json={"target": "all"})
scan.raise_for_status()
job_id = scan.json()["job_id"]

cursor = 0
while True:
    r = requests.get(
        f"{BASE}/api/v1/guardian/status/{job_id}", headers=headers, params={"cursor": cursor}
    )
    r.raise_for_status()
    job = r.json()
    cursor = job["next_cursor"]
    if job["status"] == "COMPLETED":
        break
    if job["status"] == "FAILED":
        raise RuntimeError(job["error"])
    time.sleep(3)

print(job["results"]["summary"])  # total_fields, flagged_fields, risk_score

findings = requests.get(f"{BASE}/api/v1/compliance/findings", headers=headers)
findings.raise_for_status()
print(findings.json()["summary"])  # high, medium, low, total

policy = requests.post(
    f"{BASE}/api/v1/masking/generate", headers=headers, json={"scan_job_id": job_id}
)
policy.raise_for_status()
policy_id = policy.json()["policy_id"]

masked = requests.post(
    f"{BASE}/api/v1/masking/apply",
    headers=headers,
    json={"policy_id": policy_id, "rows": [{"ssn": "123-45-6789", "email": "a@example.com"}]},
)
masked.raise_for_status()
print(masked.json()["rows"])
typescript
async function scanAndMask(adminToken: string): Promise<Record<string, unknown>[]> {
  const { job_id } = await postJson<{ job_id: string; status: string }>(
    "/api/v1/guardian/scan",
    { target: "all" },
    adminToken,
  );

  let cursor = 0;
  for (;;) {
    const job = await getJson<{ status: string; next_cursor: number; error: string | null }>(
      `/api/v1/guardian/status/${job_id}?cursor=${cursor}`,
      adminToken,
    );
    cursor = job.next_cursor;
    if (job.status === "COMPLETED") break;
    if (job.status === "FAILED") throw new Error(job.error ?? "scan failed");
    await new Promise((r) => setTimeout(r, 3000));
  }

  const findings = await getJson<{ summary: { high: number; medium: number; low: number; total: number } }>(
    "/api/v1/compliance/findings",
    adminToken,
  );
  console.log(findings.summary);

  const { policy_id } = await postJson<{ policy_id: string }>(
    "/api/v1/masking/generate",
    { scan_job_id: job_id },
    adminToken,
  );
  const masked = await postJson<{ rows: Record<string, unknown>[] }>(
    "/api/v1/masking/apply",
    { policy_id, rows: [{ ssn: "123-45-6789", email: "a@example.com" }] },
    adminToken,
  );
  return masked.rows;
}