PDF to Markdown API

Turn PDFs into Markdown you can feed to search indexes, RAG pipelines and LLMs: headings, lists and pipe tables from the PDF's text layer, in one POST.

What it does

  • Reads the PDF's text layer and rebuilds structure: headings from font size, numbering and ALL-CAPS lines; aligned rows as Markdown pipe tables; ligatures (fi, fl) expanded to plain letters.
  • Accepts a multipart upload, base64, a URL Sigli fetches, or an upload ID. Password-protected PDFs work with password.
  • In our test run, a 1.5 MB, 32-page standards document (NIST CSF 2.0) converted inline in about 1.1 s into 68,577 characters of Markdown.

curl

API=${SIGLI_API_URL:-https://api.sigli.dev}

curl -sS "$API/v1/process" \
  -H "X-API-Key: $SIGLI_API_KEY" \
  -H "X-Convert-Type: pdf-to-markdown" \
  -F "file=@nist_cswp29.pdf"
{
  "success": true,
  "meta": { "type": "pdf-to-markdown", "engine": "inhouse" },
  "data": {
    "markdown": "# The NIST Cybersecurity Framework (CSF) 2.0\n\n…",
    "processing_ms": 1130,
    "input_size_bytes": 1518858,
    "output_size_bytes": 68577
  }
}

Python

Files over 2 MB come back as HTTP 202 with a job_id; this script polls the job and reads markdown from it either way.

# Convert a PDF with a text layer to Markdown. Files over 2 MB are queued
# as a job (HTTP 202); poll it and read the markdown from the job.
# pip install requests
import os
import sys
import time

import requests

API = os.environ.get("SIGLI_API_URL", "https://api.sigli.dev")
HEADERS = {"X-API-Key": os.environ["SIGLI_API_KEY"]}

path = sys.argv[1] if len(sys.argv) > 1 else "nist_cswp29.pdf"

with open(path, "rb") as f:
    resp = requests.post(
        f"{API}/v1/process",
        headers={**HEADERS, "X-Convert-Type": "pdf-to-markdown"},
        files={"file": (os.path.basename(path), f, "application/pdf")},
        timeout=120,
    )

if resp.status_code == 422 and resp.json().get("code") == "no_text_layer":
    raise SystemExit("Scanned PDF: use X-Convert-Type: ocr instead")
resp.raise_for_status()
data = resp.json()["data"]

if resp.status_code == 202:
    job_id = data["job_id"]
    while data.get("status") not in ("completed", "failed"):
        time.sleep(3)
        job = requests.get(f"{API}/v1/jobs/{job_id}", headers=HEADERS, timeout=30)
        job.raise_for_status()
        data = job.json()["data"]
    if data["status"] == "failed":
        raise SystemExit(f"{data['code']}: {data['error']}")

out = os.path.splitext(path)[0] + ".md"
with open(out, "w", encoding="utf-8") as f:
    f.write(data["markdown"])
print(f"{out}: {len(data['markdown'])} chars in {data['processing_ms']} ms")

Scanned PDFs: 422 no_text_layer

An image-only PDF has no text to extract. Instead of returning empty Markdown, the API answers HTTP 422 with "code": "no_text_layer" and charges nothing. Send the same file to the OCR API (X-Convert-Type: ocr, output_format=markdown) — the Python OCR guide shows the fallback in code.

Pricing and limits

  • 3 credits per started 5 MB of input; failed requests are free. Included from the Starter plan ($29/month, 3,000 credits); new workspaces get a 14-day Starter trial.
  • Up to 50 MB per file. Over 2 MB runs as an async job — poll GET /v1/jobs/{job_id}.
  • Tables are rebuilt from aligned text; merged or multi-line cells can come out split, so review tables before relying on them.
  • The reverse direction, HTML into a PDF, is the HTML to PDF API.

FAQ

Does it work on scanned PDFs?

Not directly. A PDF without a text layer returns 422 with code no_text_layer and no credits are charged. Send the same file with X-Convert-Type: ocr to get its text.

How are tables handled?

Rows of aligned text are emitted as Markdown pipe tables. Complex layouts (merged cells, multi-line cells) can come out split or shifted, so check tables before feeding them to downstream parsers.

What does it cost?

3 credits per started 5 MB of input with the built-in extractor. Files over 2 MB are processed as an async job.

Try it on your own files

New workspaces start with a 14-day Starter trial (3,000 credits). Create an API key on the API Keys page and run the examples above as they are.