Report Analytics API
Send a clinical lab report as text or as a PDF. Receive structured, enriched data.
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>.
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
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
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
| Header | Value |
|---|---|
Authorization - required | Basic 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 - required | application/x-www-form-urlencoded |
X-Request-Id - optional | Your 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
| Field | Type | Description |
|---|---|---|
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
| Status | Meaning |
|---|---|
| 200 | A token valid for one hour. |
| 400 | Bad grant type, missing |
| 401 | Client authentication failed. |
| 413 | Request body over 4 KB. |
| 429 | Too many token requests (limit 0.5/second per source address). |
| 500 |
|
Response fields (200)
| Field | Type | Description |
|---|---|---|
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.
# HTTP Basic (RFC 6749 §2.3.1). -u sends the Authorization: Basic
# header - nothing secret goes in the body.
curl -X POST https://platform.mutanex.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials \
--data-urlencode 'scope=reports:text.analyze reports:file.analyze'
Analyse a report as text
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
| Header | Value |
|---|---|
Authorization - required | Bearer <access_token> from POST /oauth/token. |
Content-Type - required | application/json |
X-Request-Id - optional | Your 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
| Field | Type | Description |
|---|---|---|
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
| Status | Meaning |
|---|---|
| 200 | Analysis complete. |
| 400 |
|
| 401 |
|
| 402 |
|
| 403 |
|
| 413 |
|
| 415 |
|
| 422 |
|
| 429 |
|
| 500 |
|
| 502 |
|
| 504 |
|
Response fields (200)
meta.warnings is present, part of the analysis degradedTreat 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.
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
}
}
curl -X POST https://platform.mutanex.com/v1/reports/text/analyze \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"report_text":"<report_text>","report_type":"lab_panel"}'
Analyse a report as a PDF
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
| Header | Value |
|---|---|
Authorization - required | Bearer <access_token> from POST /oauth/token. |
Content-Type - required | One of application/json or multipart/form-data - pick the matching body below. |
X-Request-Id - optional | Your 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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Status | Meaning |
|---|---|
| 200 | Analysis complete. |
| 400 |
|
| 401 |
|
| 402 |
|
| 403 |
|
| 413 |
|
| 415 |
|
| 422 |
|
| 429 |
|
| 500 |
|
| 502 |
|
| 504 |
|
Response fields (200)
meta.warnings is present, part of the analysis degradedTreat 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.
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
}
}
curl -X POST https://platform.mutanex.com/v1/reports/file/analyze \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"file_url":"https://example.com/report.pdf","report_type":"lab_panel"}'
# or upload the bytes instead of a URL:
curl -X POST https://platform.mutanex.com/v1/reports/file/analyze \
-H "Authorization: Bearer $TOKEN" \
-F file=@report.pdf \
-F report_type=lab_panel
Remaining free allowance
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
| Header | Value |
|---|---|
Authorization - required | Bearer <access_token> from POST /oauth/token. |
X-Request-Id - optional | Your own request id (8-64 characters: letters, digits, -, _). Echoed back so your traces and ours share an identifier; generated when absent or malformed. |
Responses
| Status | Meaning |
|---|---|
| 200 | Current allowance. |
| 401 | Missing or invalid token ( |
| 403 |
|
| 429 | Too many account reads (limit 10/second per account, shared by |
| 500 |
|
Response fields (200)
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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"
}
curl -X GET https://platform.mutanex.com/v1/credits \
-H "Authorization: Bearer $TOKEN"
Usage summary
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
| Header | Value |
|---|---|
Authorization - required | Bearer <access_token> from POST /oauth/token. |
X-Request-Id - optional | Your 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
| Name | Description |
|---|---|
from - optional | RFC 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 - optional | Same format as from. Exclusive (see from). Defaults to now.e.g. 2026-09-01T00:00:00Z |
endpoint_id - optional | Restrict 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
| Status | Meaning |
|---|---|
| 200 | Usage in the requested window. |
| 401 | Missing or invalid token ( |
| 403 |
|
| 422 |
|
| 429 | Too many account reads (limit 10/second per account, shared by |
| 500 |
|
Response fields (200)
| Field | Type | Description |
|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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"
}
curl -X GET https://platform.mutanex.com/v1/usage \
-H "Authorization: Bearer $TOKEN"
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.
error.code, never on messageCodes 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.
| Status | Code | Retry? | 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.
| Limit | Default |
|---|---|
| Analysis requests per second, per account | 10 |
| Concurrent in-flight analyses, per account | 5 |
| Account reads per second, per account | 10 |
| Token requests per second, per source address | 0.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.
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)
| Report | Units |
|---|---|
| 1 page | 1 |
| 12 pages | 1 |
| 13 pages | 1.0833 |
| 24 pages | 2 |
| 60 pages | 5 |
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
| Aspect | What we do |
|---|---|
| Residency | Reports 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. |
| Retention | Reports 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. |
| Training | Your data is never used to train models. This is a contractual commitment, not a setting. |
| Logging | Nothing 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. |
| Transport | HTTPS only. |
| In-payload notice | Every 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:
- Call
POST /oauth/tokenwith your credentials and thescopeyou need (space-delimited, required). - Copy
access_tokeninto a collection variable. - 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.
Send the request_id, the timestamp, the endpoint and what you
expected. Never paste report contents into a support ticket.