Mutanex APIReport analytics · v1
OpenAPI

Report Analytics API

Send a clinical lab report as text or as a PDF. Receive structured, enriched data.

Intended use

Output is clinical decision support. It requires review by a qualified clinician and must not be used as the sole basis for a clinical decision. Every successful response repeats this in meta.intended_use.

Base URL - https://platform.mutanex.com

Everything on this page is live. Get a token under Authentication, then paste it into any endpoint's Try panel to call the API for real, against your own account. The page stores no credentials - every field, the token included, is entered by hand for each call.

Quick start

Three steps from credentials to a result. Your credentials arrive at onboarding - see Authentication.

# 0 - your credentials, from onboarding
export CLIENT_ID=cid_your_client_id
export CLIENT_SECRET=mnx_sk_your_client_secret

# 1 - exchange them for a token (once per token lifetime - one hour - not per call)
# -u sends the Authorization: Basic header - nothing secret goes in the body.
TOKEN=$(curl -s -X POST https://platform.mutanex.com/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  --data-urlencode 'scope=reports:text.analyze' | python3 -c 'import json,sys;print(json.load(sys.stdin)["access_token"])')

# 2 - analyse a report
curl -s -X POST https://platform.mutanex.com/v1/reports/text/analyze \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"report_text":"HAEMOGLOBIN 13.2 g/dL ...","report_type":"lab_panel"}'

# 3 - see what you have left
curl -s https://platform.mutanex.com/v1/credits -H "Authorization: Bearer $TOKEN"

If step 1 prints a KeyError, run its curl alone: the JSON it prints is an OAuth error explaining exactly what went wrong.

Authentication

OAuth 2.0 client credentials. Your client_id and client_secret are issued when your account is set up and delivered to your technical contact - the secret is shown once and never stored, so if it is lost we rotate it. Exchange them for an access token, then send that token on every request as Authorization: Bearer <token>.

Reuse a token until it expires - do not fetch one per call

Tokens last one hour. There is no refresh token - your client credentials already serve that purpose. Requesting a token per API call will trip the rate limit.

A long lifetime does not mean slow revocation. Account status, endpoint access and credential validity are re-checked on every single request. If a secret is rotated or an account suspended, existing tokens stop working on their very next call - not in one hour.

Try it

Space-delimited. Asking for a scope you do not have names the refused scopes back to you.

Nothing you type here is stored - anywhere

This page keeps no credential or token in any storage - no cookie, no localStorage entry, no variable that outlives a request.

  • Credentials are read from these fields at the moment you press Get token; the token is shown once below, and you paste it into each endpoint's access token field yourself.
  • Requests go from your browser straight to this API and nowhere else.

Even so: treat this like any other place you type a production secret. Avoid it on a shared screen - the token response is visible - and rotate the secret if you think it was exposed.

Get an access token

POST /oauth/token

OAuth 2.0 client credentials. Authenticate with HTTP Basic - client_id as username, client_secret as password, exactly as the cURL example shows. Credentials sent as body fields are refused with a 400 explaining the header to use.

The body carries grant_type and scope, both required, as application/x-www-form-urlencoded - not JSON. curl -d sends this by default; a JSON body is refused with invalid_request naming the encoding.

Asking for a scope you do not have returns invalid_scope and names the refused scopes.

Protocol errors (400, 401) use the RFC 6749 §5.2 shape; transport and limit errors (413, 429, 500) use the platform envelope.

Headers

HeaderValue
Authorization - requiredBasic base64(client_id:client_secret) - each component application/x-www-form-urlencoded-encoded per RFC 6749 §2.3.1 (plain values are fine for the issued formats; curl -u sends exactly this).
Content-Type - requiredapplication/x-www-form-urlencoded
X-Request-Id - optionalYour own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed.

Body - application/x-www-form-urlencoded

FieldTypeDescription
grant_type - required string Tokens are issued to an application's own credentials, never on behalf of an end user - there is no authorization-code or password flow.
must be client_credentials
scope - required string Required. Space-delimited. Available: reports:text.analyze, reports:file.analyze

Example request

grant_type=client_credentials&scope=reports%3Atext.analyze+reports%3Afile.analyze

Responses

StatusMeaning
200

A token valid for one hour.

400

Bad grant type, missing scope, malformed request, credentials sent as body fields (invalid_client - use the Authorization header), a refused scope, or an application/account no longer permitted to issue tokens (unauthorized_client).

401

Client authentication failed.

413

Request body over 4 KB.

429

Too many token requests (limit 0.5/second per source address). details.limit_type is source_address (per-address) or authentication_capacity (the process-wide verification backstop).

500

internal_error - an unexpected failure on our side.

Response fields (200)

FieldTypeDescription
access_token - required string Send as Authorization: Bearer <token> on every API call. Treat it as opaque - the internal format may change within v1.
token_type - required string Always Bearer - the token travels in the Authorization header, never in a query string.
must be Bearer
expires_in - required integer Seconds until expiry, counted from issue. Request a new token after it elapses - there are no refresh tokens.
scope - required string The scopes granted, space-delimited - identical to what you requested, because an unknown or ungranted scope is refused with invalid_scope rather than silently dropped.

Example response (200)

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "reports:text.analyze reports:file.analyze"
}

Call this endpoint from the Try it console in Getting a token - it takes form-encoded credentials, which the per-endpoint form builder does not drive.

Analyse a report as text

POST /v1/reports/text/analyze scope: reports:text.analyze

One endpoint, three names: scope reports:text.analyze (authorizes it) · endpoint_id reports.text.analyze (names it in usage rows and error details) · path /v1/reports/text/analyze (what you call).

Use this when you already hold the report as text - no OCR step, so it is the faster path, and nothing is written to disk.

Scope reports:text.analyze

Body limit 4 MiB of UTF-8 JSON (413 beyond it; a non-utf-* charset 415). Non-Latin scripts near the character ceiling can exceed it in UTF-8 - split them.

Billing 1,500 characters = 1 page, max(1, pages / 12) units. meta.pages is omitted - an estimate is not reported as a measurement.

Latency seconds to minutes - long reports are split and processed in parts; set your client timeout to at least 10 minutes.

Limits 10 requests/second, 5 concurrent per account. A success consumes one free analysis, whatever the size; failures consume nothing. Grants default to 100 - GET /v1/credits reports your live balance.

Do not retry a slow response - retrying doubles the work and counts twice.

Headers

HeaderValue
Authorization - requiredBearer <access_token> from POST /oauth/token.
Content-Type - requiredapplication/json
X-Request-Id - optionalYour own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed.

Body - application/json

FieldTypeDescription
report_text - required string The report as plain text, 1 to 500,000 characters. Faster than the file endpoint - no OCR step - and nothing is written to disk.
min 1 · max 500,000
report_type - required string A label of your choosing for what kind of report this is, e.g. "lab_panel". No allowlist - any label your workflow already uses is accepted. Echoed back as meta.report_type so a stored result carries your own label. It is a label for the KIND of report - never put patient identifiers in it. The engine does not read it, and it is not stored on your usage events.
min 1 · max 200
language - optional string BCP 47 language tag, e.g. "en-CA". Accepted and validated, but it currently has no effect: this engine release takes no language parameter, so the tag is not used, stored, or echoed back.
min 2 · max 35

Unknown fields are rejected with 422 validation_failed.

Example request

{
  "report_text": "HAEMOGLOBIN 13.2 g/dL ...",
  "report_type": "lab_panel"
}

Responses

StatusMeaning
200

Analysis complete.

400

invalid_request - The request could not be understood.

401

invalid_token - The access token is missing, malformed, expired, or no longer valid.

402

allowance_exhausted - The free allowance for this section is exhausted. Contact us to raise it.

403

account_blocked - This account is blocked.

endpoint_not_entitled - This account is not entitled to the requested endpoint.

insufficient_scope - The access token does not carry the scope required for this endpoint.

ip_not_allowed - Requests are not permitted from this source address.

413

payload_too_large - The request or file exceeds the permitted size.

415

unsupported_media_type - The file type is not supported.

422

validation_failed - The request failed validation.

429

rate_limited - Too many requests.

500

internal_error - An unexpected error occurred.

502

engine_error - The analysis engine failed to process this request.

504

engine_timeout - The analysis engine did not respond in time.

Response fields (200)

If meta.warnings is present, part of the analysis degraded

Treat that section of data as unavailable. A RISK_CLASSIFICATION_UNAVAILABLE warning with no riskClassifiedParameters must never be rendered as "nothing risky found". Branch on code; message wording may change.

FieldTypeDescription
request_id - required string Your request id - also the X-Request-Id response header.
data - required ReportData The structured report. Documented fields are stable within v1 and new fields may be added; ignore fields you do not recognise rather than failing on them.
meta - required AnalysisMeta Response metadata: the clinical-safety statement, the schema and engine versions, the report type, the billable units, and any degradation warnings.

ReportData fields

FieldTypeDescription
patient_information - required map<ValueWithConfidence> Patient and referral facts read from the report header: patient_name, age, gender, referred_by_doctor, client_name, collection_date, report_release_date. Values the report did not show are empty strings, never invented.
tests - required map<LabTest> Keyed by test name (e.g. "Complete Blood Count"). Only tests found in the report appear - an empty object means no recognised test.
report_metadata - required map<ValueWithConfidence> Laboratory bookkeeping read from the report - sample ids, timestamps, signatories - and repeated patient identifiers (patient_id, patient_name_repeat), so PHI handling must cover this object too. Same value/confidence shape as patient_information.
keyFindings - required string[] Clinician-facing highlights, concise, PHI-aware.
abnormalValues - required string[] Out-of-range parameters, named.
riskLevel - required string Overall report risk as assessed by the insights pass.
riskClassifiedParameters - optional RiskClassifiedParameter[] Absent - not empty - when risk classification degraded; meta.warnings says so. Absence must not be rendered as "nothing was risky".
riskGroupedJson - optional object Abnormal parameters grouped by severity tier. Absent alongside riskClassifiedParameters when classification degraded.

AnalysisMeta fields

FieldTypeDescription
intended_use - required string The clinical-safety statement, in-band on every success so the result testifies to what it is - decision support, not a diagnosis - wherever it is stored, logged, or forwarded beyond this documentation.
requires_clinician_review - required boolean Machine-actionable safety gate: key display or filing logic on this (e.g. show a review banner, block auto-filing) rather than parsing intended_use prose.
must be true
schema_version - required string The response-contract version this payload conforms to. A breaking change ships as a new version, announced - never in place.
must be v1
engine_version - required string | null Always present; null only if the engine version cannot be resolved.
report_type - required string The report_type you sent, echoed back unchanged. This echo is the whole of what the field does: it lets a stored or forwarded result carry your own label for what was analysed. The engine does not read it.
pages - optional integer File endpoint only. Pages counted in your PDF. Absent on the text endpoint, where no document exists to measure.
warnings - optional object[] Present only when the analysis degraded: data is usable but incomplete. For example, RISK_CLASSIFICATION_UNAVAILABLE means risk grouping failed - treat that section as unavailable, never as "nothing risky found". Branch on code; message wording may change.
units - required number Billable units for this call. See each endpoint for how it is derived.
processing_ms - required integer Engine time, excluding transport.

ValueWithConfidence fields

FieldTypeDescription
value - required string As read from the report; empty when not shown.
confidence - required string Extraction confidence, "0"-"1" as a string; empty when not applicable.

LabTest fields

FieldTypeDescription
test_name - required string The test's name as recognised from the report - the same string as its key in tests.
sample_type - optional string Specimen the test was run on, as stated by the report (e.g. "EDTA Whole Blood"). Empty when the report does not say.
specialization - optional string Laboratory discipline the test belongs to (e.g. "HEMATOLOGY"). Empty when the report does not say.
parameters - optional map<LabParameter> Keyed by parameter name (e.g. "Hemoglobin"), on the tests that are parameter-based. Only parameters with a result appear. Absent on culture, histopathology and similar tests, whose findings arrive under their own keys (organism_isolated, gross_examination, ...) rather than as a parameter map.

RiskClassifiedParameter fields

FieldTypeDescription
name - required string Parameter name, matching its key under the owning test's parameters map.
result - required string The measured value exactly as read from the report - never recomputed, converted or rounded.
unit - required string Unit as printed on the report.
reference_interval - required string The normal range printed on the report itself - the issuing lab's interval for its own method and population, not a universal range.
riskLevel - required string Severity of this parameter's deviation, classified by the insights pass. unknown means it could not be judged - surface it for clinician review, never render it as normal.
note - optional string Clinical note, present only for high/critical parameters.

warnings[] fields

FieldTypeDescription
code - required string Stable machine identifier for the degradation. Branch on this.
message - required string Human explanation, for logs and tickets. Wording may change - never branch on it.

LabParameter fields

FieldTypeDescription
result - optional string Present on parameters that report a value.
unit - optional string Present on measured parameters.
reference_interval - optional string Present on measured parameters.
confidence - optional string Present on measured parameters.

Example response (200)

{
  "request_id": "req_9x8y7z6w5v4u",
  "data": {
    "patient_information": {
      "patient_name": {
        "value": "A. Sample",
        "confidence": "0.98"
      },
      "age": {
        "value": "42",
        "confidence": "0.99"
      },
      "gender": {
        "value": "F",
        "confidence": "0.99"
      },
      "referred_by_doctor": {
        "value": "",
        "confidence": ""
      },
      "client_name": {
        "value": "",
        "confidence": ""
      },
      "collection_date": {
        "value": "2026-07-28",
        "confidence": "0.97"
      },
      "report_release_date": {
        "value": "2026-07-29",
        "confidence": "0.97"
      }
    },
    "tests": {
      "Complete Blood Count": {
        "test_name": "Complete Blood Count",
        "sample_type": "EDTA Whole Blood",
        "specialization": "Generic",
        "parameters": {
          "Hemoglobin": {
            "result": "13.2",
            "unit": "g/dL",
            "reference_interval": "12.0-16.0",
            "confidence": "0.95"
          }
        }
      }
    },
    "report_metadata": {
      "patient_id": {
        "value": "SMP001",
        "confidence": "0.96"
      }
    },
    "keyFindings": [
      "Haemoglobin within reference range."
    ],
    "abnormalValues": [],
    "riskLevel": "low",
    "riskClassifiedParameters": [],
    "riskGroupedJson": {
      "critical": [],
      "high": [],
      "low": []
    }
  },
  "meta": {
    "intended_use": "Clinical decision support. Output requires review by a qualified clinician.",
    "requires_clinician_review": true,
    "schema_version": "v1",
    "engine_version": "2.0.1",
    "report_type": "lab_panel",
    "units": 1,
    "processing_ms": 8400
  }
}

Analyse a report as a PDF

POST /v1/reports/file/analyze scope: reports:file.analyze

One endpoint, three names: scope reports:file.analyze (authorizes it) · endpoint_id reports.file.analyze (names it in usage rows and error details) · path /v1/reports/file/analyze (what you call).

Supply the PDF either as a file_url we fetch or as a multipart upload - exactly one; both or neither returns 422 validation_failed.

Scope reports:file.analyze

Type PDF only (application/pdf) - determined by file contents, never by name or declared Content-Type.

Size upload 20 MiB; file_url fetch 50 MiB - that path bypasses the JSON body ceiling entirely - completing within 20 seconds including up to 3 redirects.

Billing measured page count: max(1, pages / 12) units.

Latency seconds to minutes - long reports are split and processed in parts; set your client timeout to at least 10 minutes.

Limits 10 requests/second, 5 concurrent per account. A success consumes one free analysis, whatever the size; failures consume nothing. Grants default to 100 - GET /v1/credits reports your live balance.

Fetch failures file_unreachable is transient (slow, blocked, or 5xx host) - a retry may help; file_not_found (host 4xx, e.g. an expired signed URL) and a malformed file_url (422) - retries never fix.

Do not retry a slow response - retrying doubles the work and counts twice.

Headers

HeaderValue
Authorization - requiredBearer <access_token> from POST /oauth/token.
Content-Type - requiredOne of application/json or multipart/form-data - pick the matching body below.
X-Request-Id - optionalYour own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed.

Body - application/json

FieldTypeDescription
file_url - required string (uri) HTTPS URL we fetch the PDF from - required here, because a JSON body has no way to carry the file itself. Must be publicly resolvable on port 443 and require no credentials: use a signed URL.
max 2,048
report_type - required string A label of your choosing for what kind of report this is, e.g. "lab_panel". No allowlist - any label your workflow already uses is accepted. Echoed back as meta.report_type so a stored result carries your own label. It is a label for the KIND of report - never put patient identifiers in it. The engine does not read it, and it is not stored on your usage events.
min 1 · max 200
language - optional string BCP 47 language tag, e.g. "en-CA". Accepted and validated, but it currently has no effect: this engine release takes no language parameter, so the tag is not used, stored, or echoed back.
min 2 · max 35

Unknown fields are rejected with 422 validation_failed.

Example request

{
  "file_url": "https://example.com/report.pdf",
  "report_type": "lab_panel"
}

Body - multipart/form-data

FieldTypeDescription
file - exactly one file The PDF, maximum 20 MiB. Supply either this part or a file_url text field - exactly one. Type is determined by file contents, never by name or declared Content-Type.
file_url - exactly one string (uri) HTTPS URL we fetch the PDF from. Must be publicly resolvable on port 443 and require no credentials - use a signed URL. Mutually exclusive with a multipart file upload.
max 2,048
report_type - required string A label of your choosing for what kind of report this is, e.g. "lab_panel". No allowlist - any label your workflow already uses is accepted. Echoed back as meta.report_type so a stored result carries your own label. It is a label for the KIND of report - never put patient identifiers in it. The engine does not read it, and it is not stored on your usage events.
min 1 · max 200
language - optional string BCP 47 language tag, e.g. "en-CA". Accepted and validated, but it currently has no effect: this engine release takes no language parameter, so the tag is not used, stored, or echoed back.
min 2 · max 35

Unknown fields are rejected with 422 validation_failed.

Responses

StatusMeaning
200

Analysis complete.

400

invalid_request - The request could not be understood.

file_unreachable - The supplied file_url could not be retrieved.

file_not_found - The supplied file_url did not return a file.

401

invalid_token - The access token is missing, malformed, expired, or no longer valid.

402

allowance_exhausted - The free allowance for this section is exhausted. Contact us to raise it.

403

account_blocked - This account is blocked.

endpoint_not_entitled - This account is not entitled to the requested endpoint.

insufficient_scope - The access token does not carry the scope required for this endpoint.

ip_not_allowed - Requests are not permitted from this source address.

413

payload_too_large - The request or file exceeds the permitted size.

415

unsupported_media_type - The file type is not supported.

422

validation_failed - The request failed validation.

429

rate_limited - Too many requests.

500

internal_error - An unexpected error occurred.

502

engine_error - The analysis engine failed to process this request.

504

engine_timeout - The analysis engine did not respond in time.

Response fields (200)

If meta.warnings is present, part of the analysis degraded

Treat that section of data as unavailable. A RISK_CLASSIFICATION_UNAVAILABLE warning with no riskClassifiedParameters must never be rendered as "nothing risky found". Branch on code; message wording may change.

FieldTypeDescription
request_id - required string Your request id - also the X-Request-Id response header.
data - required ReportData The structured report. Documented fields are stable within v1 and new fields may be added; ignore fields you do not recognise rather than failing on them.
meta - required AnalysisMeta Response metadata: the clinical-safety statement, the schema and engine versions, the report type, the billable units, and any degradation warnings.

ReportData fields

FieldTypeDescription
patient_information - required map<ValueWithConfidence> Patient and referral facts read from the report header: patient_name, age, gender, referred_by_doctor, client_name, collection_date, report_release_date. Values the report did not show are empty strings, never invented.
tests - required map<LabTest> Keyed by test name (e.g. "Complete Blood Count"). Only tests found in the report appear - an empty object means no recognised test.
report_metadata - required map<ValueWithConfidence> Laboratory bookkeeping read from the report - sample ids, timestamps, signatories - and repeated patient identifiers (patient_id, patient_name_repeat), so PHI handling must cover this object too. Same value/confidence shape as patient_information.
keyFindings - required string[] Clinician-facing highlights, concise, PHI-aware.
abnormalValues - required string[] Out-of-range parameters, named.
riskLevel - required string Overall report risk as assessed by the insights pass.
riskClassifiedParameters - optional RiskClassifiedParameter[] Absent - not empty - when risk classification degraded; meta.warnings says so. Absence must not be rendered as "nothing was risky".
riskGroupedJson - optional object Abnormal parameters grouped by severity tier. Absent alongside riskClassifiedParameters when classification degraded.

AnalysisMeta fields

FieldTypeDescription
intended_use - required string The clinical-safety statement, in-band on every success so the result testifies to what it is - decision support, not a diagnosis - wherever it is stored, logged, or forwarded beyond this documentation.
requires_clinician_review - required boolean Machine-actionable safety gate: key display or filing logic on this (e.g. show a review banner, block auto-filing) rather than parsing intended_use prose.
must be true
schema_version - required string The response-contract version this payload conforms to. A breaking change ships as a new version, announced - never in place.
must be v1
engine_version - required string | null Always present; null only if the engine version cannot be resolved.
report_type - required string The report_type you sent, echoed back unchanged. This echo is the whole of what the field does: it lets a stored or forwarded result carry your own label for what was analysed. The engine does not read it.
pages - optional integer File endpoint only. Pages counted in your PDF. Absent on the text endpoint, where no document exists to measure.
warnings - optional object[] Present only when the analysis degraded: data is usable but incomplete. For example, RISK_CLASSIFICATION_UNAVAILABLE means risk grouping failed - treat that section as unavailable, never as "nothing risky found". Branch on code; message wording may change.
units - required number Billable units for this call. See each endpoint for how it is derived.
processing_ms - required integer Engine time, excluding transport.

ValueWithConfidence fields

FieldTypeDescription
value - required string As read from the report; empty when not shown.
confidence - required string Extraction confidence, "0"-"1" as a string; empty when not applicable.

LabTest fields

FieldTypeDescription
test_name - required string The test's name as recognised from the report - the same string as its key in tests.
sample_type - optional string Specimen the test was run on, as stated by the report (e.g. "EDTA Whole Blood"). Empty when the report does not say.
specialization - optional string Laboratory discipline the test belongs to (e.g. "HEMATOLOGY"). Empty when the report does not say.
parameters - optional map<LabParameter> Keyed by parameter name (e.g. "Hemoglobin"), on the tests that are parameter-based. Only parameters with a result appear. Absent on culture, histopathology and similar tests, whose findings arrive under their own keys (organism_isolated, gross_examination, ...) rather than as a parameter map.

RiskClassifiedParameter fields

FieldTypeDescription
name - required string Parameter name, matching its key under the owning test's parameters map.
result - required string The measured value exactly as read from the report - never recomputed, converted or rounded.
unit - required string Unit as printed on the report.
reference_interval - required string The normal range printed on the report itself - the issuing lab's interval for its own method and population, not a universal range.
riskLevel - required string Severity of this parameter's deviation, classified by the insights pass. unknown means it could not be judged - surface it for clinician review, never render it as normal.
note - optional string Clinical note, present only for high/critical parameters.

warnings[] fields

FieldTypeDescription
code - required string Stable machine identifier for the degradation. Branch on this.
message - required string Human explanation, for logs and tickets. Wording may change - never branch on it.

LabParameter fields

FieldTypeDescription
result - optional string Present on parameters that report a value.
unit - optional string Present on measured parameters.
reference_interval - optional string Present on measured parameters.
confidence - optional string Present on measured parameters.

Example response (200)

{
  "request_id": "req_9x8y7z6w5v4u",
  "data": {
    "patient_information": {
      "patient_name": {
        "value": "A. Sample",
        "confidence": "0.98"
      },
      "age": {
        "value": "42",
        "confidence": "0.99"
      },
      "gender": {
        "value": "F",
        "confidence": "0.99"
      },
      "referred_by_doctor": {
        "value": "",
        "confidence": ""
      },
      "client_name": {
        "value": "",
        "confidence": ""
      },
      "collection_date": {
        "value": "2026-07-28",
        "confidence": "0.97"
      },
      "report_release_date": {
        "value": "2026-07-29",
        "confidence": "0.97"
      }
    },
    "tests": {
      "Complete Blood Count": {
        "test_name": "Complete Blood Count",
        "sample_type": "EDTA Whole Blood",
        "specialization": "Generic",
        "parameters": {
          "Hemoglobin": {
            "result": "13.2",
            "unit": "g/dL",
            "reference_interval": "12.0-16.0",
            "confidence": "0.95"
          }
        }
      }
    },
    "report_metadata": {
      "patient_id": {
        "value": "SMP001",
        "confidence": "0.96"
      }
    },
    "keyFindings": [
      "Haemoglobin within reference range."
    ],
    "abnormalValues": [],
    "riskLevel": "low",
    "riskClassifiedParameters": [],
    "riskGroupedJson": {
      "critical": [],
      "high": [],
      "low": []
    }
  },
  "meta": {
    "intended_use": "Clinical decision support. Output requires review by a qualified clinician.",
    "requires_clinician_review": true,
    "schema_version": "v1",
    "engine_version": "2.0.1",
    "report_type": "lab_panel",
    "pages": 3,
    "units": 1,
    "processing_ms": 8400
  }
}

Remaining free allowance

GET /v1/credits

Your own account only, resolved from the token. There is no account identifier to pass and no way to read another account.

During the pilot this reports your free allowance. Purchased credits will appear in this same response when billing launches - the URL never changes.

Headers

HeaderValue
Authorization - requiredBearer <access_token> from POST /oauth/token.
X-Request-Id - optionalYour own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed.

Responses

StatusMeaning
200

Current allowance.

401

Missing or invalid token (invalid_token).

403

account_blocked - this account is blocked.

ip_not_allowed - requests are not permitted from this source address.

429

Too many account reads (limit 10/second per account, shared by /v1/credits and /v1/usage). details.limit_type is requests_per_second.

500

internal_error - an unexpected failure on our side.

Response fields (200)

FieldTypeDescription
request_id - required string Your request id - also the X-Request-Id response header.
account_id - required string Your account id (acc_...).
free_allowance - required object[] One entry per price-book section your account draws from.
as_of - required string (date-time) Server time the balance was read. A concurrent call can consume allowance the moment after, so treat it as a snapshot, not a reservation.

free_allowance[] fields

FieldTypeDescription
section - required string Price-book section number as a string - the stable key. Both /v1/reports/... endpoints draw from section "1".
label - required string Human name for the section, e.g. Report analysis. Display this; branch on section.
granted - required integer Free analyses granted in total.
remaining - required integer Free analyses left.

Example response (200)

{
  "request_id": "req_a1b2c3d4e5f6",
  "account_id": "acc_7k2m9p4q1w8e",
  "free_allowance": [
    {
      "section": "1",
      "label": "Report analysis",
      "granted": 100,
      "remaining": 87
    }
  ],
  "as_of": "2026-08-01T12:00:00Z"
}

Usage summary

GET /v1/usage

Per-endpoint calls, units and latency percentiles. Defaults to the last 30 days. Your own account only.

Aggregates are computed over at most 5,000 events, newest first - truncated tells you when that cap was hit.

Headers

HeaderValue
Authorization - requiredBearer <access_token> from POST /oauth/token.
X-Request-Id - optionalYour own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed.

Query parameters

NameDescription
from - optionalRFC 3339 date-time, e.g. 2026-08-01T00:00:00Z. Offsets like -04:00 are accepted and normalised to UTC. Defaults to 30 days before to. Inclusive; the window is half-open (from <= created_at < to), so reusing one call's to as the next call's from tiles windows with no gap and no double counting.
e.g. 2026-08-01T00:00:00Z
to - optionalSame format as from. Exclusive (see from). Defaults to now.
e.g. 2026-09-01T00:00:00Z
endpoint_id - optionalRestrict to one endpoint id: reports.text.analyze or reports.file.analyze. An unknown id returns 422 naming the valid values.
e.g. reports.text.analyze

Responses

StatusMeaning
200

Usage in the requested window.

401

Missing or invalid token (invalid_token).

403

account_blocked - this account is blocked.

ip_not_allowed - requests are not permitted from this source address.

422

from is not earlier than to, a timestamp is malformed, or endpoint_id is unknown.

429

Too many account reads (limit 10/second per account, shared by /v1/credits and /v1/usage). details.limit_type is requests_per_second.

500

internal_error - an unexpected failure on our side.

Response fields (200)

FieldTypeDescription
request_id - required string Your request id - also the X-Request-Id response header.
account_id - required string Your account id (acc_...).
from - required string (date-time) Start of the aggregation window, inclusive (UTC). Defaults to 30 days before to.
to - required string (date-time) End of the window, exclusive. Reuse this value as the next request's from to tile windows with no gap and no double count.
truncated - required boolean True when the 5,000-event cap was hit: the aggregates cover only the newest 5,000 calls in the window, and the echoed from is the requested bound, not where coverage starts. Narrow the range with from/to.
by_endpoint - required object[] One aggregate row per endpoint that recorded at least one call in the window - an endpoint with no traffic has no row.
as_of - required string (date-time) Server time the aggregates were computed.

by_endpoint[] fields

FieldTypeDescription
endpoint_id - required string The endpoint's stable id (e.g. reports.text.analyze) - the same id used in entitlements and error details.endpoint_id.
calls - required integer Recorded calls in the window: successful analyses plus accepted-then-failed ones.
successes - required integer Calls that returned 200 - each consumed one free analysis.
failures - required integer Analysis calls that were accepted and then failed - request validation, file fetch, or the engine. Refusals that fire before the endpoint runs (authentication, entitlement, oversized bodies) are not recorded. Failed calls do not consume your free allowance.
units - required number Billable units summed over successful calls (see "How usage is counted"). Failed calls contribute zero.
free_consumed - required integer Free analyses consumed in the window - one per success, so it tracks successes and never exceeds it.
latency_p50_ms - required integer Over successful calls in the window.
latency_p95_ms - required integer Over successful calls in the window.

Example response (200)

{
  "request_id": "req_f6e5d4c3b2a1",
  "account_id": "acc_7k2m9p4q1w8e",
  "from": "2026-07-01T00:00:00.000Z",
  "to": "2026-08-01T00:00:00.000Z",
  "truncated": false,
  "by_endpoint": [
    {
      "endpoint_id": "reports.text.analyze",
      "calls": 12,
      "successes": 11,
      "failures": 1,
      "units": 13.5,
      "free_consumed": 11,
      "latency_p50_ms": 900,
      "latency_p95_ms": 2100
    },
    {
      "endpoint_id": "reports.file.analyze",
      "calls": 7,
      "successes": 7,
      "failures": 0,
      "units": 9.0833,
      "free_consumed": 7,
      "latency_p50_ms": 3200,
      "latency_p95_ms": 5400
    }
  ],
  "as_of": "2026-08-01T12:00:00Z"
}

Errors

Every endpoint returns this shape - /oauth/token included for transport and limit errors (413, 429, 500). Only that endpoint's OAuth protocol errors (400, 401) use the RFC 6749 shape ({"error": "...", "error_description": "..."}) instead:

{
  "error": {
    "code": "endpoint_not_entitled",
    "message": "This account is not entitled to the requested endpoint.",
    "details": { "endpoint_id": "reports.file.analyze" }
  },
  "request_id": "req_a1b2c3…"
}

request_id is on every response, success or failure, and also travels as the X-Request-Id header - quote it in any support request.

Branch on error.code, never on message

Codes and their HTTP statuses are fixed for the life of v1. Messages may be reworded, and details may gain fields.

400 vs 422: a 400 invalid_request means the request could not be parsed at all (malformed JSON, broken encoding); a 422 validation_failed means it parsed but the content is invalid - its details.issues lists {"field", "code"} pairs naming exactly what failed. On /oauth/token, invalid_request carries the same meaning in RFC 6749's format.

StatusCodeRetry?Meaning
400 file_not_found no The supplied file_url did not return a file.
400 file_unreachable retry The supplied file_url could not be retrieved.
400 invalid_request no The request could not be understood.
401 invalid_token no The access token is missing, malformed, expired, or no longer valid.
402 allowance_exhausted no The free allowance for this section is exhausted. Contact us to raise it.
403 account_blocked no This account is blocked.
403 endpoint_not_entitled no This account is not entitled to the requested endpoint.
403 insufficient_scope no The access token does not carry the scope required for this endpoint.
403 ip_not_allowed no Requests are not permitted from this source address.
404 not_found no The requested resource does not exist.
413 payload_too_large no The request or file exceeds the permitted size.
415 unsupported_media_type no The file type is not supported.
422 validation_failed no The request failed validation.
429 rate_limited retry Too many requests.
500 internal_error retry An unexpected error occurred.
502 engine_error retry The analysis engine failed to process this request.
504 engine_timeout retry The analysis engine did not respond in time.

Retry guidance

Retry only the rows marked retry, with exponential backoff and jitter, three attempts at most. Retrying a permanent failure will never succeed and still counts against your rate limit. A failed call also consumes nothing - only a successful analysis draws on your free allowance.

Rate limits

The report-analysis endpoints and the account reads (/v1/credits, /v1/usage) are each limited per account, on separate budgets - a throttled analysis loop never locks you out of reading your own usage. The token endpoint has its own per-address limit.

LimitDefault
Analysis requests per second, per account10
Concurrent in-flight analyses, per account5
Account reads per second, per account10
Token requests per second, per source address0.5 (burst 10)

Exceeding any of these returns 429 rate_limited with a Retry-After header in seconds - honour it - and details.limit_type set to requests_per_second, concurrency, or source_address (token endpoint). Under load the enforced numbers can be approximate; treat 429 + Retry-After as the source of truth rather than counting to the limit yourself.

Concurrency matters more than it looks

Analysis takes seconds to minutes, so 5 simultaneous requests can sit in flight without ever approaching 10/second. Cap your own concurrency rather than discovering this limit.

These are per-account and adjustable - ask if you need more.

How usage is counted

Two separate numbers, and they are not the same thing.

Free allowance - counted in analyses, not pages

Your pilot allowance defaults to 100 analyses, shared across both endpoints - your account's actual grant can differ, and GET /v1/credits reports your live balance. A successful call consumes one, whatever the report's size - a 60-page PDF and a one-page PDF each cost one analysis.

Units - the measure of work

meta.units records how much analysis a call represented. It does not affect your free allowance during the pilot; it is what future billing is based on, so it is reported now for transparency.

units = max(1, pages ÷ 12)
ReportUnits
1 page1
12 pages1
13 pages1.0833
24 pages2
60 pages5

On the file endpoint, pages is counted from your PDF and reported in meta.

On the text endpoint there is no document to count, so pages are derived from length at 1,500 characters per page and meta.pages is omitted. The same report costs the same either way: 18,000 characters of text and a 12-page PDF are both 1 unit. Neither endpoint is the cheaper one.

Data handling

AspectWhat we do
ResidencyReports are processed in Canada - Google Cloud northamerica-northeast1 (Montréal). Model inference included: no part of a report leaves Canada at any point in a request.
RetentionReports are processed and discarded. Report text, uploaded files and fetched files are not retained after a request completes. De-identified clinical values are retained for service quality and analytics; no identifiers, text or files are kept.
TrainingYour data is never used to train models. This is a contractual commitment, not a setting.
LoggingNothing clinical is logged. We retain operational records - timestamp, endpoint, page and unit counts, latency, outcome - which contain no patient information and are what GET /v1/usage reports.
TransportHTTPS only.
In-payload noticeEvery response carries the clinician-review statement in meta.intended_use, so it reaches your interface whether or not anyone reads these docs.

Postman & OpenAPI

The full API definition is served at https://platform.mutanex.com/openapi.json - OpenAPI 3.1, generated from the running service.

Postman

Import → Link → paste https://platform.mutanex.com/openapi.json. You get a collection with every endpoint and field. Then:

  1. Call POST /oauth/token with your credentials and the scope you need (space-delimited, required).
  2. Copy access_token into a collection variable.
  3. Set collection Authorization to Bearer Token using that variable.

Every request in the collection then inherits it.

Code generation

The same document works with openapi-generator, Insomnia, Bruno, or anything else that reads OpenAPI 3.1.

Getting help

Send us the request_id. It is in the X-Request-Id header on every response, and in the request_id body field everywhere except /oauth/token's OAuth-shaped errors (400/401, which follow RFC 6749's two-field shape - read the header there; the endpoint's 413/429/500 carry it in the body like everything else). It is what lets us find your exact call in our logs.

Email support@mutanex.com.

You may also set your own X-Request-Id on a request - 8 to 64 characters of letters, digits, - or _ - and we will use it and echo it back, so your traces and ours share an identifier.

Please do not include patient data

Send the request_id, the timestamp, the endpoint and what you expected. Never paste report contents into a support ticket.