Skip to main content

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
StatusMeaning
QUEUEDAccepted and waiting to execute
IN_PROGRESSRunning. exportStatus.recordsWritten advances as records are serialised
COMPLETEDFinished successfully. download.downloadUrl is populated
FAILEDDid 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

FormatOutputUse when
JSONA single JSON document with named arrays, ordered study → subjects → organs → slides → findings → codesLoading into another system or processing programmatically
XLSXAn Excel workbook with one sheet per entity typeHuman review or offline analysis
Excel truncates long values

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
}
A 202 does not always mean a new job

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:

SituationResponseWhat to do
An active job (QUEUED / IN_PROGRESS) exists202 with the existing jobPoll it instead of retrying
A FAILED job exists409 ConflictDELETE the failed job, then start again
The study already has 5 jobs409 ConflictDELETE one or more jobs first
The study has no hierarchy to export422 Unprocessable EntityNothing 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

OperationScope
Start an exportStudy:Write
List exports, get an export, download the fileStudy:Read
Cancel or delete an exportStudy: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