Exporting a Study
The Export API produces a single file containing the full hierarchy of a study — study metadata, subjects, organs, slides, findings, and codes. Use it to take a point-in-time copy of a study for archival, reporting, or loading into another system.
Exports are asynchronous. Starting an export returns immediately with a job record; you poll that job until it completes, then download the file. There is no synchronous variant — a study with tens of thousands of records cannot be serialised within a request timeout.
Lifecycle
POST /exports → 202 Accepted, status QUEUED
│
▼
GET /exports/{id} → IN_PROGRESS (poll)
│
┌─────────┴─────────┐
▼ ▼
COMPLETED FAILED
│ │
▼ ▼
GET /{id}:download file errorMessage explains why
| Status | Meaning |
|---|---|
QUEUED | Accepted and waiting to execute |
IN_PROGRESS | Running. exportStatus.recordsWritten advances as records are serialised |
COMPLETED | Finished successfully. download.downloadUrl is populated |
FAILED | Did not finish. errorMessage describes the reason |
QUEUED and IN_PROGRESS are the two active states. Both are terminal-free — you must keep polling until the job reaches COMPLETED or FAILED.
Choosing a format
| Format | Output | Use when |
|---|---|---|
JSON | A single JSON document with named arrays, ordered study → subjects → organs → slides → findings → codes | Loading into another system or processing programmatically |
XLSX | An Excel workbook with one sheet per entity type | Human review or offline analysis |
The XLSX format is bound by Excel's 32,767-character cell limit. A longer attribute value is truncated and suffixed with .... If any of your attributes hold large payloads, read them from a JSON export instead.
Step 1 — Start the export
Required scope: Study:Write
curl -X POST "https://api.dev2.patholytix.com/api/v1/studies/5ff73eb894507b0001c43d49/exports" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"format": "JSON"}'
Response — 202 Accepted:
{
"exportId": "60f1b2c3d4e5f6a7b8c9d0e1",
"studyId": "5ff73eb894507b0001c43d49",
"format": "JSON",
"exportStatus": {
"status": "QUEUED",
"recordsWritten": 0,
"totalRecords": null
},
"download": {
"downloadUrl": null
},
"errorMessage": null,
"createdDate": "2026-09-04T10:00:00Z",
"startedDate": null,
"completedDate": null
}
Only one active export may exist per study at a time, regardless of format. If an active job already exists, this endpoint returns 202 with that existing job rather than starting a second one. Compare the returned exportId against the one you expected, or check createdDate, before assuming you created a new job.
Step 2 — Poll for completion
Required scope: Study:Read
curl "https://api.dev2.patholytix.com/api/v1/studies/5ff73eb894507b0001c43d49/exports/60f1b2c3d4e5f6a7b8c9d0e1" \
-H "Authorization: Bearer YOUR_TOKEN"
Response once finished — 200 OK:
{
"exportId": "60f1b2c3d4e5f6a7b8c9d0e1",
"studyId": "5ff73eb894507b0001c43d49",
"format": "JSON",
"exportStatus": {
"status": "COMPLETED",
"recordsWritten": 15000,
"totalRecords": 15000
},
"download": {
"downloadUrl": "/api/v1/studies/5ff73eb894507b0001c43d49/exports/60f1b2c3d4e5f6a7b8c9d0e1:download"
},
"errorMessage": null,
"createdDate": "2026-09-04T10:00:00Z",
"startedDate": "2026-09-04T10:00:01Z",
"completedDate": "2026-09-04T10:05:00Z"
}
recordsWritten against totalRecords gives you progress while the job runs. totalRecords may be absent until the job has started.
Step 3 — Download the file
Required scope: Study:Read
curl -L -o study-export.json \
"https://api.dev2.patholytix.com/api/v1/studies/5ff73eb894507b0001c43d49/exports/60f1b2c3d4e5f6a7b8c9d0e1:download" \
-H "Authorization: Bearer YOUR_TOKEN"
The file is streamed directly. Downloading a job that has not reached COMPLETED returns 409 Conflict, so always confirm the status first.
Housekeeping — the two limits that will bite you
A study retains at most 5 export jobs, and only one active job. Both limits are enforced on POST /exports:
| Situation | Response | What to do |
|---|---|---|
An active job (QUEUED / IN_PROGRESS) exists | 202 with the existing job | Poll it instead of retrying |
A FAILED job exists | 409 Conflict | DELETE the failed job, then start again |
| The study already has 5 jobs | 409 Conflict | DELETE one or more jobs first |
| The study has no hierarchy to export | 422 Unprocessable Entity | Nothing to retry — this study predates the hierarchy |
Because a FAILED job blocks the next attempt, a client that never cleans up will start returning 409 and stay stuck. Delete each job once you have downloaded it.
Delete a job (Study:Delete scope):
curl -X DELETE \
"https://api.dev2.patholytix.com/api/v1/studies/5ff73eb894507b0001c43d49/exports/60f1b2c3d4e5f6a7b8c9d0e1" \
-H "Authorization: Bearer YOUR_TOKEN"
This removes the job record and its stored file, and returns 204 No Content. Applied to an active job it cancels the export on a best-effort basis.
List all jobs for a study (Study:Read scope) returns them oldest first, which is also the order you should delete them in:
curl "https://api.dev2.patholytix.com/api/v1/studies/5ff73eb894507b0001c43d49/exports" \
-H "Authorization: Bearer YOUR_TOKEN"
End-to-end example
import time
import requests
BASE_URL = "https://api.dev2.patholytix.com/api"
def export_study(study_id: str, token: str, fmt: str = "JSON") -> bytes:
headers = {"Authorization": f"Bearer {token}"}
resp = requests.post(
f"{BASE_URL}/v1/studies/{study_id}/exports",
json={"format": fmt},
headers=headers,
)
resp.raise_for_status()
export_id = resp.json()["exportId"]
while True:
job = requests.get(
f"{BASE_URL}/v1/studies/{study_id}/exports/{export_id}",
headers=headers,
)
job.raise_for_status()
job = job.json()
status = job["exportStatus"]["status"]
if status == "COMPLETED":
break
if status == "FAILED":
raise RuntimeError(f"Export failed: {job['errorMessage']}")
time.sleep(10)
content = requests.get(
f"{BASE_URL}/v1/studies/{study_id}/exports/{export_id}:download",
headers=headers,
)
content.raise_for_status()
# Free the slot — a study retains only 5 jobs.
requests.delete(
f"{BASE_URL}/v1/studies/{study_id}/exports/{export_id}",
headers=headers,
).raise_for_status()
return content.content
Poll on an interval measured in seconds, not milliseconds. A large study takes minutes, and the status resource is not a progress stream.
Scopes
| Operation | Scope |
|---|---|
| Start an export | Study:Write |
| List exports, get an export, download the file | Study:Read |
| Cancel or delete an export | Study:Delete |
Next steps
- API Reference — request and response schemas for every export operation
- Error Codes — the shared error response format
- Data Model — what each entity in the exported hierarchy contains