Skip to content

API Endpoints

All endpoints require authentication unless noted. Base prefix: /api

MethodPathAuthDescription
GET/healthNoReturns {"status": "ok", "mode": "core"|"share"}. Mounted in both run modes. The frontend reads mode on boot to decide whether to render the admin SPA tree or the share-only tree.
MethodPathAuthDescription
GET/api/setup/statusNoReturns {"needs_setup": true/false}, true when no users exist
POST/api/setup/completeNoCreate first admin user + first patient (only works when no users exist)
{
"username": "alex",
"password": "your-password",
"display_name": "Alex Smith",
"patient_name": "Alex Smith",
"patient_date_of_birth": "1990-01-15",
"patient_sex": "M"
}

Only username, password, and patient_name are required. All other fields are optional. On success, a session cookie is set so the user is automatically logged in.

MethodPathAuthDescription
POST/api/auth/loginNoLogin with username/password
POST/api/auth/logoutYesLogout (clear session)
GET/api/auth/meYesGet current user with patient access
GET/api/auth/oidc/enabledNoCheck if OIDC is enabled
GET/api/auth/oidc/loginNoInitiate OIDC login flow
GET/api/auth/oidc/callbackNoOIDC callback handler
MethodPathAuthDescription
GET/api/patientsYesList patients accessible to the current user
POST/api/patientsYesCreate a new patient
GET/api/patients/{id}YesGet patient details
PATCH/api/patients/{id}YesUpdate patient fields
DELETE/api/patients/{id}YesDelete a patient

Patient fields: display_name, date_of_birth, sex, only fields that feed the LLM extraction context are stored.

MethodPathAuthDescription
GET/api/documentsYesList documents (filterable)
POST/api/documents/uploadYesUpload a document file. Hashes the upload before insert; on a SHA-256 match, deletes the just-uploaded copy and returns {filename, status: "duplicate", existing_document_id, existing_filename, existing_patient_id, message} instead of inserting a duplicate row.
GET/api/documents/{id}YesGet document with all related data
GET/api/documents/{id}/fileYesDownload/serve the document file
PATCH/api/documents/{id}YesUpdate document metadata
DELETE/api/documents/{id}YesDelete document and file
POST/api/documents/{id}/moveYesReassign document to another patient
POST/api/documents/{id}/reprocessYesRe-run OCR and/or LLM extraction. Enqueues onto the same single-threaded pipeline worker as inbox uploads at priority 0, so the click jumps ahead of pending uploads but still serialises against any in-flight job.
POST/api/documents/{id}/translateYesQueue an on-demand English translation of the document body. Reuses cached ocr_text (does not re-run OCR), runs through the translation_en prompt, persists to documents.ocr_text_en. Body: {llm_provider_id?: string}. Enqueues a translate job at priority 0.
POST/api/documents/{id}/translate-regionYesQueue OCR + translation of a user-selected rectangle on one PDF page. Pre-allocates a region_translations row (so the UI shows a placeholder card immediately) and enqueues a translate_region job. Body: {page: int (1-based), bbox: {x, y, w, h} (normalized [0,1]), ocr_provider_id?: string, llm_provider_id?: string}. Returns {status: "queued", document_id, region_id}.
DELETE/api/documents/{id}/region-translations/{region_id}YesDelete a region translation row and its thumbnail PNG from disk.
GET/api/documents/{id}/region-translations/{region_id}/thumbnailYesServe the cropped PNG thumbnail for a region translation card.
POST/api/documents/{id}/cancelYesCancel processing
GET/api/documents/{id}/stagesYesPer-document pipeline stage timeline (every OCR / LLM / organize transition this doc has been through, across uploads and reprocesses). Backs the document detail page’s run-grouped timeline.
POST/api/documents/{id}/edit-with-aiYesEdit metadata via natural language
POST/api/documents/{id}/generate-filenameYesGet an AI-suggested {suggested_filename} (does not rename)
POST/api/documents/{id}/renameYesRename on disk + DB; auto-disambiguates on collision (-2, -3, …)
GET/api/documents/{id}/find-candidatesYesWalk the vault for files matching this document’s original_filename. Returns vault-relative paths. Used by the document detail page to recover from a broken file_path.
POST/api/documents/{id}/relinkYesRepoint a document at an existing vault file ({"vault_path": "..."}). Updates file_path + file_size; does NOT re-run the pipeline.
POST/api/documents/{id}/replace-fileYesMultipart upload of a replacement file. Lands in the document’s organised folder (patients/{slug}/{year}/...), updates file_path. Extension is locked to the original. Does NOT re-run the pipeline.
POST/api/documents/{id}/linkYesLink to another document
GET/api/documents/{id}/linksYesGet all document links
DELETE/api/documents/{id}/links/{link_id}YesRemove a document link
POST/api/documents/{id}/suggest-linksYesAI-suggest related documents
ParameterTypeDescription
patient_idintFilter by patient
typestringFilter by document type (comma-separated for multiple)
date_fromstringEarliest event_date, inclusive (YYYY-MM-DD)
date_tostringLatest event_date, inclusive (YYYY-MM-DD)
statusstringFilter by status (comma-separated for multiple: pending, processing, done, failed, needs_review, cancelled)
qstringFull-text search query
specialtystringFilter by specialty (comma-separated for multiple)
doctor_idstringFilter by doctor (comma-separated for multiple)
facility_idstringFilter by facility (comma-separated for multiple)
limitintResults per page (default: 50)
offsetintPagination offset

patient_id, doc_type, event_date, issued_date, doctor_id, doctor_name, facility_id, facility_name, specialty_original, summary_en, event_id, notes, tags, user_notes, original_filename

event_date is the canonical timeline anchor (when the medical event happened); issued_date is the administrative date the document itself carries. Both accept YYYY-MM-DD strings or null.

doctor_name and facility_name are write-only convenience fields, they are not stored on the document, only on doctors / facilities. When a request sends a name without the matching doctor_id / facility_id, the PATCH runs the name through the alias-aware upsert and fills in the id automatically. A name that matches an existing slug or alias reuses that entry, anything else creates a new canonical row. Sending the name as null clears the foreign key.

{
"instruction": "Change the doctor to Dr. Mueller and set the date to 2024-03-15"
}
{
"mode": "both",
"llm_provider_id": "claude-1",
"ocr_provider_id": "tesseract-1",
"vision_provider_id": null
}
FieldTypeDefaultDescription
modestring"both""ocr" (OCR only), "llm" (LLM only), "both" (OCR+LLM), or "vision_llm" (single-step Vision-LLM flow)
llm_provider_idstringnullSpecific LLM provider ID (null = default highest-priority). Used when mode is llm or both.
ocr_provider_idstringnullSpecific OCR provider ID (null = default highest-priority). Used when mode is ocr or both.
vision_provider_idstringnullSpecific Vision-LLM provider ID (null = default highest-priority). Used when mode is vision_llm.

GET /api/documents/{id}/stages returns every persisted stage event for a document, oldest first:

{
"document_id": 42,
"events": [
{
"id": 1,
"stage": "ocr",
"status": "completed",
"job_kind": "upload",
"message": null,
"page_current": 49,
"page_total": 49,
"started_at": "2026-04-29T11:02:46",
"finished_at": "2026-04-29T11:08:11"
},
{
"id": 2,
"stage": "llm_extraction",
"status": "completed",
"job_kind": "upload",
"message": null,
"page_current": null,
"page_total": null,
"started_at": "2026-04-29T11:08:11",
"finished_at": "2026-04-29T11:09:02"
}
]
}

Stage values: ocr, vision_extraction, llm_extraction, page_classification, section_extraction, organizing, thumbnail, cache_ocr, translation, region_ocr, region_translation. Status values: started, completed, failed, skipped, cancelled. job_kind is upload, reprocess, translate, or translate_region. message is populated on failures with the error string. page_current / page_total are populated on stages that work page-by-page.

invoice_for, report_for, imaging_for, follow_up, related

MethodPathAuthDescription
GET/api/eventsYesList events (filterable by patient_id, event_type)
GET/api/events/{id}YesGet event with linked documents
POST/api/eventsYesCreate a new event
PATCH/api/events/{id}YesUpdate event fields
DELETE/api/events/{id}YesDelete event (unlinks documents). Pass ?delete_documents=true to also delete linked documents
POST/api/events/{id}/linkYesLink a document to the event
DELETE/api/events/{id}/link/{doc_id}YesUnlink a document from the event
POST/api/events/suggest-for-document/{doc_id}YesAI-suggest event for a document

patient_id, title, event_type, description, date_start, date_end, is_ongoing, severity, diagnosis_text, icd10_code, specialty_text, notes, color

symptom, diagnosis, hospitalization, surgery, treatment, follow_up, emergency, pregnancy, chronic_condition, injury, screening, other

MethodPathAuthDescription
GET/api/lab-resultsYesList lab results. Each row carries document_filename, document_doc_type, document_doc_date, document_missing, and canonical_code via JOINs.
GET/api/lab-results/orphansYesLab results whose document_id no longer points to an existing document.
GET/api/lab-results/timelineYesTime-series for a specific test (legacy, superseded by the in-page chart picker).
POST/api/lab-resultsYesCreate a single lab result (add-by-hand). Requires document_id and test_name_original.
PATCH/api/lab-results/{id}YesUpdate editable fields (value, unit, reference range, test_date, …). Viewers are blocked.
DELETE/api/lab-results/{id}YesDelete a single lab result.
ParameterTypeDescription
patient_idintFilter by patient
test_namestringSearch by test name
date_fromstringFilter by date
date_tostringFilter by date
limitintResults per page (default: 500, max: 2000)
offsetintPagination offset
MethodPathAuthDescription
GET/api/imagingYesList imaging studies (filterable, paginated)
GET/api/imaging/{id}YesGet study with nested series + report fields
GET/api/imaging/{id}/series/{series_id}/framesYesList DICOM frames in a series
GET/api/imaging/{id}/series/{series_id}/frame/{index}YesServe a frame as PNG (default) or raw DICOM. Accepts ?wc= and ?ww= for window-center / window-width override (used by the MR contrast sliders).
GET/api/imaging/{id}/bundle-filesYesList auxiliary files extracted from the same zip (DICOMDIR, JPEG previews, etc.)
GET/api/imaging/{id}/bundle-file/{name}YesDownload a single bundle file by name
GET/api/imaging/{id}/linksYesLinked documents (uses document_links)
POST/api/imaging/{id}/linksYesLink an existing document to this study
DELETE/api/imaging/{id}/links/{link_id}YesRemove a study-document link
POST/api/imaging/{id}/reportYesAttach a radiology report PDF to this study. Either pass ?document_id=N (or JSON {"document_id": N}) to link an existing PDF document, or post a multipart file= to upload a fresh PDF. PDF-only is enforced via libmagic. The placeholder document is replaced.
PATCH/api/imaging/{id}/metadataYesUpdate imaging-specific fields (modality, body_part, study_description, accession_number). Each change is also recorded in extraction_corrections against the parent document so the LLM picks it up as a few-shot example. Doctor / facility / event_date / patient are NOT accepted here, those are edited via PATCH /api/documents/{id}.
ParameterTypeDescription
patient_idintFilter by patient
modalitystringFilter by DICOM modality code (CT, MR, US, XR, MG, PT, …)
report_statusstringFilter by placeholder (no PDF report yet) or attached
qstringSearch across body part / study description / referring doctor / facility
date_fromstring (ISO date)Lower bound for study_date
date_tostring (ISO date)Upper bound for study_date
sortstringOne of modality, body_part, study_date, doctor, facility, patient, report_status, date_added
orderstringasc or desc (default desc)
limitintDefault 50, max 500
offsetintPagination offset

The list response shape mirrors /api/documents: {"items": [...], "total": N, "limit": L, "offset": O}.

MethodPathAuthDescription
POST/api/chatYesSend a chat message (RAG)
GET/api/chat/historyYesGet chat history
DELETE/api/chat/historyYesClear chat history
{
"patient_id": 1,
"message": "What were my last cholesterol results?"
}
MethodPathAuthDescription
GET/api/normalization/{type}YesList canonical entries (type: lab_tests, specialties, diagnoses, medications, doctors, facilities)
GET/api/normalization/{type}/{id}YesGet canonical entry + aliases
PATCH/api/normalization/{type}/{id}YesUpdate canonical code / display (409 on code collision)
DELETE/api/normalization/{type}/{id}YesDelete canonical entry (nulls FKs on referencing tables)
GET/api/normalization/{type}/{id}/documentsYesList documents that reference this entry
POST/api/normalization/{type}/{id}/aliasesYesAdd an alias
DELETE/api/normalization/{type}/aliases/{alias_id}YesDelete an alias
POST/api/normalization/{type}/{id}/confirmYesMark every auto-mapped alias on this entry as reviewed
POST/api/normalization/{type}/mergeYesMerge one source into a target
POST/api/normalization/{type}/merge-batchYesMerge many sources; body takes target_id or new_target: {canonical_code, canonical_display} to create the target inline
POST/api/normalization/{type}/auto-mergeYesPropose merges; returns {proposals, entries} without executing anything. Each proposal carries target_id, source_ids, reason, plus source ("knowledge_base" for ATC/LOINC/ICD-10 same-code matches, "llm" for model proposals) and confidence ("high" or "review")

Types: lab_tests, specialties, diagnoses, medications, doctors, facilities

MethodPathAuthDescription
GET/api/pipeline/statusYesGet pipeline processing status
POST/api/pipeline/startAdminStart the processing pipeline
POST/api/pipeline/stopAdminStop the processing pipeline
{
"queue_depth": 2,
"processing": "document.pdf",
"processing_step": "llm_extraction",
"processing_doc_id": 42,
"processing_pages": 15,
"processing_page_current": 7,
"total_processed": 128,
"total_errors": 3,
"recent_errors": [],
"queued_files": [{"filename": "next.pdf", "size": 1234567}],
"current_job": {
"doc_id": 42,
"filename": "document.pdf",
"kind": "reprocess",
"stage": "llm_extraction",
"page_current": null,
"page_total": null,
"stages_planned": ["ocr", "llm_extraction"],
"stages_done": ["ocr"],
"started_at": "2026-04-29T11:38:08"
},
"queued_jobs": [
{"kind": "upload", "label": "next.pdf", "doc_id": null}
],
"llm_queues": [],
"watcher_active": true,
"auto_stopped": false,
"auto_stop_reason": ""
}

The processing / processing_step / processing_pages fields are kept populated for backward compatibility. New clients should read current_job (carries the job kind, the stage stepper data, and live page progress) and queued_jobs (mirrors the worker queue so the UI can show “Up next”). kind is "upload" for inbox files and "reprocess" for clicks from the document detail page. The flow architecture is implicit in stages_planned: a list containing vision_extraction is the Vision-LLM flow, otherwise it’s the OCR + LLM flow.

MethodPathAuthDescription
GET/api/settingsYesGet all settings
PATCH/api/settingsYesUpdate settings (persisted to YAML)
GET/api/settings/llm-providersYesList LLM providers
PUT/api/settings/llm-providersYesUpdate LLM providers
GET/api/settings/ocr-providersYesList OCR providers
PUT/api/settings/ocr-providersYesUpdate OCR providers
GET/api/settings/vision-providersYesList Vision-LLM providers
PUT/api/settings/vision-providersYesUpdate Vision-LLM providers
GET/api/settings/credentialsYesList shared credentials (URL, API key, concurrency, retry policy)
PUT/api/settings/credentialsYesUpdate shared credentials
GET/api/settings/general-llmYesGet the General-LLM config (chat, auto-merge, AI edit, event extraction, link suggestion)
PUT/api/settings/general-llmYesUpdate the General-LLM config
POST/api/settings/test-llm-providerYesTest an LLM provider connection
POST/api/settings/test-ocr-providerYesTest an OCR provider connection
POST/api/settings/test-vision-providerYesTest a Vision-LLM provider with a tiny image round-trip
GET/api/settings/logsAdminRecent log lines (tail)
GET/api/settings/audit-logAdminStructured audit-log entries
GET/api/settings/sessionsAdminList active sessions across all users
DELETE/api/settings/sessions/{session_id}AdminRevoke a session

All three test endpoints accept the same request body:

{ "provider_id": "claude-1" } // test a persisted provider by id
{ "provider": { "id": "ollama-x", "type": "ollama", ... } } // test an inline, possibly unsaved entry

The inline provider form is what the UI uses so the Test Connection button works with unsaved edits. Secret fields (api_key, remote_api_key, etc.) left blank are merged from the saved entry with the same id if one exists.

Sent to PATCH /api/settings. Any subset of these may be included in a single request.

LLM: extraction_timeout, llm_max_concurrent_requests, llm_max_retries, llm_retry_backoff_seconds, canonical_language

OCR (legacy flat fields, kept for the auto-migration path): ocr_engine, ocr_language, ocr_confidence_threshold, cloud_ocr_enabled, ocr_remote_url, ocr_remote_api_key, llm_vision_provider, llm_vision_model, llm_vision_ollama_url, google_vision_key

Vision-LLM (legacy flat fields): vision_extraction_timeout, vision_max_concurrent_requests, vision_max_retries, vision_retry_backoff_seconds. New deployments should leave these at defaults and rely on the per-credential values.

Pipeline: pipeline_watch_enabled, pipeline_poll_interval, pipeline_retry_interval, pipeline_max_retries, pipeline_default_flow ("ocr_llm" or "vision_llm")

Auth: session_ttl_hours

OIDC: oidc_enabled, oidc_provider_url, oidc_client_id, oidc_client_secret, oidc_scopes, oidc_auto_create_user, oidc_username_claim, oidc_display_name_claim

For the provider lists themselves (llm.providers, ocr.providers, vision.providers) and the shared credential list (credentials[]), use the dedicated PUT /api/settings/{llm|ocr|vision}-providers and PUT /api/settings/credentials endpoints, each accepts the full ordered array and replaces the existing list. Fields with empty api_key are preserved from the previous value, so you never need to re-enter secrets when reordering.

MethodPathAuthDescription
GET/api/settings/promptsYesList all prompts with current values
PUT/api/settings/prompts/{key}YesUpdate a prompt
DELETE/api/settings/prompts/{key}YesReset prompt to default

Prompt keys: classification, vision_extraction, extraction_lab_test, extraction_specialist_report, extraction_prescription, extraction_invoice, extraction_discharge, extraction_imaging_report, extraction_surgical_report, extraction_vaccination, document_edit, sql_generation, chat_system, link_suggestion, page_classification

MethodPathAuthDescription
GET/api/settings/backupYesDownload SQLite backup file
MethodPathAuthDescription
GET/api/settings/usersYesList all users
POST/api/settings/usersYesCreate a user
PATCH/api/settings/users/{id}YesUpdate user (display_name, password)
DELETE/api/settings/users/{id}YesDelete a user
GET/api/settings/users/{id}/accessYesGet user’s patient access
POST/api/settings/users/{id}/accessYesGrant patient access
DELETE/api/settings/users/{id}/access/{patient_id}YesRevoke patient access

Mounted under /api/shares (plural) in core mode only — the share container serves a stripped surface and 404s every admin path. All admin endpoints require admin OR patient-owner role; non-admins see only shares for patients they own.

MethodPathAuthDescription
POST/api/sharesAdmin/ownerCreate a share. Body: {patient_id, document_ids[], recipient_label, recipient_contact, expires_in_days?, default_ocr_provider_id?, default_llm_provider_id?}. Response: {share_id, share_url, expires_at}. The share_url honors share.public_base_url (env: ASCLEPIUS_SHARE_PUBLIC_URL), so split-host setups hand the admin the doctor-facing URL.
GET/api/sharesYesList shares the caller can manage. Optional ?patient_id=N to scope. Each row includes share_url decorated the same way.
DELETE/api/shares/{share_id}Admin/ownerRevoke a share. Marks revoked_at and immediately invalidates every active doctor session for that share. Idempotent.
GET/api/shares/{share_id}/auditAdmin/ownerFull audit trail for a share. With ?include_active_otp=true, also returns the live OTP code.
GET/api/shares/{share_id}/active-otpAdmin/ownerJust the live OTP ({active_otp: {code, expires_at, attempts} | null}). Cheaper than /audit?include_active_otp=true when the dashboard only needs the code.
GET/api/shares/{share_id}/documentsAdmin/ownerPreview the documents in this share (same JOIN shape the doctor sees).
GET/api/shares/{share_id}/sessionsAdmin/ownerActive doctor session(s) and queued waiters. Response: {active: [...], queued: [...]}. Each active row carries an is_idle flag. The cookie-equivalent session.id is intentionally NOT exposed — the admin handle is SQLite rowid so an exfiltrated response cannot be replayed as an auth token.
DELETE/api/shares/{share_id}/sessions/{rowid}Admin/ownerForce-terminate a single active session. Idempotent.
DELETE/api/shares/{share_id}/queue/{rowid}Admin/ownerDrop a single queued waiter. Idempotent.

The audit log records these action strings:

  • otp_request — fresh code issued to the doctor
  • otp_verify_ok / otp_verify_fail — OTP verification outcome
  • view_doc — doctor opened a document
  • view_file — doctor fetched the watermarked PDF bytes
  • translate — doctor queued a region or full-page translation
  • logout / session_expired — session ended on the doctor’s side
  • share.session.revoke — admin force-killed an active session
  • share.queue.drop — admin dropped a queued waiter
  • share.create / share.revoke — admin created or revoked the share

Mounted under /api/share (singular) in both core and share modes. Authentication is by share-specific cookie, not the admin session cookie — share auth and admin auth are entirely isolated.

MethodPathAuthDescription
POST/api/share/{token}/request-otpTokenIssue a fresh OTP for the share token. Always returns 204 — the body never reveals whether the token is valid, so an attacker cannot enumerate.
POST/api/share/{token}/verify-otpToken + OTPBody: {code: "123456"}. On success either sets the asclepius_share cookie and returns 200 {status: "active"}, or sets the asclepius_share_queue cookie and returns 202 {status: "queued", queue_expires_at} when another device already holds the slot. 401 on bad code; 429 when the per-IP rate limit fires.
POST/api/share/claimQueue cookiePolled by a queued waiter every 5s. Returns {status: "active"} (and sets the session cookie) when the slot freed up, {status: "queued", queue_expires_at} while still waiting, or 410 when the queue token expired or the share was revoked.
DELETE/api/share/queueQueue cookieExplicit cancel from the waiting page. Drops the queue entry and clears the cookie. 204.
POST/api/share/heartbeatSession cookieKeepalive — bumps last_seen_at so the idle clock resets while the doctor is reading. 204.
POST/api/share/logoutSession cookieRevoke the session and clear cookies. CSRF-exempt so navigator.sendBeacon works on tab close. 200.

Same /api/share prefix; requires a valid asclepius_share cookie.

MethodPathDescription
GET/api/share/meDashboard payload: patient name, document list, recipient label, session expiry, allowed translation languages, default language.
GET/api/share/documents/{doc_id}Document detail (same JOIN shape the admin sees, minus encounter notes which are deliberately hidden).
GET/api/share/documents/{doc_id}/fileWatermarked PDF/image bytes. Fresh watermark on every request with the recipient’s name + UTC timestamp. Cache-Control: no-store.
POST/api/share/documents/{doc_id}/translate-regionBody: {page, bbox: {x, y, w, h}, target_language?, ocr_provider_id?, llm_provider_id?}. Pre-allocates a region_translations row, enqueues a translate_region job. Rate-limited per session (debounce) and per share (rolling-hour cap).
POST/api/share/documents/{doc_id}/translateDeprecated whole-document variant kept for the e2e test only; the doctor UI no longer exposes a button.
GET/api/share/documents/{doc_id}/region-translations/{region_id}/thumbnailCropped PNG thumbnail of a region translation.
MethodPathAuthDescription
GET/api/vault/treeYesGet the vault directory tree (filtered by user scope)
DELETE/api/vault/fileYesDelete a file on disk and its matching documents row
POST/api/vault/moveYesMove a file or directory and atomically rewrite documents.file_path, imaging_studies.folder_path, and imaging_series.folder_path so the document reference stays intact. Used by the Move action in the file browser to fix files that landed in the wrong folder.