Skip to content

REST API

Base URL: http://127.0.0.1:8000 (local)

GET /health

Returns service status and config flags (no PHI).

Includes: status, classifier_gemini (bool, the effective value), inbox_max_pages (effective), vertex_gemini_model, vertex_gemini_fallback_model (effective), vertex_cooldowns ({model: seconds left} after a persistent 429/5xx), runtime_settings ({values, source: {key: "file"|"env"}, updated_at, updated_by, override_count}), loinc_mapper_ready, mapping_registry_version (the published registry), pending_approvals (approvals that wait for a publish), pending_applied (how many of them the running mapper applies), pending_view_version and mapper_package ({name, version, build} of the mapper package the service loaded from the bucket) (BUG-88). With a mapper package that lacks the review interface, loinc_mapper_ready is false and loinc_mapper_error names the missing piece.

Settings

The Settings page's API. Values are counts, ratios, booleans and a model id — never a secret. A saved override applies to the next fax on both paths and beats env until cleared.

Method Path Notes
GET /api/v1/settings {schema_version, updated_at, updated_by, override_count, fields:[{key, label, help, kind (int, ratio, bool, text), min, max, max_length, value, source, env_value, override}]} — one entry per overridable key: inbox_max_pages, abstain_warn_ratio, abstain_warn_count, hold_unfiled_ratio, hold_min_lab_rows, loinc_judge_allow_medium, elation_new_chart_on_no_match, classifier_gemini, vertex_gemini_fallback_model
PUT /api/v1/settings Body = the override set ({"inbox_max_pages": 10, "classifier_gemini": true, "hold_min_lab_rows": null}); a null or absent key means the env value applies again. Returns the GET view plus changed (["inbox_max_pages:50->10"]). 422 on an unknown key, a value out of bounds or hold_ratio_below_warn_ratio; 503 runtime_settings_write_failed. Audited as runtime_settings_saved by=<session user> changed=

GET /api/v1/parsers

List OCR and extractor plugins with enabled status.

POST /api/v1/parse

Multipart form upload (file, ocr_parsers, extract_parsers, optional classifier_gemini=true|false). Missing classifier_gemini uses the CLASSIFIER_GEMINI env (fail closed). The extract-page checkbox is this parse only; inbox poll does not send the field. Streaming variant: POST /api/v1/parse/stream (NDJSON). One parse runs at a time in the service (BUG-89): while the inbox or another upload is being parsed, both routes answer 409 with a sentence and Retry-After: 60; a stream that loses the race ends with {"stage": "error", "status": "busy", "message": …}. GET /health → parse_slot {busy, owner, held_s}.

POST /internal/inbox/poll (Cloud Scheduler, X-Inbox-Poll-Token) answers 202 {ok, accepted, submit, chain} at once; the fax is processed by the background chain (chain: running, rounds, items, errors, deferred, held, last_reason). ?dry_run=true is a synchronous listing (dry_run: true, counts, items with their skip reason). Outside clinic hours or while a chain runs it answers 200 skipped with the reason.

Completed jobs include image_fidelity (page counts, pixel min/max, wrapper img2pdf|pillow|original_pdf, recompressed, low_resolution). Numbers only.

An upload with more pages than the effective inbox_max_pages is refused before any parse: 413 too_many_pages: N pages, cap M (Settings → Max pages); 0 disables the cap.

The result's filing_plan.verdict is the filing verdict: {status: ok|warn|hold, reason, lab_rows, filed_rows, unfiled_rows, unfiled_ratio, needs_communication, thresholds}. Unfiled = lab rows not filed to the grid. warn files and flags the fax for further communication; hold files nothing until a person overrides (below). Each segment carries its own verdict for the note.

GET /api/v1/elation/report-types

Elation document-type catalog for the harness dropdown (report_type, display_value).

POST /api/v1/jobs/{job_id}/file-to-elation

File a stored parse job to Elation.

JSON body (all optional):

Field Description
segment_id Filing segment; default first
report_type Elation catalog label (fail closed if unknown). Source returned as report_type_source: classifier or manual
override_hold true files a job whose verdict says hold — a person's decision, logged as filing_hold_overridden job_id= by=<session user> and returned as hold_overridden_by. Without it a held job answers 409 filing_held and nothing is posted

The response also carries note_grid (an Imaging Result posted its verbatim sections on a results-less grid), note_posted (the non-visit-note channel: true, false on failure — the report stays filed — or null when there was nothing to post) and review_reason (imaging_result when a person should review the copied sections).

LOINC review

Method Path Notes
GET /api/v1/loinc-review/cases open_only, status, dispute_only, needs_attention, auto_filed_only, q (test name, printed/repaired name, code, status, job or case id), job_id, limit; the response carries limit and truncated. A case file written before questions existed is never listed
GET /api/v1/loinc-review/case-counts {open, attention, disputed, auto_filed, ops, all, legacy} for the filter chips; legacy = case files kept on disk and shown nowhere
GET /api/v1/loinc-review/cases/{case_id} Mapping facts + opaque IDs; name (the question's name), question ({id, key_version, facts:[{key, label, words}]}), laboratories_seen, decisions (where each decision stands: state approved, published, replaced, returned, withdrawn or recorded; code, reviewer_name, registry_version, replaced_by, reason_code, message, not_applied), kept, choice (what separates the codes of a tie), policy_defaults, primary_outcome, diagnostics, panel (heading, source, member count, panel_key = the index record a required_panel approval may name), blocked_approved_code (the clinician-approved code the mapper blocked (rejected, or held for its unit), from the mapper's primary outcome only, else ""), refused_approvals ([{code, axis, reason_code, state}]: the approvals of the name the latest row did not take because it prints another fact) and refused_line (the card's sentence for it, else ""), repair_provenance.printed_name (never the upload filename)
GET /api/v1/loinc-review/cases/{case_id}/decision The saved decision to prefill the form: the active one, else the newest withdrawn one, else null
POST /api/v1/loinc-review/cases/{case_id}/preview {selected_loinc} and nothing else. What approving the code would mean, before anything is written: {state: new, confirms, conflict or refused; message; selected{code, display}; earlier{decision_id, code, display, reviewer_name, reviewed_at, state, registry_version}; refusal{axis, reason_code}; method_hint; scope}. Takes no mapper lock. 503 mapper_unavailable
GET /api/v1/loinc-review/quick kind=question (default) or inference → {kind, counts{question, inference}, cards}; a card carries the question's facts, laboratories, tie, preselected, and per code separates
POST /api/v1/loinc-review/cases/{case_id}/quick-decision {case_revision, selected_loinc, reviewer_id, reviewer_name, choice, earlier_decision_id}; nothing else is accepted. choice is empty (approve), keep (no decision is written, the card closes as kept:<decision>) or replace (the approval supersedes earlier_decision_id, copied from the preview). 400 conflict or refused with the outcome; 409 conflict_changed when the earlier answer is no longer the one the reviewer saw
POST /api/v1/loinc-review/quick/confirm-all {reviewer_id, reviewer_name} → {confirmed, left:[{case_id, reason}], counts}; each filing by inference goes through the preview, a conflict is never replaced in a batch
GET /api/v1/loinc-review/ops-counts {total, counts:[{reason, name, count, last_seen_at}]}: what was filed or set aside without a doctor's case (flag:<review flag>, policy:<axis>, hold:<reason> for an approved code that waits for its unit, no_question:<status>)
POST /api/v1/loinc-review/disputes Open a case for a mapped-but-wrong code. {job_id, row_id} (the harness job store, else the job's fax record) or {raw_name, ...}. Extra patient-like keys rejected.
POST /api/v1/loinc-review/cases/{case_id}/decision Clinician decision; an unknown key is refused (422). choice and earlier_decision_id as on the quick decision. required_panel (approve_universal only) names the panel index record the case resolves to — validated against the case's heading with the mapper's panel index. An approval answers state: approved and applies from the next fax
POST /api/v1/loinc-review/cases/{case_id}/reopen Withdraw every decision that answers the case and reopen
POST /api/v1/loinc-review/publish Compile → replay → active registry, per item (dry_run=true first, it changes nothing; policy=strict_replay for operators) → {published, already_published, returned:[{decision_id, case_id, reason_code, message, other}], deferred_source_specific, active_version, worker_loaded, …}. 400 no_decisions when no approval waits
GET /api/v1/loinc-review/search Active LOINC search, on the catalog's own read-only connection
GET /api/v1/loinc-review/export.csv Review CSV (503 when the mapper package is not importable)

Faxes

The fax explorer reads the fax record (FAX_DATA_DIR/fax_records/, one file per job with every attempt), the journeys and the review cases. Ids, dates, counts and rows with their values — never patient names; the only patient field is the Elation patient_id a report was filed to.

Method Path Notes
GET /api/v1/faxes q (a numeric value matches the inbox id, an Elation report id or a patient id; anything else is a job-id prefix), from/to (YYYY-MM-DD, else 400 invalid_date), report_type, decision (auto_file, staging_file, new_chart_file, triage), outcome (filed, partial, parsed, failed, skipped, held, legacy), needs_communication=true (warn/hold verdicts and held items), limit ≤ 500 → {count, items}; each item carries verdict (ok, warn, hold or empty) and needs_communication; latest attempt per job, newest first
GET /api/v1/faxes/{job_id} {job_id, record, journey, journeys_by_attempt, review_cases, links, legacy, notices}; each attempt carries verdict (the numbers, hold_overridden/hold_overridden_by after an override), held_reason and, per segment, review_reason; 404 fax_not_found; 500 record_contains_forbidden_fields when a stored record carries a patient key
GET /api/v1/faxes/{job_id}/elation-report/{report_id} Read-back from Elation: {report_id, report_type, result_count, grids:[{note, note_chars, result_count}], read_at}; on an Imaging report note is empty and note_chars says how long the verbatim note is (the text stays in Elation); 404 unless the report belongs to the fax, 503 without Elation credentials, 502 with the HTTP status only

Communication

Who is told about a fax, where, and in which mode (faxautomation/api/routes/communication.py). Staff, settings and the state of notices: ids, staff names, counts and codes. The credential is never returned, the words of a clinical note are never returned, and a vendor's message is never passed through: a failure is a short code.

Method Path Notes
GET /api/v1/communication/status {mode, saved_mode, ceiling, blockers, file_problem, connection{state, organization_name, members}, credential_suffix, organization{id, name, pin}, limits{hour_left, day_left, breaker_open, breaker_code, failures}, steps{off, dry_run, pilot, live}, open_notices, test_note_verified_at, revision}. connection.state is not_configured, refused, unreachable, wrong_organization, connected or ready. steps lists per mode what keeps it from being switched on
GET /api/v1/communication/directory/members ?refresh=true. {members:[{member_id, name, kind, suspended}], count}; never an email. 503 directory_unavailable:<code>
GET /api/v1/communication/directory/conversations notes and team conversations without a contact: {conversations:[{conversation_id, kind, title, internal, archived, endpoint_id}], count}
GET /api/v1/communication/directory/physicians the practice's doctors from Elation: {physicians:[{physician_id, name}], count}
POST /api/v1/communication/directory/conversations/lookup {link}: the conversation a person pasted. 422 not_a_conversation, not_found, or not_internal for a patient's conversation, whose title is never returned
GET /api/v1/communication/settings the settings file as it is, with file_problem (unreadable, invalid or empty)
PUT /api/v1/communication/settings the editable part and revision. Names are stamped from the directories, whatever the body says. mode, organization_id, test_note or an unknown key: 422. 409 settings_revision_mismatch; 422 {error: unknown_member, member_suspended, unknown_physician or thread_not_internal, detail}, duplicate_physician, daily_limit_below_hourly, invalid_patient_id
POST /api/v1/communication/organization the settings adopt the organization the credential answers for, when it is the pinned one. 409 connection_<state>
POST /api/v1/communication/mode {mode}. 409 {error: mode_blocked, blockers} with every blocker; off always works. Lowering the mode cancels what was not sent; off resets the breaker
POST /api/v1/communication/test-note takes nothing (any key is 422): fixed synthetic words to where notices about faxes go (the picked team conversation, or the owner's own thread, which the first note makes), paging the owner. At most 5 an hour. 409 with what is missing
POST /api/v1/communication/test-note/{key}/check one look at the conversation: {notice, verified}. verified = found, internal, nobody paged but the owner; the settings then record it
GET /api/v1/communication/notices state, kind, limit ≤ 200 → {notices, count}, newest first
GET /api/v1/communication/notices/{key} the route taken, the checks, the history. words is the exact text of a note that carries no text of a report; for a clinical note words_withheld: true and sections holds section names with character counts
POST /api/v1/communication/notices/{key}/cancel {state} as the page saw it. 409 notice_changed, not_cancellable
POST /api/v1/communication/notices/{key}/retry {state}. A blocked notice is tried as it is; a failed one as a new generation without the text of the report. 409 notice_changed, not_retryable
GET /api/v1/communication/routes/gaps the doctors whose notices went to the fallback coordinator: {gaps:[{physician_id, physician_name, notices}], count}

GET /api/v1/faxes/{job_id} carries notices: one entry per notice about the fax, as the outbox has it now. GET /health carries communication{mode, saved_mode, ceiling, blockers} for a signed-in caller.

Pages

/, /loinc-review, /quick-review, /faxes, /faxes/{job_id}, /communication and /settings are composed server-side (web/pages.py) from one shell and a fragment under web/static/pages/. Cookie login when APP_AUTH_ENABLED; an API call without a session gets 401 {"detail":"unauthorized"} and the page scripts send the person to /login?next=….

Errors

Code Cause
400 Disabled parser, unknown report_type, invalid dispute row
401 App auth required
404 Unknown parser, expired job, missing case
409 filing_held — the job's verdict holds it; send override_hold: true to file on a person's decision. conflict_changed — the earlier answer of a review question changed since the preview
413 too_many_pages — the upload is over the page cap (Settings → Max pages)
422 Schema / extra forbidden fields; a settings value out of bounds or hold_ratio_below_warn_ratio
503 runtime_settings_write_failed — the settings file could not be written