StructaMed Partner API v1
Send structured laboratory results, receive a rule-based interpretation. The analysis is deterministic and rule-based: no AI model is called, and nothing you send is used for any purpose other than producing the response.
This is an interpretation aid for clinicians and laboratory staff. It does not replace clinical judgement, and it makes no diagnosis.
Machine-readable spec (OpenAPI 3.1): https://<host>/partner/openapi.yaml. A Postman collection is at https://<host>/partner/structamed-partner.postman_collection.json (set the variables baseUrl and apiKey). This reference is also published at https://<host>/labs/api. A step-by-step setup guide for your IT team is available on request.
Endpoint
POST https://<host>/api/v1/analyze Content-Type: application/json
Authentication
Send your key in either header:
Authorization: Bearer sm_live_<32 characters> x-api-key: sm_live_<32 characters>
Keys are issued to you once. Only a hash is stored on our side, so a lost key cannot be recovered, only replaced. A missing, unknown or deactivated key returns 401.
Request
{
"patient": { "sex": "male", "age": 45, "birth_date": "1980-01-31" },
"collected_at": "2026-09-30",
"mode": "clinical",
"lang": "en",
"results": [
{ "name": "ApoB", "code": "optional", "value": 1.32, "unit": "g/L",
"ref_low": 0.6, "ref_high": 1.0, "ref_text": "optional printed range", "flag": "H" }
],
"previous": [
{ "collected_at": "2025-09-30", "results": [ { "name": "ApoB", "value": 1.1, "unit": "g/L" } ] }
]
}| Field | Required | Notes |
|---|---|---|
patient.sex | yes | "male" or "female". Reference ranges and several rules depend on it. |
patient.age | one of age / birth_date | Years, 0-120. |
patient.birth_date | one of age / birth_date | YYYY-MM-DD. Age is computed at collected_at (today if absent). If both are sent, age is used. |
patient.context | no | Clinical context the engine can use: athlete, menopausal status, pregnancy, medications, conditions. See Patient context. |
collected_at | no | YYYY-MM-DD, or an ISO date-time (2026-09-17T08:30, ...T08:30:00Z, ...T08:30:00+02:00). The date is used for age and for dating the trend; the time of day, when both this and a previous panel carry one, gives the interval for serial troponin (see Serial troponin). |
mode | no | "clinical" (default) or "wellness". Wellness uses optimal-range framing and wellness next steps. |
lang | no | en (default), de, uk, fr, pl, es, pt. Texts fall back to English where a translation is missing. Marker names are always English. |
results | yes | 1 to 300 entries. |
results[].name | yes | The marker name as you label it ("ApoB", "Hemoglobin", "hs-CRP"). Matched against our marker list by name/synonym. |
results[].value | yes | A number, or a string: "5.2", "6,1", "5.2 H", "<0.5", "Negative", "1+". null or "" marks a row without a result (see Values). |
results[].unit | no, but send it | Units are checked and converted to the engine's own unit where the conversion is known. A unit that is invalid for the marker causes the row to be reported in unrecognized. See Values. |
results[].ref_low, ref_high | no | Your laboratory's own reference limits. When present they are used instead of our defaults (ref_source: "partner"). One side only is allowed. |
results[].ref_text | no | A printed range as text, used only if ref_low/ref_high are absent. |
results[].flag | no | H, HH, L, LL, N (or high/low/normal). Treated as a hint; the value is graded against the range. |
results[].code | no | A LOINC code (for example 1884-6). A supported code decides the marker; see LOINC codes. |
previous | no | Up to 12 earlier panels, each with collected_at (date or ISO date-time) and results in the same shape. Used for trends and for serial troponin. |
Maximum body size: 1 MB.
Response (200)
{
"request_id": "req_...",
"engine_version": "structamed-engine/2026-10",
"lang": "en",
"summary": "Some findings are outside the optimal range and worth reviewing.",
"summary_code": "summary.wellness_findings_present",
"risk": { "score": 1.4, "level": "low" },
"counts": { "received": 19, "graded": 19, "abnormal": 4 },
"findings": [ ... ],
"patterns": [ { "type": "anemia", "confidence": "moderate", "evidence": ["..."] } ],
"clusters": ["metabolic"],
"notes": [ { "key": "notes.lipid_abnormal", "text": "..." } ],
"next_steps": { "urgency": "routine", "steps": [ { "category": "monitor", "key": "...", "text": "...", "confidence": "moderate" } ] },
"unrecognized": [ { "name": "Xyz", "value": 3, "unit": "u", "reason": "marker name is not recognized" } ],
"trends": [ ... ],
"trends_skipped": [ { "key": "ferritin", "reason": "unit differs between panels; not compared" } ]
}trends and trends_skipped appear only when previous was sent.
findings[]
One entry per graded marker, normal ones included.
| Field | Meaning |
|---|---|
key, name | Engine marker key and its English name. input_name is your own label. matched_by is "loinc" or "name": how your row was matched to the marker. |
derived | true for a value the engine calculated (for example HOMA-IR from glucose and insulin), not one you sent. |
value, unit | The value in the engine's unit. If a unit conversion happened, submitted holds your original value, unit and range. |
value_label, operator | For word results ("Negative") and limit values ("<0.5"). |
ref_low, ref_high | The range actually used for grading; null where a side does not exist. |
ref_source | "partner" if the range came from your request, "engine_default" if it is ours. |
status | low, normal or high against that range. |
severity | normal, info, warning or critical. Severity is not the same as status: it also reflects how far outside the range the value is and the clinical context. |
rationale[] | Steps explaining the finding (code, rendered text, values). |
trends[]
Per marker present in both the current panel and at least one earlier panel with the same unit: points (oldest first, the current panel last), direction (up/down/flat, flat being under 5% movement), quality (improving, worsening, mixed, stable, judged by distance to the range, not by the sign of the change), first, last, change_percent, notable_change (true when the value moved by 25% or more between the first and last point, whatever quality says: a marker can be stable because it stays inside its range and still have fallen by 40%; the flag never changes quality, direction or out_of_range). Trend values are the numbers as you sent them, in your unit. A marker whose unit differs between panels is listed in trends_skipped, never converted silently.
warnings[]
Top-level list of plain-text notes, present only when there is something to say. Today: a row whose LOINC code and name disagree (for example the code is a urine test and the name says serum). The code wins; the warning tells you so you can fix the source.
unrecognized[]
Nothing is dropped silently. A row appears here if its name is not a known marker, if it duplicates another row for the same marker (the first one is analyzed), if the engine rejected its unit or value as impossible for that marker, or if it was recognized but could not be graded. A finding is only as good as the name matching: please review this list.
recognition
An answer carries a recognition block when it has something to report (a row nothing recognised, or a row your dictionary resolved); POST /api/v1/recognize always returns it. The block holds total_rows, by_source (partner_dictionary, code, global, none), no_value (rows without a result value) and unresolved[] with the reason and up to three closest catalog entries per row (suggestions, hints only, never applied). POST /api/v1/recognize takes the same key and body as /api/v1/analyze and returns only this block (plus unit warnings and omics notes); it runs no analysis, makes no AI call, renders no PDF and is not counted as a report. Rows your account's dictionary maps carry matched_by: "partner_dictionary" in findings[].
collection
results[] and omics.functional[] rows accept an optional collection object: specimen, time_point, fasting (boolean), provoked (boolean), provoking_agent and collected_at (date or ISO date-time). Explicit fields win over what is inferred from the printed name; provoked: false overrides any provocation wording. Unknown fields or wrong types are refused with 400 and a message naming the field.
LOINC codes
Send the LOINC code in results[].code when your system has it. Precedence:
- A supported code decides the marker (
matched_by: "loinc"). Yournameis not used to route; it is still returned asinput_name. - An unknown or absent code falls back to the name (
matched_by: "name"). Nothing is rejected for an unknown code. - If a supported code and the name point at different markers, or at different specimens (urine against serum), the code wins and an entry is added to
warnings[].
Specimens are separate markers: urine creatinine and serum creatinine, urine albumin and serum albumin never map to each other. Mass and molar codes of one analyte (for example LDL 13457-7 in mg/dL and 22748-8 in mmol/L) map to the same marker; send the unit that goes with your value and it is converted.
The supported list (code, marker key, specimen, usual unit, LOINC name, verification date) is served as JSON by GET /api/v1/loinc: no key needed, 30 requests per minute per client address.
Values, units and ranges
What the API accepts, as tested against a corpus of South-East Asian and Australian LIS-style rows.
Marker names. Case, British spellings, a glued flag or star (ALT (SGPT) *, Glucose (H)), and specimen words (Serum Urea, Ferritin, Serum, PSA, Plasma) are handled; so are common LIS labels such as FPG, Alk Phos, Gamma GT, Total White Cell Count, Mean Cell Volume, Total CO2, Lipoprotein (a). Two labels are refused on purpose, with a reason in unrecognized: post-prandial glucose (it has no fasting range) and bioavailable testosterone (it is not total testosterone). A LOINC code in code overrides the name.
Values.
| You send | Result |
|---|---|
5.2, "5.2", " 4.0 ", "6,1" (decimal comma) | the number |
"5.2 H", "H 5.2", "5.2 (H)", "5.2*" | the number; the letter is used as flag when you sent none |
"<0.5", "< 0.5", "<=0.5", ">=5" | a limit value: operator is < or >, graded against the range without treating the limit as a measurement |
"Negative", "Non-reactive", "Not detected", "Nil", "1+", "Trace", "Positive" | a word result, graded by the marker's qualitative rules, for markers that have a qualitative form (serology, urinalysis dipstick, stool tests, titres). On a quantitative marker (hs-CRP, glucose, ferritin ...) it is never turned into 0: the row is listed in unrecognized with the reason qualitative result on a quantitative marker |
null, "", "N/A", "Pending", "See comment", "Not done", "TNP", "Haemolysed", "--" | not a result: listed in unrecognized with a reason, never graded; the other rows of the panel are still analyzed |
| a value outside the physiologically possible range for the marker | unrecognized ("outside the physiologically plausible range") |
A thousands separator is not guessed: "1,234" is read as 1234 (a decimal comma is only assumed when there is no group of three digits after it). Send plain numbers if your system prints comma decimals with three decimals.
Units. Case and the micro sign are levelled (umol/L, µmol/L, μmol/L; MMOL/L). Counts may be x10^9/L, 10^9/L, x10E9/L, K/uL, x10^3/uL, 10*3/uL (and /uL for WBC and platelets, which is divided by 1000). A method note in the unit is dropped (mmol/mol (IFCC), % (NGSP)). Values in conventional and SI units are converted to the engine's unit and the original is returned in submitted. Lipoprotein(a): mg/dL is native, g/L and mg/L are converted; nmol/L is never converted (the ratio to mg/dL depends on the assay): the value is kept in nmol/L and, if you sent no reference range, it is graded against a molar default of ours, < 75 nmol/L (EAS 2022 consensus: < 75 nmol/L (30 mg/dL) low risk, 75-125 grey zone, > 125 nmol/L (50 mg/dL) high risk), with ref_source: "engine_default" and a warning that says so. Your own ref_low/ref_high win.
Reference ranges. ref_low/ref_high or ref_text (3.5 - 5.1, 3.5-5.1, 3.5 to 5.1, 0.6–1.0, <5.2, <= 5.2, > 1.0, ≥1.0, Desirable: <5.2, several risk-tier lines, Male: 59-104 Female: 45-84, Age 18-60: 59-104 / Age >60: 50-110) are read for the patient's sex and age; for tiers the "desirable" tier is the range. A range with ref_low greater than ref_high is rejected with 400. A range in a unit other than the value's: if the text names the unit (53-106 umol/L) it is converted; if it does not and the numbers do not fit the value's unit but fit another known unit of the marker (creatinine 0.6-1.2 beside a value in µmol/L), it is read in that unit and a warnings[] entry says so; if it fits no known unit it is set aside, the engine's default range is used (ref_source: "engine_default") and a warning says so.
Panels. 1-300 rows (301 gives 400). Two rows for the same marker (for example mg/dL and mmol/L): the first is always the one graded. If the later row agrees with it (within 10 % after unit conversion) it is listed in unrecognized as a duplicate. If it disagrees, both rows are listed in unrecognized with the reason conflicting duplicate, so the conflict is visible; the first is still the one graded. In previous, a unit spelled differently (umol/L and µmol/L) is the same unit; a really different unit (mg/dL against µmol/L) is left out of trends and listed in trends_skipped.
Patient context
patient.context is optional. Unknown fields or values and contradictions (pregnancy or menopausal status for male, a trimester without pregnant: true) return 400. Whatever you send is answered in two top-level lists, present only when patient.context was sent: context_applied (what changed the analysis) and context_ignored (accepted, but the engine has no rule for it, with the reason). Nothing is pretended: a field that is not wired is never listed as applied.
"context": {
"athlete": true,
"menopausal_status": "pre | peri | post",
"pregnant": true, "trimester": 2,
"fasting": true,
"medications": ["statin", "levothyroxine", "testosterone", "biotin", "metformin", "oral_contraceptive", "glp1"],
"conditions": ["type2_diabetes", "hypothyroidism", "ckd", "heart_failure"],
"setting": "outpatient | acute",
"smoker": true
}| Field | Values | What it does |
|---|---|---|
athlete | true, false, "endurance", "strength" | Athlete reference ranges: CK ceiling 1000 U/L, creatinine ceiling 125 (endurance) / 140 (strength) umol/L, ferritin floor 50 ug/L, lower resting heart rate and diastolic BP floors (endurance). true is applied as "endurance". A range your own row prints is not overwritten: the severity is re-judged against the athlete range and a note says so. false is listed as ignored (it is the default). |
menopausal_status | pre, peri, post (women) | Replaces the age rule when given. post: postmenopausal at any age (FSH/LH high, estradiol/progesterone low and low AMH are expected, postmenopausal testosterone ranges and ROMA cut-offs). pre: never postmenopausal, so premenopausal ranges stay valid and high FSH or low estradiol show at any age. peri: the age rules are off; low AMH is still expected from 45. Without it the old rule stays: from age 45 (AMH), 50 (testosterone ranges, ROMA) or 55 (FSH/LH, estradiol, progesterone, hCG) a woman is read as postmenopausal. |
pregnant, trimester | true, 1-3 | Pregnancy reference ranges (haemoglobin, haematocrit, RBC, ferritin, TSH, creatinine, ALT/AST, calcium and others) and pregnancy notes. The trimester selects trimester ranges for TSH, D-dimer, progesterone and (3rd trimester) uric acid; it is sent to the engine as a gestational week inside the trimester (10, 20, 32). Not applied under age 18 (listed as ignored). Condition softening is not applied in pregnancy. Ranges your own rows print win. |
medications | levothyroxine | TSH above 2.5 mU/L is graded as a warning (treated-hypothyroid target). |
metformin | Vitamin B12 below 300 (engine unit, pmol/L) is a warning, with a note. | |
oral_contraceptive | Note that high SHBG / low free testosterone is an artifact of the pill. | |
biotin | Applied as a note only: when the panel has immunoassay markers known to be affected by high-dose biotin (TSH, free T4/T3, troponin, PTH, ferritin, hCG, vitamin D, testosterone, estradiol, cortisol, ...), a notes.biotin_interference note recommends confirming with a biotin-free sample (stop biotin at least 48-72 h before the draw; FDA 2017/2019). Severities are not changed; affected_markers lists the markers. | |
statin | Applied: LDL-C, non-HDL-C, ApoB or total cholesterol below the printed lower limit is the expected on-treatment result, so it is not reported as a finding (the marker reads normal, no deviation) and notes.statin_on_treatment says why and names the ESC/EAS 2019 goals (LDL-C < 1.4 mmol/L very-high risk, < 1.8 mmol/L high risk, reduction >= 50 %). Raised CK, ALT or AST adds notes.statin_ck_alt: the statin as a possible cause, with the ESC/EAS 2019 thresholds (CK 4x ULN or more: continue and monitor without symptoms and under 10x ULN, otherwise stop and re-check; ALT/AST 3x ULN or more: stop or reduce and re-test in 4-6 weeks). A raised LDL-C on treatment is graded as usual; treatment goals are not graded. | |
testosterone, glp1 | Accepted, ignored: the engine has no rule. | |
clopidogrel, warfarin, allopurinol, carbamazepine, thiopurine, fluoropyrimidine, iron_supplement | Accepted; the blood engine has no rule (listed as ignored there). With omics (beta) they link to the multi-omics pharmacogenomic and haematology rules: e.g. clopidogrel + a CYP2C19 poor/intermediate genotype, statin + SLCO1B1 decreased function + raised CK, iron_supplement + a thalassaemia trait. | |
setting | outpatient, acute | Clinical setting of the request. Absent or acute = the default (emergency-style) reading, unchanged. outpatient: the emergency-style cardiac next steps (serial troponin at 0h/3h, "measure SpO2/RR now") are replaced by an outpatient work-up (ECG, echocardiography and specialist assessment, repeat troponin on a new sample), unless chest pain or breathlessness is reported or the troponin is critical; a natriuretic peptide (NT-proBNP, BNP) graded critical is read as warning (ESC 2021 HF guideline: non-acute rule-out NT-proBNP 125 pg/mL, the age-stratified 450/900/1800 pg/mL values are acute-setting; NICE NG106: > 2000 ng/L specialist assessment within 2 weeks, 400-2000 ng/L within 6 weeks). With conditions: ["heart_failure"] a stable heart-failure picture reads soon, not urgent. Genuinely critical values (potassium, sodium, a critical troponin, creatinine ...) stay critical and urgent in every setting. acute is listed as ignored. Send setting (or have your integration configured with defaultSetting) for every outpatient panel: without it the engine reads the acute way (serial troponin, vitals every 4-6 h, Order CBC/CMP on a 24 h inpatient timeline, 48-72 h kidney repeat). With outpatient the CMP order reads "within 48-72 hours", the kidney repeat is a confirm-and-stage step (KDIGO 2024: CKD needs more than 3 months) and the timed source hunt for an inflammatory marker is limited to sepsis. |
conditions | ckd, diabetes, type1_diabetes, type2_diabetes, thyroid_disease, hypothyroidism, hyperthyroidism, anticoagulation, chronic_liver_disease, gout, heart_failure, acs_pci, ascvd | Warnings on the markers the condition explains (CKD: creatinine, eGFR, urea ...; diabetes: glucose, HbA1c ...; thyroid: TSH, FT4, FT3; heart failure: NT-proBNP, BNP) are shown as info with expected_for_condition. Critical findings are never softened. The diabetes types and the thyroid kinds are not told apart. Not applied in pregnancy (listed as ignored). acs_pci (acute coronary syndrome or PCI as the indication for antiplatelet therapy) changes no blood marker; the multi-omics CYP2C19 / clopidogrel finding is high priority only with it (CPIC 2022 is strong for that indication). ascvd (established atherosclerotic cardiovascular disease, also implied by acs_pci) changes no blood marker: the lipid next step does not send the reader to a SCORE2 primary-prevention estimate (ESC 2021: SCORE2 is for apparently healthy people aged 40-69, SCORE2-OP from 70; also withheld for heart failure, CKD with eGFR < 60 and age < 40). |
medications_other | string (200 characters) | Free text about other medicines. Shown, never interpreted (listed as ignored). |
allergies | { "drug_classes": ["penicillins", "cephalosporins", "sulfonamides", "nsaids_aspirin", "iodinated_contrast", "opioids"], "other": "text" } | Documented drug allergies, shown in the report header. A next step whose text names a drug of an allergic class gets a visible caution (allergy_caution on the step lists the classes). No other interpretation; other is free text and never interpreted. |
surgical_history | { "procedures": [...], "other": "text" }, procedures: bariatric_rygb, bariatric_sleeve, bariatric_bpd_ds, gastrectomy_total, gastrectomy_partial, ileal_resection, thyroidectomy, splenectomy, nephrectomy, oophorectomy_bilateral, cholecystectomy, hysterectomy, other | Bariatric surgery: a low B12, iron/ferritin, folate, vitamin D, calcium, copper or zinc (or a high PTH) gets notes.history_bariatric_deficiency; if none of the ASMBS screening set is on the panel a "consider" next step lists it. Total gastrectomy: a low B12 gets a note and a replacement step (notes.history_gastrectomy_total_b12); partial: lower-risk wording. Ileal resection: low B12 note. Thyroidectomy: TSH / free T4 read as replacement monitoring; low calcium with a low or not-raised PTH gets notes.history_postsurgical_hypoparathyroidism (a PTH step when PTH is absent). Splenectomy: a high platelet count, WBC or lymphocyte count graded warning is shown as info with expected_for_condition: "splenectomy"; a critical value is never softened; notes.history_splenectomy_counts. Nephrectomy: creatinine / eGFR note (read against the patient's own baseline). oophorectomy_bilateral (women only; male returns 400): postmenopausal reading of the sex hormones at any age; an explicit menopausal_status wins. Severity is never changed except for the splenectomy counts above. cholecystectomy, hysterectomy, other and the free text are recorded and shown, with no rule. |
smoker | boolean | true: CEA is read against the smoker limit of 5 ng/mL (non-smokers 3 ng/mL; laboratory catalogues). A CEA above your printed non-smoker range but at or below 5 ng/mL reads normal and notes.smoker_cea says so; above 5 ng/mL it is graded as usual. false is listed as ignored (the default). |
fasting | boolean | Accepted, ignored: glucose, triglycerides and insulin are always graded against fasting ranges. |
Each context_applied entry: { "field", "value", "effect", "affected_markers": [...] }. affected_markers are the markers of THIS panel the rule can act on; an empty list means the rule is on but nothing in the panel is subject to it. Each context_ignored entry: { "field", "value", "reason" }.
Serial troponin
When the panel has a troponin (troponin = hs-cTnT, or troponin_i) and a previous panel carries the same marker, the change is judged and the verdict is added to the finding's rationale (codes rationale.troponinRise01h, troponinRise02h, troponinRiseRelative, troponinRiseNoInterval, troponinNoRise, troponinNoRiseNoInterval, with values: from, to, delta, percent, hours, p99). The nearest earlier panel is used; values are compared in the engine's unit (ng/L).
| Interval (from the two time stamps) | Rule | Rise = |
|---|---|---|
| up to 1.5 h, hs-cTnT | ESC 2020 NSTE-ACS 0/1-h algorithm (Elecsys hs-cTnT) | change of 5 ng/L or more |
| 1.5 to 2.5 h, hs-cTnT | ESC 2020 0/2-h algorithm | change of 10 ng/L or more |
longer, unknown, or troponin_i (assay-specific cut-offs are not applied) | Fourth Universal Definition of Myocardial Infarction | change of more than 20 % with at least one value above the 99th percentile (the finding's upper limit) |
A rise is graded critical (acute myocardial injury until proven otherwise), the case is urgent, and the emergency work-up stays even with setting: "outpatient". A change below the thresholds does not raise the severity; the rationale says so, and that a stable raised value points to chronic injury (the clinical picture and ECG decide). The interval is unknown when either time stamp has no time of day (date only, including two panels on the same day), or when one has a UTC offset and the other does not; then only the relative rule is applied and the rationale says that the 0/1-h and 0/2-h rules need collected_at with a time. A falling troponin is not a rise.
Lipid and tumour-marker next steps
Added to next_steps.steps (all worded as a work-up, never as a diagnosis; clinical mode only for the tumour markers):
Step key (nextSteps.category.) | When | Content |
|---|---|---|
assessFamilialHypercholesterolaemia | LDL-C 4.9 mmol/L (190 mg/dL) or higher, patient 18 or older, not pregnant | Possible familial hypercholesterolaemia: exclude secondary causes, Dutch Lipid Clinic Network score, genetic testing and cascade screening (ESC/EAS 2019). Says that SCORE2-type calculators are not valid in FH; the steps that send the reader to SCORE2 (assessAtherogenicRisk, reviewLipids) are left out. |
assessSevereHypertriglyceridaemia | Triglycerides above 10 mmol/L (about 880 mg/dL) | Acute-pancreatitis risk (ESC/EAS 2019): abdominal pain and lipase, secondary causes, very low-fat diet and no alcohol, fibrate (omega-3 as add-on), lipid specialist, fasting repeat. |
evaluateRaisedPSA | PSA above its (age-specific) range | EAU risk-adapted pathway: repeat under standard conditions, free/total ratio in the 3-10 ng/mL zone, risk assessment and multiparametric MRI before any biopsy, urology. |
evaluateRaisedAFP | AFP above 400 ng/mL | Liver imaging (ultrasound, then multiphase CT or MRI) and hepatology; hepatitis status; AFP with a liver lesion is highly specific for HCC (EASL) but alone is not a diagnosis. |
evaluateRisingTumourMarker | previous has at least two earlier panels with the same CEA, AFP, CA 19-9, CA 125, CA 15-3 or PSA; the series rises on at least 2 consecutive measurements, 30 % or more in total, and the latest value is above its limit | Confirm with a repeat sample within about a month and correlate with imaging per the treating oncologist's protocol. The finding gets a rationale.tumourMarkerRising step and its severity is raised from info to warning. A series that is flat, not consecutive, under 30 %, or still inside its limit is not flagged. |
Errors
Every error is JSON: { "error": { "code": "...", "message": "...", "request_id": "..." } }.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON or a failed validation; the message names the field. |
| 401 | unauthorized | Missing, unknown or inactive key. |
| 413 | payload_too_large | Body over 1 MB. |
| 422 | no_recognized_results | No row could be matched to a marker; error.unrecognized lists them. |
| 429 | rate_limited | More than 30 requests per minute for your key. |
| 429 | monthly_limit_exceeded | Your monthly report allowance is used up (only if one is set on your key). |
| 502 / 500 | analysis_failed / internal_error | Unexpected failure. Retry; quote request_id if it persists. |
Limits and metering
- 300 results per request, 12 previous panels, 1 MB body.
- 30 requests per minute per key (counted per server instance).
- One successful (HTTP 200) call counts as one report, on
/api/v1/analyzeor/api/v1/report. Failed calls are not counted.
Branded PDF report
POST https://<host>/api/v1/report Content-Type: application/json
Same authentication, request body, validation, limits and errors as /api/v1/analyze, and the same rule-based analysis (no AI model is called). The response is 200 application/pdf instead of JSON: a report your clinicians can read, in your branding. Two optional fields are added:
| Field | Notes |
|---|---|
patient.display_id | A reference printed on the PDF, up to 64 characters (a case number, for instance). Names are not required and should not be sent unless you need them printed. |
design | "blocks" or "classic". Absent: your profile's default (blocks). |
lang selects the language of the PDF (as for analyze); when absent your profile's default is used. Errors are the same JSON error objects as for analyze; a failed render returns 500 internal_error.
One successful call is one metered report. A report call is not also counted as an analyze call.
Your profile
The look of your PDF is set per partner by us; it is not sent with each request. Fields:
| Field | Meaning |
|---|---|
displayName | Your name, printed in the PDF (up to 80 characters). |
logo | PNG or JPEG, printed in the header instead of the StructaMed mark. Supplied as a data URL or an https URL (fetched once and cached). |
palette | One of the colour presets: structa (default), ocean, navy, sky, forest, emerald, burgundy, plum, amber, graphite, rose, slate. Clinical status colours never change. |
hideStructaBranding | true removes the StructaMed mark and the "STRUCTA.MED" footer text; your displayName and logo take their place. Default false. |
defaultLang | Language when the request sends none. Default en. |
defaultDesign | blocks (default) or classic. |
defaultSetting | outpatient or acute: the clinical setting used when a request sends no patient.context.setting. Absent: engine default. |
Without a profile you get the defaults: StructaMed palette and branding, English, blocks.
The line "Structured for clinical review. Final judgment remains with the clinician." (translated) is a safety statement and is printed on every report, whatever the profile says.
Example
curl -s https://<host>/api/v1/analyze \
-H "Authorization: Bearer $STRUCTA_KEY" \
-H "Content-Type: application/json" \
-d '{
"patient": { "sex": "male", "age": 45 },
"results": [
{ "name": "ApoB", "value": 1.32, "unit": "g/L", "ref_low": 0.6, "ref_high": 1.0 },
{ "name": "ALT", "value": 22, "unit": "U/L", "ref_high": 50 }
]
}'Glossary
- Reference range: the interval a laboratory prints as expected for a healthy population. Yours win over our defaults when sent.
- Status: where the value sits relative to the range used (
low/normal/high). - Severity: how much attention the finding deserves (
info,warning,critical). - Pattern: a combination of markers that together suggest a recognized picture (for example iron-deficiency anemia). Patterns are suggestions for review, not diagnoses.
- Cluster: the body system a group of findings belongs to.
- Risk: a coarse overall level (
low,medium,high) with a numeric score derived from the findings; useful for sorting, not for diagnosis.