PDF to Markdown API: Upload, Poll, and Download
A PDF-to-Markdown API integration has two success conditions: the job must finish, and the result must preserve the content your application needs. A completed job can still contain interleaved columns, an unreadable table header, or a missing scanned appendix.
This guide shows a one-file Markovo integration: upload for an estimate, confirm a Credit ceiling, poll the job, and download Markdown. It also explains which operations can be resumed without submitting another conversion.
The request sequence follows the public developer contract and adapts the HTTP client from our Python conversion guide. The code was checked with local response stubs, not a new authenticated API conversion. The output examples later in this article are historical synthetic-fixture results, clearly dated below.
Choose the extraction route before building the queue
An HTTP interface does not change a converter's extraction behavior. A local library behind your own endpoint still needs upload validation, job storage, resource limits, and an output contract.
| PDF content | Candidate route | Check before accepting the result |
|---|---|---|
| Selectable, single-column prose | Local text extraction, such as MarkItDown | Whole sentences remain in sequence |
| Native columns or tables | Layout-aware extraction, such as the tested PyMuPDF4LLM configuration | Reading order and header-to-value relationships survive |
| Image-only pages or mixed text/scan documents | A configured OCR path, local or hosted | Text from each required scanned page is present |
| Formulas | A route evaluated on your actual notation | Symbols, grouping, and expression boundaries match the PDF |
Classify pages, not only files. A text-first route may handle the report body and miss its scanned appendix. For local deployment choices, use the GitHub project selection guide.
Understand the four requests
Use an API key in a backend environment, with the markovo:convert scope. Never put it in client-side JavaScript, a public repository, or the converted document.
POST /v1/estimates: upload one PDF as multipart form data. Readupload_idandestimated_credit_units.POST /v1/jobs: confirm that upload with a positive integermax_credit_unitsceiling.GET /v1/jobs/{job_id}: poll the same owned job until it reaches a terminal state.GET /v1/jobs/{job_id}/download?format=md: download the Markdown after success.
The developer contract specifies that an identical confirmation for the same upload_id returns the existing job. Changing the ceiling on a replay produces a non-retryable 409 idempotency_conflict. Persist the confirmation before sending it, then persist job_id as soon as it is returned. Confirmation and retry contract.
The older POST /v1/convert endpoint combines estimation and submission. Do not automatically repeat that compatibility upload after a lost response: the first request may already have created a job. New clients should use the explicit estimate-and-confirm sequence below.
A complete Python example
Save this as markovo_pdf.py. It uses the Python standard library, so there is no additional client package to install. It handles one file at a time and deliberately does not implement automatic HTTP retries.
Run it with Python 3.11 or newer after setting MARKOVO_API_KEY securely in your server environment:
python markovo_pdf.py input.pdf runs/invoice --max-credit-units 5000
Here, 5,000 units is a maximum of 5 Credits, not a dollar price or a prediction of the file's cost. The program confirms automatically only if the estimate is within the ceiling you supplied. Running it against the live service uploads the PDF and can spend account Credits.
import argparse
import json
import os
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
BASE = "https://markovo.net"
def request(path, key, data=None, content_type=None):
headers = {"Authorization": f"Bearer {key}"}
if content_type:
headers["Content-Type"] = content_type
req = Request(BASE + path, data=data, headers=headers)
try:
with urlopen(req, timeout=60) as response:
return response.read()
except HTTPError as exc:
try:
error = json.loads(exc.read()).get("error", {})
except (ValueError, AttributeError):
error = {}
raise RuntimeError(
f"HTTP {exc.code}: code={error.get('code', 'unknown')}; "
f"request_id={error.get('request_id', 'unknown')}; "
f"retryable={error.get('retryable', 'unknown')}"
) from exc
def estimate(source, key):
boundary = uuid.uuid4().hex
fields = {"capability_id": "pdf-to-markdown", "mode": "fast"}
parts = []
for name, value in fields.items():
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; '
f'name="{name}"\r\n\r\n{value}\r\n').encode())
parts.append((f'--{boundary}\r\nContent-Disposition: form-data; '
'name="file"; filename="input.pdf"\r\n'
'Content-Type: application/pdf\r\n\r\n').encode()
+ source.read_bytes() + b"\r\n")
parts.append(f"--{boundary}--\r\n".encode())
payload = json.loads(request("/v1/estimates", key, b"".join(parts),
f"multipart/form-data; boundary={boundary}"))
return payload
def download_when_ready(job_id, key, destination, poll_seconds=600):
job_path = "/v1/jobs/" + quote(job_id, safe="")
deadline = time.monotonic() + poll_seconds
while time.monotonic() < deadline:
payload = json.loads(request(job_path, key))
job = payload.get("job", payload)
status = job.get("status")
if status == "succeeded":
content = request(job_path + "/download?format=md", key)
destination.write_text(content.decode("utf-8"), encoding="utf-8")
return
if status in {"failed", "cancelled", "timed_out"}:
raise RuntimeError(f"Job {job_id}: {status}; {job.get('error_code')}")
time.sleep(2)
raise TimeoutError(f"Polling stopped; job {job_id} may still be running.")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("output_dir", type=Path)
parser.add_argument("--max-credit-units", type=int, required=True)
args = parser.parse_args()
if args.max_credit_units <= 0:
parser.error("--max-credit-units must be a positive integer")
key = os.environ["MARKOVO_API_KEY"]
args.output_dir.mkdir(parents=True, exist_ok=True)
job_file = args.output_dir / "job_id.txt"
confirmation_file = args.output_dir / "confirmation.json"
if job_file.exists():
job_id = job_file.read_text(encoding="utf-8").strip()
else:
if confirmation_file.exists():
confirmation = json.loads(confirmation_file.read_text(encoding="utf-8"))
if confirmation["max_credit_units"] != args.max_credit_units:
raise ValueError("Resume with the original Credit ceiling.")
else:
result = estimate(args.source, key)
units = result["estimated_credit_units"]
if units > args.max_credit_units:
raise ValueError(f"Estimate {units} exceeds the chosen ceiling.")
confirmation = {"upload_id": result["upload_id"],
"max_credit_units": args.max_credit_units}
confirmation_file.write_text(json.dumps(confirmation), encoding="utf-8")
# Reuse exactly this confirmation after an ambiguous network failure.
payload = json.loads(request("/v1/jobs", key,
json.dumps(confirmation).encode(),
"application/json"))
job_id = payload["job_id"]
job_file.write_text(job_id + "\n", encoding="utf-8")
print(f"Job: {job_id}", flush=True)
download_when_ready(job_id, key, args.output_dir / "output.md")
print(f"Saved {args.output_dir / 'output.md'}")
if __name__ == "__main__":
main()
The output directory contains confirmation.json, job_id.txt, and, after download, output.md. Use a new directory for every new conversion. Do not run two processes against the same directory, edit its saved confirmation, or reuse it for a different source: it identifies one submission, not a general output folder.
If the process loses a confirmation response, rerunning the same command uses the saved upload_id and ceiling. If it already has job_id.txt, it skips submission and resumes the read-only job/download steps. A polling timeout stops this client; it does not cancel the server job or establish that conversion failed.
This is a small integration example, not a production queue. It builds the multipart body in memory and uses ordinary local state files. A production worker should stream large inputs where supported, store state transactionally, prevent concurrent confirmation races, and enforce file limits before upload. Keep the original PDF in your own approved storage if later source review is required.
Costs: use estimates, not a page-count guess
One Credit equals 1,000 credit units. On October 19, 2026, the public catalog listed 100 units per digital-document page and 500 units per image-OCR page. Those rates are context, not a substitute for the estimate returned for your file. Discover current capability and billing details through /v1/capabilities and /v1/catalog; use the returned estimate to decide whether to confirm. Current pricing and Credits.
Record the estimate's credit_model_version and rate_card_version alongside the job. A rate-card identifier is a pricing version, not an extraction-engine version.
In the historical October 6 examples, the one-page image-only file recorded 500 actual units, and the three-page mixed file recorded 700. These are two saved results, not guaranteed prices for every one-page or three-page PDF. Scan response, mixed-document response.
For a resumed job, the original confirmed ceiling remains the relevant limit. Changing a command-line number cannot retroactively change a job's price or authorize a different confirmation. The API also applies account-specific file, page, concurrency, and retention limits. Check those before building a batch uploader. API limits.
Retry the failed operation, not the whole workflow
| Situation | What the client knows | Safe next step |
|---|---|---|
| Estimate request fails before confirmation | No conversion has been confirmed by this client | Inspect the error; retry only within the documented error policy |
| Confirmation response is lost | The job may exist | Replay the saved, identical confirmation, not another upload |
| Polling connection fails | The saved job still identifies the work | Resume GET requests for that job |
| Client polling deadline expires | Server outcome is unknown | Check the same job later; do not infer cancellation |
Job is failed, cancelled, or timed_out |
A terminal unsuccessful state is reported | Inspect the job error and decide whether a corrected new conversion is appropriate |
| Download fails after success | Conversion need not be repeated | Retry the download if the error permits and the result remains retained |
| Quota, ownership, validation, or ceiling error | Repetition will not fix the request | Fix the cause rather than loop |
Structured API errors include code, message, request_id, retryable, and docs_url. Save the request ID for diagnosis, without logging the Authorization header or uploaded content. Automatic retry logic should respect retryable, use bounded backoff, and distinguish read-only GETs from confirmation replays. An ambiguous network failure has no trustworthy response body; the stored confirmation is what makes that particular replay safe. Error contract.
If an estimate has expired or the service returns a non-retryable confirmation error, stop and reconcile the saved state before creating another submission. Do not delete state files just to get past an error.
Check content after the download succeeds
Start with five checks tied to your actual consumer:
- Coverage: verify a distinctive sentence from each required content page, especially scanned attachments. A nonempty file is insufficient.
- Reading order: read a full paragraph across a column boundary. Joining line breaks cannot repair alternating columns.
- Tables: map each amount to its row, leaf header, and any parent header. The presence of all numbers does not prove the mapping.
- Syntax and assets: decide whether the consumer accepts HTML tables, math, and relative image paths. A Markdown-only download is not the entire asset bundle.
- Formulas: compare symbols against the source before using them in calculations or factual answers.
Markovo's documented artifact contract includes Markdown, assets, metadata, a source map, a quality report, and a ZIP bundle. Keep the necessary artifacts together; do not assume a standalone Markdown string makes referenced assets available. Source-map and quality metadata can support review, but neither establishes that every extracted claim is correct. Output contract.
Two historical failure examples
The grouped-header output below came from the historical PyMuPDF4LLM run. A flat first-row parser would leave the last column unnamed and treat the parent label as a leaf field:
|**Item**|**Qty**|**Price**||
|---|---|---|---|
|||**Unit**|**Total**|
|Consulting hours|12|85.00|1020.00|
|Data export fee|3|40.00|120.00|
Unedited grouped-header output.
Markovo recovered the scanned text in the cached fixtures, but returned invoice tables as HTML and damaged formula notation. One expression contained nested display delimiters:
$$
\[\mathsf{E}=\mathsf{m}\mathsf{c}\mathsf{^2}\]
$$
The same formula fixture had normalized expression-match scores of 1.0 for MarkItDown, 0.75 for PyMuPDF4LLM, and 0.5 for Markovo. This measures reference-string recovery after markup normalization, not mathematical equivalence or valid rendering. Recovering an ordinary scan does not establish formula fidelity.
Evidence and validation boundary
The historical local comparison ran on October 14, 2026, with Python 3.11.14, MarkItDown 0.1.8, and PyMuPDF4LLM 1.28.2. Its eight generated English PDFs cover single-column prose, simple tables, grouped headers, columns, scans, mixed pages, linear formulas, and rotated headers.
The Markovo PDF outputs were cached jobs created on October 6, 2026. They were reused in this and related articles; they are not independent repeat trials. Cached elapsed_s=0.0 values are not conversion latency. This October 19 revision did not submit new conversions or measure live throughput.
Markovo is the service we build. Its successes and failures are both retained here. The saved mixed response contains different preflight billing and post-conversion OCR counts. Those fields alone do not trace how each page or table element was processed, and they do not measure content accuracy.
Only the integration-relevant examples are shown above. The full scores and metric definitions, execution records, and cached API provenance preserve the rest. Table scores use same-line string checks and do not establish merged-header semantics. The experiment did not test image-link delivery or downstream retrieval quality.
The revised Python example was syntax-checked and exercised with local stubs for success, resume, over-ceiling estimates, terminal failure, and ambiguous confirmation responses. That validates the illustrated control flow, not current authenticated endpoint behavior or billing settlement.
For the current authentication, limits, and artifact contract, continue to the Markovo API documentation. For the next stage after conversion, use the source-aware LLM ingestion workflow.


