Post a PDF, get source-linked data
The API is the same service as the workspace: the same credit, the same limits, the same results. Your account comes with an API key.
Quick start
Set PARSEDOCS_KEY to your key. Base address for this server: https://parsedocs.tathya.ai.
# 1. Upload a PDF and start parsing it.
# data_class=deidentified states the file has no patient information.
curl -s -X POST "https://parsedocs.tathya.ai/v1/parse?tier=standard&data_class=deidentified" \
-H "Authorization: Bearer $PARSEDOCS_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "file=@fax.pdf"
# 2. Check progress. Repeat until "status" is "completed".
curl -s "https://parsedocs.tathya.ai/v1/jobs/JOB_ID" \
-H "Authorization: Bearer $PARSEDOCS_KEY"
# 3. Read the structured result, or download the findings as CSV.
curl -s "https://parsedocs.tathya.ai/v1/jobs/JOB_ID/result" \
-H "Authorization: Bearer $PARSEDOCS_KEY"
curl -s "https://parsedocs.tathya.ai/v1/jobs/JOB_ID/result.csv" \
-H "Authorization: Bearer $PARSEDOCS_KEY" -o findings.csv
# No file handy? Run a built-in fictional example instead of step 1.
curl -s -X POST "https://parsedocs.tathya.ai/v1/jobs" \
-H "Authorization: Bearer $PARSEDOCS_KEY" \
-H "Content-Type: application/json" \
-d '{"example_id": "referral-nephrology", "tier": "standard"}'import os, time, uuid
import requests
BASE = "https://parsedocs.tathya.ai"
HEADERS = {"Authorization": f"Bearer {os.environ['PARSEDOCS_KEY']}"}
# Upload a PDF and start parsing it.
with open("fax.pdf", "rb") as f:
r = requests.post(
f"{BASE}/v1/parse",
params={"tier": "standard", "data_class": "deidentified"},
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
files={"file": ("fax.pdf", f, "application/pdf")},
)
if r.status_code == 402: # saved, but your credit does not cover it yet
raise SystemExit(r.json()["error"]["message"])
r.raise_for_status()
job = r.json()["job"]
# Wait for it to finish. Long documents are processed in the background.
while job["status"] in ("queued", "processing"):
time.sleep(2)
job = requests.get(f"{BASE}/v1/jobs/{job['id']}", headers=HEADERS).json()["job"]
if job["status"] != "completed":
raise SystemExit(job.get("failure", {}).get("message", job["status"]))
# Every finding carries the page and the printed line it came from.
result = requests.get(f"{BASE}/v1/jobs/{job['id']}/result", headers=HEADERS).json()
print(result["overview"]["headline"])
for item in result["items"]:
where = item["evidence"][0] if item["evidence"] else {}
print(item["entity"], item["value"]["original"], item["value"]["unit"],
"| page", where.get("page"), "|", item["status"])
print("charged", job["charge"]["amount"], "credit left", job["charge"].get("credit_remaining"))// Node 18 or newer (built-in fetch, FormData and Blob).
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const BASE = "https://parsedocs.tathya.ai";
const headers = { Authorization: `Bearer ${process.env.PARSEDOCS_KEY}` };
// Upload a PDF and start parsing it.
const form = new FormData();
form.append("file", new Blob([await readFile("fax.pdf")], { type: "application/pdf" }), "fax.pdf");
const started = await fetch(`${BASE}/v1/parse?tier=standard&data_class=deidentified`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": randomUUID() },
body: form,
});
const body = await started.json();
if (!started.ok) throw new Error(body.error.message); // 402 means: saved, but credit does not cover it yet
let job = body.job;
// Wait for it to finish. Long documents are processed in the background.
while (["queued", "processing"].includes(job.status)) {
await new Promise((resolve) => setTimeout(resolve, 2000));
job = (await (await fetch(`${BASE}/v1/jobs/${job.id}`, { headers })).json()).job;
}
if (job.status !== "completed") throw new Error(job.failure?.message ?? job.status);
// Every finding carries the page and the printed line it came from.
const result = await (await fetch(`${BASE}/v1/jobs/${job.id}/result`, { headers })).json();
console.log(result.overview.headline);
for (const item of result.items) {
console.log(item.entity, item.value.original, item.value.unit, "| page", item.evidence[0]?.page, "|", item.status);
}
console.log("charged", job.charge.amount, "credit left", job.charge.credit_remaining);How a job moves
| Status | What it means | Charged? |
|---|---|---|
queued | Accepted. The price is set aside from your credit. | Not yet |
processing | Being read. progress shows the stage and pages done. | Not yet |
completed | The result is ready. | Once, for the pages read |
failed | Could not finish. failure.message says why. Retrying is free. | No |
pending_funds | Saved, not started: credit does not cover it. It starts by itself once it does (for purchased credit: once payment, agreement and review are complete). | No |
canceled | You canceled it. | No |
Endpoints
| Request | Purpose |
|---|---|
| POST /v1/parse | Upload a PDF and start a job in one call. Query: tier, data_class, optional webhook_url. |
| POST /v1/documents | Upload a PDF without starting a job. Returns its page count. |
| POST /v1/estimate | Price of a document or page count at a tier, and whether your credit covers it. |
| POST /v1/jobs | Start a job for an uploaded document_id or a built-in example_id. |
| GET /v1/jobs/{id} | Status, progress, price, charge and a timeline. |
| GET /v1/jobs | Your jobs, newest first. |
| POST /v1/jobs/{id}/retry | Retry a failed job. Free. |
| POST /v1/jobs/{id}/cancel | Cancel a waiting or running job. Never charged. |
| GET /v1/jobs/{id}/result | The structured result. Add ?version=1 for the original reading. |
| GET /v1/jobs/{id}/result.csv | Findings as a spreadsheet. |
| POST /v1/jobs/{id}/review | Accept, correct, hold or reject findings. Saved as a new version. |
| GET /v1/jobs/{id}/pages/{n}.png | An image of a source page, for showing evidence. |
| GET /v1/account · /v1/usage | Plan, what the account may do, credit and the credit history. |
| GET /v1/pricing · /v1/examples | Prices and the built-in fictional examples. No key needed. |
Say what the file is: data_class
deidentified
You state the file contains no patient information: it is synthetic, or it was de-identified before upload. Accepted on every account. Your statement is recorded with the document.
patient
Real patient information. Refused with 403 baa_required until the account has a signed business associate agreement, approval and billing in place. The check happens before the file is read, and no credit balance changes it.
An upload with no data_class is refused unread. The service does not de-identify files for you.
What a result contains
{
"schema": "parsedocs-result-v1",
"version": 1,
"overview": { "headline": "1 patient · lab report · 14 findings · 2 to review" },
"patients": [{ "key": "A", "name": "…", "date_of_birth": "1961-04-09", "identity": { "confirmed": true } }],
"documents": [{ "id": "d1", "label": "Lab report", "first_page": 1, "last_page": 2 }],
"items": [{
"id": "it_…", "patient_key": "A", "kind": "lab", "entity": "Potassium",
"value": { "original": "5.9", "number": 5.9, "unit": "mmol/L" },
"date": { "value": "2026-10-19", "basis": "collected" },
"polarity": "present", // or absent: the page denies it ("no known drug allergies", "was not started")
"medication_action": "none", // for medications: started, stopped, changed, continued, refill_requested
"status": "supported", // or needs_review, unsupported
"flags": [], // for example unit_missing, patient_unbound, conflicting_information
"holds": [], // ids of open holds that concern this finding
"suggested_patient_key": null, // on a page that prints no patient: who it probably belongs to (not assigned)
"evidence": [{ "page": 1, "line": "p1.l14", "text": "Potassium 5.9 mmol/L H", "box": [0.11, 0.42, 0.63, 0.44] }],
"review": { "state": "unreviewed" }
}],
"summary": [{ "category": "key_results", "text": "…", "evidence": [ … ] }],
"holds": [{ "id": "h_…", "reason": "conflicting_information", "label": "Statements that conflict", "page": 2, "items": ["it_…", "it_…"] }],
"pages": [{ "number": 1, "readable": true, "not_captured": [ … ] }],
"usage": { "tier": "standard", "pages": 2, "billed_pages": 2, "charge": "$0.04" }
}- Evidence. Each finding and summary line names the page and printed line it rests on.
boxis the line's position on the page image (fractions of width and height) when it could be located. - Uncertainty is kept.
needs_reviewfindings,unsupportedfindings (the cited line does not print the value), openholdsandnot_capturedsource lines are all returned, never dropped. - A contradiction is carried on the findings, not only on the document. When two statements conflict (a medication started on one page and "not started" in an addendum), both findings stay as printed, both become
needs_reviewwith the flagconflicting_information, and each names the hold inholds. Nothing is decided for you. In the CSV, readstatus,holdsandopen_holds_in_documentbefore treating a row as cleared. - Present or absent, and what happened to a medication, are two separate fields.
polaritysays whether the page affirms the thing or denies it.medication_actionsays what the page reports was done. A medication the page reports as stopped is an event that happened, so it ispresentwithmedication_action: "stopped": "Lisinopril 20 mg, discontinued 10/02/2026" comes back present, stopped, with that date. A denial staysabsent: "furosemide was not started" and "denies taking ibuprofen" come back absent. A line that says a medication was not stopped is left exactly as it was read and markedneeds_reviewwith the flagnegated_action; a "stopped" that the cited line does not print is marked withaction_unconfirmed. Neither is turned into a stop. - A medication name on a scanned page that the second reader spells differently is flagged, not corrected. The finding keeps the name as first read, becomes
needs_reviewwith the flagname_unconfirmed, and itsnotesgive the other spelling. A person decides. - A page with no patient printed on it is not assigned. Its findings come back with no
patient_key, the flagpatient_unboundand, when the surrounding pages point to one person, asuggested_patient_keyfor a person to confirm. This is deliberate: a continuation page is attached by a person, not by a guess. - Versions. Version 1 is the reading. Each staff review saves a new version; corrected findings keep the first reading under
original. - No urgency grading. Results report what is printed. They do not rank or triage.
Reviewing a result
A review is a list of actions. It is saved as a new version; the reading it started from is kept. Send the version you were looking at as base_version: if someone saved a newer one in the meantime you get 409 version_conflict and nothing changes.
POST /v1/jobs/{id}/review
{
"base_version": 1,
"actions": [
{ "target": "item", "id": "it_…", "action": "accept" },
{ "target": "item", "id": "it_…", "action": "correct", "fields": { "value_original": "5.4", "event_date": "2026-10-19" }, "note": "read from the page" },
{ "target": "item", "action": "add", "fields": { "entity": "Creatinine", "kind": "lab", "value_original": "1.4", "unit": "mg/dL", "page": 2 } },
{ "target": "hold", "id": "h_…", "action": "resolve", "note": "confirmed with the referring office" },
{ "target": "document", "action": "mark_reviewed" }
]
}| Target | Actions | Notes |
|---|---|---|
| item | accept · correct · hold · reject · reset · add | correct can change entity, value_original, unit, event_date (YYYY-MM-DD), patient_key, kind, polarity and med_action; the first reading stays under original. add needs at least entity and is marked as added by staff. reset returns a finding to its first reading. |
| hold | resolve · reopen | A hold is never removed, only resolved, with who and when. |
| document | mark_reviewed · hold · reopen | The state of the whole result. |
A malformed action is answered 400 invalid_action or 400 unknown_target with what to fix, and no version is saved. Values are text or numbers of at most 300 characters.
Errors
Every error has the same shape. code is stable; message says what to do next.
{ "error": { "code": "upgrade_required", "message": "Your document is saved and nothing was charged…",
"request_id": "req_8Zk2…", "details": { "job": { "id": "job_…", "status": "pending_funds" }, "estimate": "$1.20" } } }| HTTP | Code | Meaning |
|---|---|---|
| 400 | data_class_required · data_class_conflict · invalid_pdf · encrypted_pdf · invalid_tier · invalid_action · unknown_target | Fix the request. Nothing was stored or charged. |
| 401 | unauthenticated | Missing, invalid, expired or revoked key. |
| 402 | upgrade_required · insufficient_credit | The job is saved as pending_funds and starts by itself once credit is available (for purchased credit: once payment, agreement and review are complete). |
| 403 | baa_required · missing_scope | Patient data is not enabled, or the key lacks a permission. |
| 404 | not_found | Not in this account. |
| 409 | idempotency_key_reused · job_not_ready · version_conflict | The request conflicts with current state; the message says how. |
| 413 | file_too_large · too_many_pages · too_many_pages_for_tier · page_too_large | Split the document, or use Premium for longer ones. A page larger than about 20 inches on a side is refused. |
| 429 | rate_limited | Wait Retry-After seconds. |
| 503 | engine_unavailable | Processing is not available right now. Nothing was charged. |
Retries and webhooks
Safe retries
Send an Idempotency-Key header with any unique value. Repeating the request with the same key returns the same job. A document already waiting or running at a tier is returned instead of duplicated. One completed job is one charge.
Result webhooks
Pass webhook_url (https) when you start a job. We send job.completed or job.failed with the job's status and links, never document content, and retry on failure. Verify Parsedocs-Signature: t=…,v1=…: v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your webhook secret.