> For the complete documentation index, see [llms.txt](https://docs.bloodgpt.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.bloodgpt.com/submitting-tests/upload.md).

# Upload API

Upload medical documents and extract structured data (JSON or FHIR R4) without PDF report generation

{% hint style="info" %}
`/api/v1/upload` is the canonical door for files. It accepts one or more PDF/image files, runs image-first vision-model recognition, and persists the result as FHIR. Unlike `/process`, it returns raw extracted data (parameters, diagnoses, medications) in JSON or FHIR R4 format — no PDF report is generated.
{% endhint %}

## Overview

Use `/api/v1/upload` when you need structured data extraction rather than a formatted PDF report:

| Feature                 | `/api/v1/process`      | `/api/v1/upload`                                          |
| ----------------------- | ---------------------- | --------------------------------------------------------- |
| **Input**               | JSON, HL7              | PDF, JPEG, PNG (one or more files)                        |
| **Output**              | PDF report (via CDN)   | JSON or FHIR R4 Bundle                                    |
| **Parameters**          | Included in PDF        | Structured with LOINC codes                               |
| **Medical context**     | Included in PDF        | Diagnoses, medications, procedures, symptoms, vital signs |
| **Content negotiation** | No                     | Yes (`Accept` header)                                     |
| **Use case**            | Patient-facing reports | Data integration, EHR import                              |

## Flow Diagram

Recognition and interpretation are two separate calls — see [Recognition and interpretation are two calls](#recognition-and-interpretation-are-two-calls). Steps 3 and 4 are optional: stop after step 2 if you only need the extracted values.

```mermaid
sequenceDiagram
    participant Client
    participant BloodGPT API

    Client->>BloodGPT API: 1. POST /api/v1/upload (file)
    activate BloodGPT API
    BloodGPT API-->>Client: 202 Accepted (test_id, recognize_processing)
    deactivate BloodGPT API

    Note right of BloodGPT API: Recognition pipeline (up to 30 min)

    Client->>BloodGPT API: 2. GET /api/v1/upload/{test_id}
    BloodGPT API-->>Client: 200 {status: "completed", parameters}

    Client->>BloodGPT API: 3. POST /api/v1/interpret/{test_id}
    activate BloodGPT API
    BloodGPT API-->>Client: 202 Accepted (waiting_for_analysis)
    deactivate BloodGPT API

    Note right of BloodGPT API: Interpretation pipeline

    Client->>BloodGPT API: 4. GET /api/v1/upload/{test_id}
    BloodGPT API-->>Client: 200 {status: "completed"}
```

{% hint style="info" %}
**Recognition is cross-checked automatically.** PDFs are converted to page images, the pages are batched with overlap, and each batch is recognized three times in parallel; a judge model reconciles the attempts before results are returned — you don't trigger this. The pipeline passes through several internal phases while it runs; the status endpoint reports all of them as `processing`.
{% endhint %}

***

## Accepted File Formats

Binary formats are recognized by their **magic bytes** — the declared filename and `Content-Type` of the part are not trusted. Text formats carry no signature, so they are accepted by their declared file extension. Anything else is rejected with `415` before the request writes any data.

| Format     | How it is accepted          | Notes                                      |
| ---------- | --------------------------- | ------------------------------------------ |
| PDF        | Magic bytes (`%PDF`)        | Converted to page images by the pipeline   |
| JPEG       | Magic bytes (`FF D8 FF`)    |                                            |
| PNG        | Magic bytes (`89 50 4E 47`) |                                            |
| JSON       | Extension `.json`           |                                            |
| HL7        | Extension `.hl7`            | See [HL7 Format](/submitting-tests/hl7.md) |
| Plain text | Extension `.txt`            |                                            |

{% hint style="warning" %}
**HEIC/HEIF is not accepted.** iPhone photos must be converted to JPEG before upload — the API answers `415 unsupported_media_type` with a message asking for the conversion. Browser file pickers in our own dashboards convert automatically; server-to-server integrations must convert themselves.
{% endhint %}

Per-request limits:

| Limit              | Value | Error when exceeded       |
| ------------------ | ----- | ------------------------- |
| Files per request  | 20    | `422 too_many_files`      |
| Total request size | 40 MB | `422 total_size_exceeded` |

***

## Recognition and interpretation are two calls

{% hint style="warning" %}
**Uploading a document does not analyse it.** `/api/v1/upload` runs *recognition* — it extracts the parameters, normalizes them, and writes them to FHIR. It does not produce per-parameter interpretation, a test overview, or follow-up recommendations. Those come from a second, explicit call to [`POST /api/v1/interpret/{testId}`](/interpretation-and-results/interpret.md#interpret-a-single-test).

A test that reports `completed` has finished **recognition**. That is a terminal, correct state — reached whether or not an interpretation is ever requested.
{% endhint %}

The full lifecycle is the four steps in the [flow diagram](#flow-diagram) above: upload, poll for recognition, request the interpretation, poll for the interpretation.

If you only need the extracted values — to store them, chart them, or feed your own logic — stop after step 2. Steps 3 and 4 are what produce BloodGPT's analysis.

Interpreting a **whole patient** rather than one document is a different endpoint, [`POST /api/v1/summary`](/interpretation-and-results/interpret.md#generate-a-patient-summary). Choose by intent: one report per uploaded document, or one report per patient across their whole record.

***

## Step 1 — Submit Files for Recognition

**Endpoint:** `POST /api/v1/upload`

### Request

Submit using `Content-Type: multipart/form-data`.

Headers:

```
Content-Type: multipart/form-data
Authorization: Bearer YOUR_API_KEY
```

Request Body (multipart/form-data):

* **file** or **files:** one or more files (both field names are accepted, and may be combined)
* **data:** JSON string with request metadata

```json
{
  "external_id": "lab-report-2025-001",
  "patient_id": "patient-123"
}
```

| Field          | Type   | Required | Description                                                                                                                  |
| -------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `external_id`  | string | Yes      | Your unique identifier for this request                                                                                      |
| `patient_id`   | string | No       | Patient identifier for tracking and de-duplication. Omitted or blank means the result is not attached to a patient           |
| `patient_info` | object | No       | Optional patient demographics: `name`, `gender`, `birth_date`, `weight` (see [Processing API](/submitting-tests/process.md)) |

### cURL Example

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/upload \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: multipart/form-data" \
  -F file=@/path/to/blood-test.pdf \
  -F 'data={"external_id":"lab-report-2025-001","patient_id":"patient-123"}'
```

Multiple files in one request:

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/upload \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F files=@/path/to/page-1.jpg \
  -F files=@/path/to/page-2.jpg \
  -F 'data={"external_id":"lab-report-2025-002","patient_id":"patient-123"}'
```

### Response (202 Accepted)

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "status": "recognize_processing"
}
```

| Field        | Type   | Description                                        |
| ------------ | ------ | -------------------------------------------------- |
| `request_id` | string | Unique request identifier for tracing              |
| `test_id`    | string | BloodTest record ID — use this to poll for results |
| `status`     | string | Always `"recognize_processing"`                    |

Keep the `test_id` — every later step is addressed by it.

### Error Responses

`/api/v1/upload` follows the standard [error contract](/reference/errors.md) — `{ "error": "<code>", "message": "<human-readable>" }`.

<details>

<summary>400 Bad Request</summary>

```json
{
  "error": "invalid_json",
  "message": "Invalid JSON format in data field"
}
```

</details>

<details>

<summary>415 Unsupported Media Type</summary>

The request itself was not multipart:

```json
{
  "error": "unsupported_content_type",
  "message": "Content-Type must be multipart/form-data"
}
```

Or one of the uploaded files is not in an accepted format:

```json
{
  "error": "unsupported_media_type",
  "message": "File \"scan.tiff\" is not in an accepted format. Accepted formats: PDF, JPEG, PNG, JSON, HL7, TXT. HEIC/HEIF must be converted to JPEG before uploading."
}
```

HEIC/HEIF gets its own message:

```json
{
  "error": "unsupported_media_type",
  "message": "HEIC/HEIF files are not accepted. Convert the photo to JPEG before uploading (iPhone photos are converted automatically by the browser file picker)."
}
```

</details>

<details>

<summary>422 Unprocessable Entity</summary>

```json
{
  "error": "missing_file",
  "message": "No file was provided. Use 'file' or 'files' field in multipart/form-data"
}
```

or

```json
{
  "error": "missing_data",
  "message": "data field is required with format: {\"external_id\":\"...\",\"patient_id\":\"...\"}"
}
```

or

```json
{
  "error": "missing_external_id",
  "message": "external_id is required"
}
```

Request-shape limits use the same status:

```json
{
  "error": "too_many_files",
  "message": "Maximum 20 files allowed per request"
}
```

```json
{
  "error": "total_size_exceeded",
  "message": "Total upload size exceeds 40MB limit"
}
```

</details>

<details>

<summary>422 Duplicate file</summary>

The same file cannot be submitted twice for the same patient. Duplicate detection is content-based (SHA-256), so re-uploading identical bytes is rejected:

```json
{
  "error": "duplicate_file_previous",
  "message": "File \"blood-test.pdf\" has already been uploaded in a previous test (test_id: clxyz789012)"
}
```

If the same file appears more than once within a single multi-file request, the error code is `duplicate_file_in_batch` instead.

Note the scope: the check against **previous** uploads is per patient, so it only applies when you send a `patient_id`. Duplicate detection *within* one request always applies.

</details>

<details>

<summary>500 Internal Server Error</summary>

```json
{
  "error": "internal_server_error",
  "message": "An unexpected error occurred while processing your request"
}
```

</details>

{% hint style="info" %}
`duplicate_file_previous` names the test the file already became: `File "report.pdf" has already been uploaded in a previous test (test_id: clxyz789012)`. If you have lost track of a `test_id`, re-submitting the file recovers it from that message instead of creating a second test.
{% endhint %}

***

## Step 2 — Poll for Recognition Results

**Endpoint:** `GET /api/v1/upload/{testId}`

Poll this endpoint to check processing status and retrieve results. The response format depends on the `Accept` header.

### Content Negotiation

| Accept Header                | Response Format                 |
| ---------------------------- | ------------------------------- |
| `application/json` (default) | Flat JSON with categorized data |
| `application/fhir+json`      | FHIR R4 Bundle                  |

### Processing Statuses

This one endpoint reports both the recognition and the interpretation phase.

| Status                  | Description                                                                                                                              | `parameters` present? |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `processing`            | Recognition pipeline is still running                                                                                                    | No                    |
| `completed`             | Recognition finished — parameters are final                                                                                              | Yes                   |
| `analyzing`             | An interpretation requested via `POST /api/v1/interpret/{testId}` is running                                                             | Yes                   |
| `pending_doctor_review` | The interpretation is written and waiting for a doctor to approve it (see [Doctor Validation](/integration-guides/doctor-validation.md)) | Yes                   |
| `failed`                | The run ended without a result, or the document was refused as non-medical — check the `error` field                                     | No                    |

{% hint style="warning" %}
**`completed` appears twice in the lifecycle** — once when recognition finishes, and again when an interpretation finishes. The status alone does not tell you which one you are looking at. Track what you are waiting for on your side: after step 1 poll until the status leaves `processing`; after step 3 poll until it leaves `analyzing`.
{% endhint %}

### Processing Response

While the pipeline is running:

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "external_id": "lab-report-2025-001",
  "status": "processing"
}
```

### Failed Response

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "external_id": "lab-report-2025-001",
  "status": "failed",
  "error": "Unable to extract data from the provided file"
}
```

### Retrying a failed recognition

**Endpoint:** `POST /api/v1/upload/{testId}/retry`

A `failed` recognition can be re-run on the existing test — the uploaded file is still in storage, so there is nothing to send again:

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/upload/clxyz789012/retry \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "status": "recognize_processing",
  "message": "Recognition pipeline retry started"
}
```

The call is idempotent. Only a test that actually failed is restarted; retrying one that is already running or already past recognition is a no-op that answers `202` with the test's current status, so a double-click cannot start two runs.

| Status | `error`            | Meaning                                                               |
| ------ | ------------------ | --------------------------------------------------------------------- |
| `409`  | `no_file_to_retry` | The test has no stored file to re-process — upload the document again |
| `404`  | `test_not_found`   | No such test in your organization                                     |

***

## Step 3 — Request the Interpretation

**Endpoint:** `POST /api/v1/interpret/{testId}`

Once the test reports `completed`, ask for the analysis. The body is optional; see [Interpret & Summary](/interpretation-and-results/interpret.md#interpret-a-single-test) for the full reference.

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/interpret/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"processing_options": {"target_language": "en"}}'
```

### Response (202 Accepted)

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "status": "waiting_for_analysis",
  "message": "Interpretation pipeline started"
}
```

`status` is `waiting_for_analysis` for a test that carries a `patient_id` — those are queued so one patient's tests are interpreted one at a time — and `interpreting` for an unattached test.

### Errors

| Status | `error`             | Meaning                                                                 | Retry?                    |
| ------ | ------------------- | ----------------------------------------------------------------------- | ------------------------- |
| `409`  | `invalid_status`    | The test has not reached a state that can be interpreted                | Yes — after a short delay |
| `409`  | `missing_fhir_data` | Recognition finished but the test's FHIR references are not written yet | Yes — after a short delay |
| `404`  | `test_not_found`    | No such test in your organization                                       | No                        |

{% hint style="info" %}
Both `409`s are expected if you call `/interpret` the moment the status turns `completed` — the recognition write is still landing. They clear on their own, so treat them as "not ready yet" and retry with backoff rather than as a failure.
{% endhint %}

### Loading several documents for one patient

Interpretation reads the patient's existing observations out of FHIR to produce trends. When you are loading a patient's history, **recognize all of their documents first, then interpret them oldest first.** Interpreting a 2023 report before a 2024 one has been recognized analyses it against history that has not been ingested yet.

***

## Step 4 — Poll for the Interpretation

Poll `GET /api/v1/upload/{testId}` again. The status moves `analyzing` → `completed`, or `analyzing` → `pending_doctor_review` if your organization has [Doctor Validation](/integration-guides/doctor-validation.md) enabled — in which case the analysis is written but waits for a doctor to approve it before it is final, and the webhook fires only after that approval.

The recognized `parameters` stay in the response throughout, so an interpretation that is still running never costs you the values you already had.

***

## JSON Response Format

**Request:** `GET /api/v1/upload/{testId}` with `Accept: application/json`

```bash
curl https://sandbox.bloodgpt.com/api/v1/upload/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json"
```

### Example Response (Discharge Summary)

A document with medical context (discharge summary, referral letter, etc.) produces a rich response with patient demographics, encounter details, diagnoses, medications, procedures, and more:

```json
{
  "request_id": "req_abc123",
  "test_id": "clxyz789012",
  "external_id": "lab-report-2025-001",
  "status": "completed",
  "document_date": "2024-12-10",
  "source_format": "pdf",
  "patient": {
    "name": "Sarah Michelle Johnson",
    "birth_date": "1985-03-15",
    "gender": "female",
    "mrn": "SMH-2024-008912"
  },
  "encounter": {
    "status": "finished",
    "class": "emergency",
    "period_start": "2024-12-10T00:00:00Z",
    "period_end": "2024-12-12T00:00:00Z",
    "reason": "Acute appendicitis with perforation",
    "discharge_disposition": "Home"
  },
  "practitioners": [
    {
      "name": "Dr. Michael Williams",
      "qualification": "Doctor of Medicine"
    },
    {
      "name": "Dr. Sarah Lee",
      "qualification": "Doctor of Medicine"
    }
  ],
  "parameters": [
    {
      "name": "Glucose",
      "normalized_name": "Glucose [Mass/volume] in Serum or Plasma",
      "value": 144,
      "unit": "mg/dL",
      "reference_range": "70-110",
      "reference_min": 70,
      "reference_max": 110,
      "status": "high",
      "loinc": { "code": "2345-7", "long_common_name": "Glucose [Mass/volume] in Serum or Plasma" },
      "group": "Metabolic"
    },
    {
      "name": "Glucose",
      "normalized_name": "Glucose [Mass/volume] in Serum or Plasma",
      "value": 98,
      "unit": "mg/dL",
      "reference_range": "70-110",
      "reference_min": 70,
      "reference_max": 110,
      "status": "normal",
      "loinc": { "code": "2345-7", "long_common_name": "Glucose [Mass/volume] in Serum or Plasma" },
      "group": "Metabolic"
    }
  ],
  "tests": [
    {
      "test_date": "2024-12-10",
      "diagnostic_report_id": "b8f3c1a2-0f4d-4a1e-9d33-5c6f0b1d7e21",
      "parameters": [
        {
          "name": "Glucose",
          "value": 144,
          "unit": "mg/dL",
          "status": "high",
          "loinc": { "code": "2345-7", "long_common_name": "Glucose [Mass/volume] in Serum or Plasma" }
        }
      ]
    },
    {
      "test_date": "2024-12-12",
      "diagnostic_report_id": "d1a7e5b9-6c2f-42ab-8e10-9f43b8c2a705",
      "parameters": [
        {
          "name": "Glucose",
          "value": 98,
          "unit": "mg/dL",
          "status": "normal",
          "loinc": { "code": "2345-7", "long_common_name": "Glucose [Mass/volume] in Serum or Plasma" }
        }
      ]
    }
  ],
  "diagnoses": [
    {
      "name": "Acute appendicitis with perforation and localized abscess",
      "status": "resolved",
      "onset": null,
      "icd10_code": "K35.3",
      "snomed_code": null,
      "snomed_display": null,
      "notes": []
    },
    {
      "name": "Hypertension",
      "status": "active",
      "onset": null,
      "icd10_code": "I10",
      "snomed_code": "38341003",
      "snomed_display": "Hypertensive disorder",
      "notes": []
    }
  ],
  "medications": [
    {
      "name": "Oxycodone-acetaminophen",
      "dosage": "5/325mg Every 6 hours as needed",
      "status": "active",
      "intent": "order",
      "rxnorm_code": "82063",
      "snomed_code": null,
      "snomed_display": null,
      "reason": "Pain"
    },
    {
      "name": "Lisinopril",
      "dosage": "10mg Once daily",
      "status": "active",
      "intent": null,
      "rxnorm_code": "29046",
      "snomed_code": "386873009",
      "snomed_display": "Lisinopril",
      "reason": "Blood pressure"
    }
  ],
  "procedures": [
    {
      "name": "Laparoscopic appendectomy with abscess drainage",
      "date": "2024-12-11T00:00:00Z",
      "status": "completed",
      "cpt_code": "44970",
      "snomed_code": null,
      "snomed_display": null
    }
  ],
  "symptoms": [
    {
      "name": "Abdominal pain",
      "snomed_code": "21522001",
      "snomed_display": "Abdominal pain",
      "duration": "24 hours"
    }
  ],
  "vital_signs": [
    {
      "name": "Body temperature",
      "snomed_code": null,
      "snomed_display": null,
      "value": 100.8,
      "unit": "°F"
    }
  ],
  "narrative_blocks": [
    {
      "pageNumber": 1,
      "realPageNumber": 1,
      "contentType": "anamnesis",
      "text": "Patient is a 39-year-old female who presented to the emergency department...",
      "date": "2024-12-10"
    },
    {
      "pageNumber": 2,
      "realPageNumber": 2,
      "contentType": "recommendation",
      "text": "Return to clinic on 12/18/2024 at 10:30 AM for post-operative check."
    }
  ],
  "documents": [
    {
      "file_id": "cm9f0k2xy0001v8h0a1b2c3d4",
      "file_name": "discharge-summary.pdf",
      "content_type": "application/pdf",
      "summary": "Discharge summary after laparoscopic appendectomy",
      "lab_name": "St. Mary's Hospital",
      "test_date": "2024-12-10",
      "language": "en",
      "historical": false,
      "document_reference_id": "9c1d5e7f-3b2a-4c88-91de-77a0b4e6f2c3",
      "download_url": "https://storage.googleapis.com/..."
    }
  ]
}
```

### Example Response (Lab Results Only)

A pure lab report (e.g., Quest Diagnostics CBC/CMP) returns parameters without medical context sections:

```json
{
  "request_id": "req_def456",
  "test_id": "clxyz789013",
  "external_id": "quest-lab-2024-001",
  "status": "completed",
  "document_date": "2024-11-02",
  "source_format": "pdf",
  "parameters": [
    {
      "name": "Hemoglobin A1c",
      "normalized_name": "HbA1c",
      "value": 7.2,
      "unit": "%",
      "reference_range": "<5.7%",
      "reference_min": 0,
      "reference_max": 5.7,
      "status": "high",
      "loinc": { "code": "4548-4", "long_common_name": "Hemoglobin A1c/Hemoglobin.total in Blood" },
      "group": "Hemoglobin A1c"
    },
    {
      "name": "Glucose",
      "normalized_name": "Glucose",
      "value": 92,
      "unit": "mg/dL",
      "reference_range": "70-100",
      "reference_min": 70,
      "reference_max": 100,
      "status": "normal",
      "loinc": { "code": "2339-0", "long_common_name": "Glucose [Mass/volume] in Blood" },
      "group": "Comprehensive Metabolic Panel"
    }
  ],
  "narrative_blocks": [],
  "tests": [
    {
      "test_date": null,
      "diagnostic_report_id": null,
      "parameters": [
        { "name": "Hemoglobin A1c", "value": 7.2, "unit": "%", "status": "high" },
        { "name": "Glucose", "value": 92, "unit": "mg/dL", "status": "normal" }
      ]
    }
  ],
  "documents": [
    {
      "file_id": "cm9f0k2xy0002v8h0e5f6g7h8",
      "file_name": "quest-cbc-cmp.pdf",
      "content_type": "application/pdf",
      "summary": "Routine CBC and CMP panel",
      "lab_name": "Quest Diagnostics",
      "test_date": "2024-11-02",
      "language": "en",
      "historical": false,
      "document_reference_id": "4f2b8a10-77c3-4c1d-9b6e-2d0a5f9e1c48",
      "download_url": "https://storage.googleapis.com/..."
    }
  ]
}
```

### Top-level Fields

| Field              | Type           | Always present | Description                                                                                                                                                   |
| ------------------ | -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request_id`       | string         | Yes            | Unique request identifier for tracing                                                                                                                         |
| `test_id`          | string         | Yes            | BloodTest record ID                                                                                                                                           |
| `external_id`      | string         | Yes            | Your unique identifier from the submission                                                                                                                    |
| `status`           | string         | Yes            | `"processing"`, `"completed"`, `"analyzing"`, `"pending_doctor_review"` or `"failed"` — see [Processing Statuses](#processing-statuses)                       |
| `document_date`    | string \| null | No             | Date extracted from the document                                                                                                                              |
| `source_format`    | string \| null | On `completed` | `"pdf"` or `"image"` — which input branch the pipeline took. `null` when recognition did not report it                                                        |
| `parameters`       | array          | No             | Flat list of all extracted lab parameters, across every date in the document                                                                                  |
| `tests`            | array          | On `completed` | The same parameters grouped by measurement date — see [Multi-date documents](#multi-date-documents-and-the-tests-array)                                       |
| `narrative_blocks` | array          | On `completed` | Non-tabular text extracted from the document — see [narrative\_blocks](#narrative_blocks)                                                                     |
| `documents`        | array          | No             | One entry per uploaded file, with a signed download URL — see [documents](#documents). Omitted when the test has no stored files                              |
| `patient`          | object         | No             | Patient demographics (when available in document)                                                                                                             |
| `encounter`        | object         | No             | Encounter/visit details (discharge summaries)                                                                                                                 |
| `practitioners`    | array          | No             | Healthcare providers mentioned in the document                                                                                                                |
| `overview`         | object         | No             | AI interpretation of the test — present only after interpretation has run for this test (see [Interpret & Summary](/interpretation-and-results/interpret.md)) |
| `follow_up`        | object         | No             | Follow-up recommendations — present only after interpretation has run                                                                                         |

`source_format`, `tests`, and `narrative_blocks` are part of the result body only: `processing` and `failed` responses carry just `request_id`, `test_id`, `external_id`, `status` (and `error`).

### Patient Object

| Field        | Type           | Description                                  |
| ------------ | -------------- | -------------------------------------------- |
| `name`       | string \| null | Full patient name                            |
| `birth_date` | string \| null | Date of birth (YYYY-MM-DD)                   |
| `gender`     | string \| null | `"male"`, `"female"`, `"other"`, `"unknown"` |
| `mrn`        | string \| null | Medical Record Number                        |

### Encounter Object

| Field                   | Type           | Description                                              |
| ----------------------- | -------------- | -------------------------------------------------------- |
| `status`                | string \| null | `"finished"`, `"in-progress"`, etc.                      |
| `class`                 | string \| null | Visit type: `"emergency"`, `"inpatient"`, `"outpatient"` |
| `period_start`          | string \| null | Admission date (ISO 8601)                                |
| `period_end`            | string \| null | Discharge date (ISO 8601)                                |
| `reason`                | string \| null | Reason for visit                                         |
| `discharge_disposition` | string \| null | Where patient went after discharge (e.g., `"Home"`)      |

### Practitioner Object

| Field           | Type           | Description          |
| --------------- | -------------- | -------------------- |
| `name`          | string         | Full name with title |
| `qualification` | string \| null | Specialty or degree  |

### Parameter Fields

| Field             | Type                     | Description                                                                        |
| ----------------- | ------------------------ | ---------------------------------------------------------------------------------- |
| `name`            | string                   | Original parameter name as printed on the report                                   |
| `normalized_name` | string \| null           | Standardized English parameter name                                                |
| `value`           | number \| string \| null | Measured value — a number, or a string for qualitative results (e.g. `"Negative"`) |
| `unit`            | string                   | Unit of measurement (may be empty for unitless observations)                       |
| `reference_range` | string \| null           | Original reference range string                                                    |
| `reference_min`   | number \| null           | Parsed minimum of reference range                                                  |
| `reference_max`   | number \| null           | Parsed maximum of reference range                                                  |
| `status`          | string \| null           | `"low"`, `"normal"`, `"high"`, or `null` if range unavailable                      |
| `loinc`           | object \| null           | `{ code, long_common_name }` — LOINC coding                                        |
| `group`           | string \| null           | Panel category (e.g. "Hematology", "Metabolic")                                    |
| `ai_details`      | string                   | Present only after interpretation has run — AI commentary for this parameter       |

### Multi-date documents and the `tests` array

A single uploaded document may contain the same parameter measured on several dates — a discharge summary with "day 1 / day 3 / day 7" columns, a quarterly compilation, or a table with an explicit date column. The pipeline treats every (parameter × date) pair as its own measurement:

* Each distinct measurement date gets **its own `DiagnosticReport`** in FHIR.
* `parameters` stays flat and contains every measurement, so clients that ignore dates keep working.
* `tests[]` groups those same parameter objects by measurement date.

| Field                  | Type           | Description                                                                                      |
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------------ |
| `test_date`            | string \| null | Measurement date (YYYY-MM-DD), or `null` for the bucket of parameters with no per-parameter date |
| `diagnostic_report_id` | string \| null | FHIR `DiagnosticReport.id` for that date. `null` for the `null`-date bucket                      |
| `parameters`           | array          | The parameters measured on that date — same objects as in the flat `parameters` list             |

{% hint style="info" %}
**Single-date documents produce one `tests` entry with `test_date: null`.** Most lab reports are a single snapshot, so recognition deliberately leaves the per-parameter date empty and the document-level date lands in `document_date` instead. In that case `diagnostic_report_id` is `null` too — read the report from the [FHIR Bundle](#fhir-r4-bundle-response) if you need its id.
{% endhint %}

### `narrative_blocks`

Text that is not part of a lab table — diagnoses, medications, allergies, anamnesis, recommendations — is returned as narrative blocks alongside the parameters. The same content also drives the medical-context resources (`diagnoses`, `medications`, `procedures`, …), so blocks are the raw source, not a replacement for those categories.

| Field            | Type   | Description                                                                                                                                                                                        |
| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pageNumber`     | number | Page the block was found on                                                                                                                                                                        |
| `realPageNumber` | number | Page number in the original document                                                                                                                                                               |
| `contentType`    | string | One of `diagnosis`, `medication`, `allergy`, `procedure`, `imaging`, `vital-signs`, `lifestyle`, `risk-score`, `family_history`, `symptom`, `anamnesis`, `recommendation`, `immunization`, `other` |
| `text`           | string | Verbatim text of the block, in the document's original language                                                                                                                                    |
| `date`           | string | Date tied to the block, when the document states one. Absent otherwise                                                                                                                             |

{% hint style="info" %}
Keys inside `narrative_blocks` are camelCase, unlike the snake\_case used elsewhere in the response. This is intentional for backward compatibility — do not "fix" it in your parser.
{% endhint %}

### `documents`

One entry per file stored for this test, in upload order.

| Field                   | Type            | Description                                                                                                                                |
| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `file_id`               | string          | Internal file identifier                                                                                                                   |
| `file_name`             | string          | Stored file name — the extension is corrected to follow the actual bytes                                                                   |
| `content_type`          | string \| null  | MIME type inferred from the stored file name                                                                                               |
| `summary`               | string \| null  | One-sentence summary of the document produced by recognition                                                                               |
| `lab_name`              | string \| null  | Laboratory name, when visible in the document                                                                                              |
| `test_date`             | string \| null  | Document-level date reported by recognition                                                                                                |
| `language`              | string \| null  | ISO 639-1 code of the document language (e.g. `en`, `ru`, `de`)                                                                            |
| `historical`            | boolean \| null | `true` when recognition classified the document as a historical record                                                                     |
| `document_reference_id` | string \| null  | FHIR `DocumentReference.id` for the stored file, when it was created                                                                       |
| `download_url`          | string \| null  | Short-lived signed URL for the original file (**valid one hour**), regenerated on every response. `null` if the URL could not be generated |

{% hint style="warning" %}
`summary`, `lab_name`, `test_date`, `language`, and `historical` are recognition metadata for the **request as a whole**, not per file: in a multi-file upload every entry repeats the same values. Do not treat them as per-file classification.
{% endhint %}

{% hint style="info" %}
`download_url` expires — never store it. Re-read `GET /api/v1/upload/{testId}` to get a fresh URL.
{% endhint %}

### Medical Context Categories

All categories are optional — only non-empty arrays/objects are included in the response.

| Category         | Description                           | Key Fields                                                                        |
| ---------------- | ------------------------------------- | --------------------------------------------------------------------------------- |
| `diagnoses`      | Conditions/diagnoses                  | `name`, `status`, `onset`, `icd10_code`, `snomed_code`, `snomed_display`, `notes` |
| `medications`    | Prescribed and continuing medications | `name`, `dosage`, `status`, `intent`, `rxnorm_code`, `snomed_code`, `reason`      |
| `procedures`     | Surgical and diagnostic procedures    | `name`, `date`, `status`, `cpt_code`, `snomed_code`                               |
| `family_history` | Family medical history                | `relationship`, `condition`, `onset_age`, `snomed_code`                           |
| `symptoms`       | Reported symptoms                     | `name`, `snomed_code`, `snomed_display`, `duration`                               |
| `vital_signs`    | Vital sign measurements               | `name`, `snomed_code`, `snomed_display`, `value`, `unit`                          |

### Coding Systems

The response includes standard medical codes where available:

| System        | Field                       | Example                               |
| ------------- | --------------------------- | ------------------------------------- |
| **LOINC**     | `parameters[].loinc.code`   | `"4548-4"` (HbA1c)                    |
| **ICD-10-CM** | `diagnoses[].icd10_code`    | `"K35.3"` (Acute appendicitis)        |
| **SNOMED CT** | `diagnoses[].snomed_code`   | `"38341003"` (Hypertension)           |
| **RxNorm**    | `medications[].rxnorm_code` | `"29046"` (Lisinopril)                |
| **CPT**       | `procedures[].cpt_code`     | `"44970"` (Laparoscopic appendectomy) |

***

## FHIR R4 Bundle Response

**Request:** `GET /api/v1/upload/{testId}` with `Accept: application/fhir+json`

```bash
curl https://sandbox.bloodgpt.com/api/v1/upload/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/fhir+json"
```

The response is a FHIR R4 `Bundle` of type `searchset`, assembled from the patient's record in our FHIR store. Each entry's `fullUrl` is a relative reference (`Observation/<id>`), and resources reference each other the same way (`"subject": { "reference": "Patient/<id>" }`).

{% hint style="warning" %}
**The bundle is patient-scoped, not test-scoped.** It contains every resource stored for the patient this test belongs to — including results from the patient's earlier uploads. Filter by `DiagnosticReport` / `Observation.effectiveDateTime`, or use the `tests[].diagnostic_report_id` values from the JSON response, if you need only this document's data.
{% endhint %}

### Resource Types

**Always present:**

| Resource Type       | Description                                                                              |
| ------------------- | ---------------------------------------------------------------------------------------- |
| `Patient`           | Patient with demographics (when available) and external identifier                       |
| `Observation`       | One per extracted lab parameter — LOINC-coded with interpretation (H/L/N)                |
| `DiagnosticReport`  | One per measurement date, referencing that date's Observations                           |
| `DocumentReference` | One per uploaded file — the original document, auditable alongside the derived resources |

**Medical context (discharge summaries, referral letters):**

| Resource Type         | Description                                                                       |
| --------------------- | --------------------------------------------------------------------------------- |
| `Practitioner`        | Healthcare providers mentioned in the document                                    |
| `Encounter`           | Visit details (admission/discharge, reason, class)                                |
| `Condition`           | Diagnoses with ICD-10 and/or SNOMED coding                                        |
| `MedicationRequest`   | New prescriptions (intent: `order`)                                               |
| `MedicationStatement` | Continuing/home medications                                                       |
| `Procedure`           | Surgical and diagnostic procedures with CPT/SNOMED coding                         |
| `FamilyMemberHistory` | Family medical history                                                            |
| `CarePlan`            | Structured discharge instructions; follow-up recommendations after interpretation |
| `Composition`         | Narrative sections, and the AI report after interpretation                        |
| `Provenance`          | Links each narrative-derived resource back to the source `DocumentReference`      |

### Example Response

```json
{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 4,
  "entry": [
    {
      "fullUrl": "Patient/a1b2c3d4",
      "resource": {
        "resourceType": "Patient",
        "id": "a1b2c3d4",
        "name": [{ "text": "Sarah Michelle Johnson" }],
        "gender": "female",
        "birthDate": "1985-03-15",
        "identifier": [
          {
            "type": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "MR" }] },
            "value": "SMH-2024-008912"
          }
        ]
      }
    },
    {
      "fullUrl": "Observation/e5f6a7b8",
      "resource": {
        "resourceType": "Observation",
        "id": "e5f6a7b8",
        "status": "final",
        "category": [
          {
            "coding": [
              { "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory", "display": "Laboratory" }
            ]
          }
        ],
        "code": {
          "coding": [
            { "system": "http://loinc.org", "code": "2345-7", "display": "Glucose [Mass/volume] in Serum or Plasma" }
          ],
          "text": "Glucose"
        },
        "subject": { "reference": "Patient/a1b2c3d4" },
        "effectiveDateTime": "2024-12-10",
        "issued": "2025-01-15T12:00:00.000Z",
        "valueQuantity": {
          "value": 144,
          "unit": "mg/dL",
          "system": "http://unitsofmeasure.org"
        },
        "referenceRange": [
          {
            "low": { "value": 70, "unit": "mg/dL" },
            "high": { "value": 110, "unit": "mg/dL" },
            "text": "70-110"
          }
        ],
        "interpretation": [
          {
            "coding": [
              { "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", "code": "H", "display": "High" }
            ]
          }
        ]
      }
    },
    {
      "fullUrl": "Condition/c1d2e3f4",
      "resource": {
        "resourceType": "Condition",
        "id": "c1d2e3f4",
        "code": {
          "coding": [
            { "system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "K35.3", "display": "Acute appendicitis with localized peritonitis" },
            { "system": "http://snomed.info/sct", "code": "73688009", "display": "Acute appendicitis" }
          ],
          "text": "Acute appendicitis with perforation"
        },
        "subject": { "reference": "Patient/a1b2c3d4" },
        "clinicalStatus": {
          "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": "resolved" }]
        }
      }
    },
    {
      "fullUrl": "DiagnosticReport/f8a9b0c1",
      "resource": {
        "resourceType": "DiagnosticReport",
        "id": "f8a9b0c1",
        "status": "final",
        "category": [
          {
            "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v2-0074", "code": "LAB", "display": "Laboratory" }]
          }
        ],
        "code": {
          "coding": [{ "system": "http://loinc.org", "code": "11502-2", "display": "Laboratory report" }],
          "text": "Blood Test Report"
        },
        "subject": { "reference": "Patient/a1b2c3d4" },
        "effectiveDateTime": "2024-12-10",
        "issued": "2025-01-15T12:00:00.000Z",
        "performer": [{ "display": "St. Mary's Hospital" }],
        "result": [
          { "reference": "Observation/e5f6a7b8" },
          { "reference": "Observation/e5f6a7b9" }
        ]
      }
    }
  ]
}
```

### Observation Interpretation

Each Observation with a numeric value and reference range includes an `interpretation` field:

| Code | Display | Meaning                               |
| ---- | ------- | ------------------------------------- |
| `H`  | High    | Value exceeds reference range maximum |
| `L`  | Low     | Value below reference range minimum   |
| `N`  | Normal  | Value within reference range          |

System: `http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation`

Results that were ordered but never produced (cancelled, not performed, insufficient sample) are returned as an Observation with no value and a `dataAbsentReason` of `not-performed` — check for it before reading `valueQuantity`.

***

## Complete Example

{% tabs %}
{% tab title="Submit file" %}

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/upload \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F file=@blood-test.pdf \
  -F 'data={"external_id":"test-001","patient_id":"patient-123"}'
```

Response:

```json
{"request_id":"req_abc","test_id":"clxyz789012","status":"recognize_processing"}
```

{% endtab %}

{% tab title="Get results (JSON)" %}

```bash
curl https://sandbox.bloodgpt.com/api/v1/upload/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/json"
```

{% endtab %}

{% tab title="Get results (FHIR)" %}

```bash
curl https://sandbox.bloodgpt.com/api/v1/upload/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Accept: application/fhir+json"
```

{% endtab %}

{% tab title="Interpret" %}
Once the status is `completed`, request the analysis — see [Step 3](#step-3-request-the-interpretation).

```bash
curl -X POST https://sandbox.bloodgpt.com/api/v1/interpret/clxyz789012 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Response:

```json
{"request_id":"req_abc","test_id":"clxyz789012","status":"waiting_for_analysis","message":"Interpretation pipeline started"}
```

Then poll the same result endpoint again until the status leaves `analyzing`.
{% endtab %}
{% endtabs %}

***

## Best Practices

<details>

<summary><strong>Processing takes up to 30 minutes</strong></summary>

The recognition pipeline runs multiple stages (page rendering, cross-checked recognition, normalization, LOINC harmonization, FHIR mapping). Processing typically takes 2-10 minutes but can take up to 30 minutes for complex documents. Fetch results from your backend after a sufficient delay — do not poll from the client.

</details>

<details>

<summary><strong>Send a <code>patient_id</code> if you want de-duplication</strong></summary>

Re-uploading the same bytes is only rejected against previous uploads when the request carries a `patient_id` — that is what scopes the check. Without it, the same document can be ingested twice as two unrelated tests.

</details>

<details>

<summary><strong>Convert HEIC to JPEG before uploading</strong></summary>

HEIC/HEIF is refused with `415`. Convert client-side (browser file pickers usually do it for you) or server-side before calling the API.

</details>

<details>

<summary><strong>Use FHIR format for EHR integration</strong></summary>

If you're importing data into an EHR or clinical system, use `Accept: application/fhir+json` to get a standards-compliant FHIR R4 Bundle that can be loaded directly. Remember it is patient-scoped — filter to the dates or `DiagnosticReport` ids you care about.

</details>

<details>

<summary><strong>Handle null fields gracefully</strong></summary>

Fields like `loinc`, `normalized_name`, `status`, `source_format`, `patient`, `encounter`, and `practitioners` may be `null` or absent when the system cannot confidently determine the value or when the document doesn't contain that data. Always check for null/missing values in your integration code.

</details>

<details>

<summary><strong>Don't assume one document equals one date</strong></summary>

Discharge summaries and longitudinal tables produce several measurement dates from one file — one `DiagnosticReport` each. Read `tests[]` rather than assuming all parameters share `document_date`.

</details>

***

## End-to-end example

```bash
API=https://sandbox.bloodgpt.com
KEY=YOUR_API_KEY

# 1. Upload
TEST_ID=$(curl -s -X POST $API/api/v1/upload \
  -H "Authorization: Bearer $KEY" \
  -F file=@blood-test.pdf \
  -F 'data={"external_id":"lab-report-2025-001","patient_id":"patient-123"}' \
  | jq -r .test_id)

# 2. Wait for recognition
until [ "$(curl -s $API/api/v1/upload/$TEST_ID \
  -H "Authorization: Bearer $KEY" | jq -r .status)" != "processing" ]; do
  sleep 10
done

# 3. Ask for the interpretation
curl -s -X POST $API/api/v1/interpret/$TEST_ID \
  -H "Authorization: Bearer $KEY"

# 4. Wait for the analysis
while [ "$(curl -s $API/api/v1/upload/$TEST_ID \
  -H "Authorization: Bearer $KEY" | jq -r .status)" = "analyzing" ]; do
  sleep 10
done

curl -s $API/api/v1/upload/$TEST_ID -H "Authorization: Bearer $KEY" | jq .
```

Production integrations should use [Webhooks](/interpretation-and-results/webhooks.md) rather than the two wait loops.
