Uploading Patient Documents
Uploading Patient Documents
This guide walks you through the full lifecycle of a patient prontuário (medical record) document in DELPHOS: how to upload it, how it is processed asynchronously (OCR text extraction followed by automatic classification), how to poll for completion, how to list and remove documents afterward, and how an uploaded document ultimately enriches the patient’s clinical context through the Knowledge Graph.
Every endpoint on this page requires the x-api-key header and is
tenant-scoped: you only ever see and modify documents that belong to your
own API key. See Authentication & API Keys
for how keys work.
Lifecycle at a glance
-
Upload the file with
POST /v1/patients/{patient_id}/prontuario-documents. The API responds202 Acceptedimmediately — it does not wait for processing. -
OCR runs in the background. Text is extracted by DELPHOS Inteligência Documental.
-
Classification runs next (unless you supplied a
document_type). The extracted text is classified into one of the supported categories. -
Poll the patient’s document list to watch
ocr_document.statusand the document’s classification progress until processing is complete. -
The extracted text feeds the Knowledge Graph, which is what later powers patient context, clinical summaries, and lab-result trends.
Endpoints
| Method | Path | Status | Description |
|---|---|---|---|
POST | /v1/patients/{patient_id}/prontuario-documents | 202 | Upload a document (multipart) |
GET | /v1/patients/{patient_id}/prontuario-documents/{document_id} | 200 | Poll one document’s status (OCR + classification) |
DELETE | /v1/patients/{patient_id}/prontuario-documents/{document_id} | 204 | Soft-delete a document (is_active=false) |
GET | /v1/patients/{patient_id}/documents | 200 | List a patient’s documents (status included per document) |
POST | /v1/patients/{patient_id}/documents | 201 | Link an already-stored OCR document to a patient |
GET | /v1/patients/{patient_id}/lab-trends | 200 | Lab-result trends derived from processed documents |
Authentication
Pass your API key in the x-api-key header on every request. The key is bound
to a single tenant; all document operations are automatically filtered to that
tenant by row-level security.
curl "https://your-instance.delphos.app/v1/patients" \ -H "x-api-key: YOUR_API_KEY"A missing or invalid key returns 401.
The patient_id path parameter
The {patient_id} segment accepts two identifier shapes, and you can use
either interchangeably:
| Shape | Example | Notes |
|---|---|---|
| Tenant-scoped opaque token | pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp | Recommended. A pat_ prefix followed by 22 characters. |
| Internal DELPHOS UUID | 123e4567-e89b-12d3-a456-426614174000 | Accepted for backward compatibility; deprecated. |
DELPHOS resolves the value you send to the internal patient record before the handler runs, under your tenant’s isolation context. The behaviour is identical regardless of which shape you use.
Upload a document
POST /v1/patients/{patient_id}/prontuario-documentsThis endpoint accepts multipart/form-data (not JSON). It stores the file,
creates the document records, and returns 202 Accepted while OCR and
classification continue in the background.
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
file | file | Yes | The document binary. Supported types: PDF, PNG, JPG, DICOM. |
title | string | No | Human-readable title (max 500 characters). |
notes | string | No | Free-text notes about the document (max 2000 characters). |
document_type | string | No | Manual type override. When provided, automatic classification is skipped and the document is assigned this type immediately. See the enum below. |
Supported file types and size limits
| Type | MIME | Max size | OCR + classification? |
|---|---|---|---|
application/pdf | 40 MB | Yes | |
| PNG | image/png | 20 MB | Yes |
| JPG | image/jpeg (image/jpg also accepted) | 20 MB | Yes |
| DICOM | application/dicom | 20 MB | No — DICOM cannot be OCR’d, so classification is skipped (status skipped). |
The document_type enum
If you omit document_type, DELPHOS classifies the document automatically after
OCR. If you supply it, it must be one of these nine values (any other value
returns 400):
| Value | Meaning |
|---|---|
clinical_note | Clinical note / evolution note |
consent_form | Patient consent form (termo de consentimento) |
exam_result | Exam or diagnostic result |
imaging | Imaging study (X-ray, MRI, CT, ultrasound) |
insurance_doc | Insurance / convênio documentation |
lab_report | Laboratory report |
other | Unclassified / catch-all |
prescription | Prescription document |
referral | Referral letter |
The 202 response body
On success you receive 202 Accepted with this body (all fields are
snake_case):
| Field | Type | Description |
|---|---|---|
document_id | UUID | ID of the underlying OCR document record. |
patient_document_id | UUID | ID of the patient ↔ document link. |
patient_id | UUID | The resolved internal patient UUID. |
document_type | string | Current type. Provisional (other) when auto-classifying; may change once classification completes. |
classification_status | string | One of pending, completed, failed, skipped. At upload time this is pending (auto) or skipped (manual type / DICOM). |
original_filename | string | The filename you uploaded. |
file_size_bytes | integer | Stored file size in bytes. |
ocr_status | string | One of pending, processing, completed, failed. Always pending at upload time. |
created_at | datetime | Creation timestamp (UTC). |
message | string | Informative status message. |
{ "document_id": "9b2c1d3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e", "patient_document_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "patient_id": "123e4567-e89b-12d3-a456-426614174000", "document_type": "other", "classification_status": "pending", "original_filename": "hemograma.pdf", "file_size_bytes": 248193, "ocr_status": "pending", "created_at": "2026-06-03T14:21:09.482Z", "message": "Documento recebido. OCR e classificação em processamento."}Upload example
curl -X POST \ "https://your-instance.delphos.app/v1/patients/pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/prontuario-documents" \ -H "x-api-key: YOUR_API_KEY" \ -F "file=@hemograma.pdf;type=application/pdf" \ -F "title=Hemograma Completo — 2026-06-03" \ -F "notes=Coleta em jejum"import httpx
with open("hemograma.pdf", "rb") as fh: response = httpx.post( "https://your-instance.delphos.app/v1/patients/" "pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/prontuario-documents", headers={"x-api-key": "YOUR_API_KEY"}, files={"file": ("hemograma.pdf", fh, "application/pdf")}, data={ "title": "Hemograma Completo — 2026-06-03", "notes": "Coleta em jejum", # Omit "document_type" to let DELPHOS classify automatically, # or set it (e.g. "lab_report") to skip classification. }, )
result = response.json()document_id = result["document_id"]patient_document_id = result["patient_document_id"]Document lifecycle & status values
A document moves through two independent status tracks after upload: OCR
status and classification status. Both start at pending and are updated by the
background pipeline.
ocr_status
| Value | Meaning |
|---|---|
pending | Accepted, OCR not started yet. |
processing | OCR is running. |
completed | Text extracted successfully — the document is OCR-ready. |
failed | OCR could not extract text (e.g. unreadable scan, or an unsupported binary such as DICOM). |
classification_status
| Value | Meaning |
|---|---|
pending | Awaiting automatic classification (OCR must finish first). |
completed | The document was automatically classified; document_type now holds the final category. |
failed | Classification could not run — typically because OCR returned no text. |
skipped | Classification was intentionally not run: you supplied a document_type, or the file is a type that cannot be OCR’d (DICOM). |
Typical transitions
ocr_status: pending → processing → completedclassification_status: pending ───────────────→ completedIf OCR fails, classification cannot proceed:
ocr_status: pending → processing → failedclassification_status: pending ───────────────→ failedIf you supplied a document_type (or uploaded a DICOM):
ocr_status: pending → processing → completed (DICOM: → skipped)classification_status: skipped (unchanged)Poll for completion
DELPHOS does not push a webhook when processing finishes. Poll until
ocr_status and classification_status leave the pending / processing
states.
Recommended: poll the single document
Use the document_id from the upload response to poll exactly the document you
uploaded. This is the simplest, lowest-overhead way to track one upload.
GET /v1/patients/{patient_id}/prontuario-documents/{document_id}Returns 404 if no active document with that id exists for the patient. On
success the body carries both statuses directly:
| Field | Type | Description |
|---|---|---|
document_id | UUID | The OCR document id you polled with. |
patient_document_id | UUID | The patient ↔ document link id. |
patient_id | UUID | The patient UUID. |
document_type | string | Current type (classifier’s result once complete, or your manual value). |
ocr_status | string | pending → processing → completed | failed | skipped. |
classification_status | string | pending → completed | failed, or skipped. |
classification_confidence | number or null | Classifier confidence (0–1) once classification completes. |
original_filename | string or null | Original upload filename. |
file_size_bytes | integer or null | Stored size. |
page_count | integer or null | Pages detected during OCR. |
ocr_error | string or null | Error message if OCR failed. |
created_at / updated_at / processed_at | datetime or null | Timestamps (processed_at is null until processing finishes). |
The document is fully ready when ocr_status is completed (or skipped
for non-OCR formats) and classification_status is completed or skipped.
curl "https://your-instance.delphos.app/v1/patients/pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/prontuario-documents/aa5d11cb-4b8f-4da4-99cb-4e105708fa05" \ -H "x-api-key: YOUR_API_KEY"Bulk: poll the patient’s document list
To reconcile many documents at once, list them — each item now carries
classification_status and classification_confidence alongside the OCR
ocr_document.status.
GET /v1/patients/{patient_id}/documentsQuery parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Page size (1–100). |
offset | integer | 0 | Pagination offset. |
document_type | string | — | Filter by document type. |
date_from | datetime | — | Only documents created on/after this date. |
date_to | datetime | — | Only documents created on/before this date. |
Response
| Field | Type | Description |
|---|---|---|
documents | array | Page of patient-document records (see below). |
total | integer | Total matching documents. |
limit | integer | Echo of the requested page size. |
offset | integer | Echo of the requested offset. |
Each item in documents:
| Field | Type | Description |
|---|---|---|
id | UUID | The patient ↔ document link ID (equals patient_document_id from upload). |
patient_id | UUID | The patient UUID. |
document_id | UUID | The OCR document ID (equals document_id from upload). |
document_type | string | Current type (final value once classification completes). |
title | string or null | Title you supplied at upload. |
notes | string or null | Notes you supplied at upload. |
uploaded_by | UUID or null | The key/user that created the link. |
is_active | boolean | false after a soft-delete. |
classification_status | string or null | pending | completed | failed | skipped. |
classification_confidence | number or null | Classifier confidence (0–1) once complete. |
created_at | datetime | Creation timestamp (UTC). |
updated_at | datetime | Last update timestamp (UTC). |
ocr_document | object or null | Lightweight OCR metadata (see below). |
ocr_document summary (the OCR text itself is never returned here):
| Field | Type | Description |
|---|---|---|
original_filename | string or null | Original filename. |
mime_type | string or null | Stored MIME type. |
status | string or null | The ocr_status value — poll this. |
page_count | integer or null | Page count, when known. |
Polling example
curl "https://your-instance.delphos.app/v1/patients/pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/documents?limit=20" \ -H "x-api-key: YOUR_API_KEY"import timeimport httpx
BASE = "https://your-instance.delphos.app/v1"HEADERS = {"x-api-key": "YOUR_API_KEY"}PATIENT = "pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp"
def find_document(patient_document_id: str) -> dict | None: """Locate one document by its link id in the patient's document list.""" page = httpx.get( f"{BASE}/patients/{PATIENT}/documents", headers=HEADERS, params={"limit": 100}, ).json() for doc in page["documents"]: if doc["id"] == patient_document_id: return doc return None
def wait_until_processed(patient_document_id: str, timeout_s: int = 120) -> dict: """Poll until OCR + classification settle, or raise on timeout.""" deadline = time.time() + timeout_s while time.time() < deadline: doc = find_document(patient_document_id) if doc is None: raise RuntimeError("Document not found (not_found)")
ocr = (doc.get("ocr_document") or {}).get("status") classification = doc.get("classification_status") # Both statuses are now in the list payload. The document is settled # once OCR is terminal and classification is terminal/skipped. if ocr in ("completed", "failed", "skipped") and classification in ( "completed", "failed", "skipped", None, ): return doc
time.sleep(3) # back off between polls
raise TimeoutError("Document still processing after timeout")Managing documents afterward
List a patient’s documents
Covered above under Poll for completion. The same
endpoint is your general-purpose “list everything for this patient” call —
filter with document_type, date_from, and date_to, and page with limit /
offset.
Link an existing OCR document
POST /v1/patients/{patient_id}/documentsUse this when an OCR document already exists and you want to associate it
with a patient (the upload endpoint already does this for you on new uploads).
It is idempotent: linking the same document_id to the same patient twice
returns the existing link rather than erroring.
Request body (application/json):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
document_id | UUID | Yes | — | An existing OCR document to link. |
document_type | string | No | other | One of the document types below. |
title | string | No | null | Max 500 characters. |
notes | string | No | null | Free-text notes. |
Returns 201 with a single patient-document record (same shape as a list
item). Returns 404 if the patient does not resolve for your tenant.
Removing a document
Removal is a soft-delete — the record’s is_active flag is set to false
and the stored file and OCR record are retained for audit; nothing is
hard-deleted. Use the document_id from the upload response.
DELETE /v1/patients/{patient_id}/prontuario-documents/{document_id}204 No Content— the document was soft-deleted.404 Not Found— no active document with that id exists for the patient (including a document you already deleted — the operation is idempotent in effect: the resource is gone either way).
Soft-deleted documents are excluded from GET /v1/patients/{patient_id}/documents
by default (it returns only is_active=true records).
curl -X DELETE \ "https://your-instance.delphos.app/v1/patients/pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/prontuario-documents/aa5d11cb-4b8f-4da4-99cb-4e105708fa05" \ -H "x-api-key: YOUR_API_KEY"How a document reaches the Knowledge Graph
Uploading a document is not just storage — it is the entry point into DELPHOS’s clinical intelligence layer.
-
OCR. After upload, DELPHOS Inteligência Documental extracts the full text of the document. On success
ocr_statusbecomescompleted. -
Classification. The extracted text is classified into a
document_type(unless you supplied one). -
Knowledge Graph enrichment. Extracted clinical text is processed asynchronously by the Knowledge Graph pipeline, which uses the DELPHOS language model to identify entities (conditions, medications, allergies, lab results, procedures, symptoms, anthropometric measurements, and more) and the relationships between them, then generates semantic embeddings for retrieval.
Once a document’s data is in the Knowledge Graph, it surfaces through other DELPHOS endpoints:
| Surface | What it returns |
|---|---|
GET /v1/patients/{patient_id}/context | Aggregated clinical context — allergies, conditions, medications, lifestyle, social factors, recent consultations, recent lab alerts. |
POST /v1/patients/{patient_id}/summary | An LLM-generated clinical summary built from the graph. |
GET /v1/patients/{patient_id}/lab-trends | Time-series lab-result trends (see below). |
Lab-result trends
GET /v1/patients/{patient_id}/lab-trendsLab values extracted from uploaded reports become lab_result entities in the
Knowledge Graph, and this endpoint groups them by marker for charting.
| Parameter | Type | Default | Description |
|---|---|---|---|
markers | string | — | Comma-separated marker names to filter. Empty tokens are ignored. |
period | integer | 12 | Look-back window in months (1–120). |
Response:
| Field | Type | Description |
|---|---|---|
patient_id | UUID | The patient. |
period_months | integer | Echo of the requested window. |
markers | array | One series per marker (name, unit, trend, reference range, data points). |
total_results | integer | Total lab data points considered. |
curl "https://your-instance.delphos.app/v1/patients/pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/lab-trends?markers=Hemoglobina,Glicose&period=12" \ -H "x-api-key: YOUR_API_KEY"import httpx
response = httpx.get( "https://your-instance.delphos.app/v1/patients/" "pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp/lab-trends", headers={"x-api-key": "YOUR_API_KEY"}, params={"markers": "Hemoglobina,Glicose", "period": 12},)trends = response.json()for series in trends["markers"]: print(series["marker_name"], series["trend"])Returns 422 if you request too many markers or pass invalid parameters,
and 404 if the patient does not resolve for your tenant.
Error responses
| Code | When | Body / notes |
|---|---|---|
400 | Empty file | { "detail": "Arquivo vazio." } |
400 | Unsupported file type | { "detail": "Tipo de arquivo não suportado: <mime>. Tipos aceitos: PDF, PNG, JPG, DICOM." } |
400 | File too large | { "detail": "Arquivo excede o limite de <N> MB." } (40 MB for PDF, 20 MB otherwise) |
400 | Invalid document_type | { "detail": "Tipo de documento inválido: '<value>'. Tipos válidos: [...]" } |
401 | Missing or invalid x-api-key | Authentication failed. |
413 | File too large (transport-level) | The request body exceeded the accepted size before validation. Honor the size limits above. |
404 | Patient not found for your tenant | Uniform "Not found" body — same for a nonexistent patient and a cross-tenant one. |
422 | Invalid query/body parameters | The body lists the offending fields (e.g. on lab-trends). |
500 | Unexpected server error | Uniform internal-error body; no SQL or internal details are leaked. |
End-to-end example (happy path)
Upload a lab report, poll until OCR completes, then read the resulting lab trends.
import timeimport httpx
BASE = "https://your-instance.delphos.app/v1"HEADERS = {"x-api-key": "YOUR_API_KEY"}PATIENT = "pat_3Fq9Lm2Xb7Kd1Rs8Tn0Wp"
# 1) Upload — returns 202 immediatelywith open("hemograma.pdf", "rb") as fh: upload = httpx.post( f"{BASE}/patients/{PATIENT}/prontuario-documents", headers=HEADERS, files={"file": ("hemograma.pdf", fh, "application/pdf")}, data={"title": "Hemograma Completo — 2026-06-03"}, # document_type omitted -> automatic classification )upload.raise_for_status() # 202body = upload.json()patient_document_id = body["patient_document_id"]print("accepted:", body["ocr_status"], body["classification_status"])
# 2) Poll the document list until OCR settlesdef current(doc_id): page = httpx.get( f"{BASE}/patients/{PATIENT}/documents", headers=HEADERS, params={"limit": 100}, ).json() return next((d for d in page["documents"] if d["id"] == doc_id), None)
deadline = time.time() + 120while time.time() < deadline: doc = current(patient_document_id) if doc is None: raise RuntimeError("not_found") ocr_status = (doc.get("ocr_document") or {}).get("status") if ocr_status == "completed": print("processed; final type:", doc["document_type"]) break if ocr_status == "failed": raise RuntimeError("OCR failed for this document") time.sleep(3)else: raise TimeoutError("still processing")
# 3) Read graph-derived lab trends (available after processing)trends = httpx.get( f"{BASE}/patients/{PATIENT}/lab-trends", headers=HEADERS, params={"period": 12},).json()print("markers tracked:", [s["marker_name"] for s in trends["markers"]])