API recipes¶
This page collects copy-paste recipes for talking to the md-bridge
HTTP API from the three places contributors and operators ask about:
the shell (curl), Python (requests), and the browser or Node
(fetch).
The API is documented endpoint-by-endpoint at
/docs (Swagger UI on the
running service). This page focuses on the practical "give me a
working snippet" angle.
Endpoints at a glance¶
| Method | Path | Purpose | Request body | Response |
|---|---|---|---|---|
GET |
/api/health |
Liveness probe | empty | { "status": "ok" } |
POST |
/api/inspect-pdf |
Read PDF metadata without converting | multipart/form-data (file) |
JSON |
POST |
/api/pdf-to-md |
Convert PDF to Markdown | multipart/form-data (file + options) |
JSON |
POST |
/api/md-to-pdf |
Render Markdown to PDF | multipart/form-data (file + options) |
application/pdf |
Default host in every recipe is http://localhost:8000. Switch to
your deployed host as needed.
Upload limit on all POST endpoints is env-driven via
MD_BRIDGE_MAX_UPLOAD_MB (read at startup in apps/api/app/settings.py).
The code default is 500 MB, so a bare local run is unchanged; the public
deploy recipe lowers it (the Oracle bootstrap ships 50 MB). An oversized
upload returns 413 payload_too_large.
Health check¶
The cheapest sanity check.
curl¶
Python¶
import requests
r = requests.get("http://localhost:8000/api/health", timeout=5)
r.raise_for_status()
print(r.json()) # {'status': 'ok'}
JavaScript¶
const r = await fetch("http://localhost:8000/api/health");
const data = await r.json();
console.log(data); // { status: "ok" }
Inspect a PDF (no conversion)¶
Returns the document profile that the converter would build, without running the conversion. Useful for deciding whether OCR is needed before paying the conversion cost.
Response shape¶
{
"pages": 4,
"body_size_pt": 11.0,
"heading_sizes_pt": [18.0, 14.0, 12.5],
"fonts": [
{ "name": "Helvetica-Bold", "size": 18.0, "count": 6, "sample": "Introduction" }
],
"tagged": false,
"needs_ocr": false
}
needs_ocr: true means the PDF has no extractable text on at least
one page; the converter would emit a needs_ocr warning unless OCR
is enabled.
curl¶
Python¶
import requests
with open("whitepaper.pdf", "rb") as fh:
r = requests.post(
"http://localhost:8000/api/inspect-pdf",
files={"file": ("whitepaper.pdf", fh, "application/pdf")},
timeout=60,
)
r.raise_for_status()
report = r.json()
if report["needs_ocr"]:
print("This PDF is scanned. Install the OCR extra on the server "
"(it then runs automatically), or retry with force=true.")
JavaScript¶
const form = new FormData();
form.append("file", fileInput.files[0]); // browser <input type="file">
const r = await fetch("http://localhost:8000/api/inspect-pdf", {
method: "POST",
body: form,
});
if (!r.ok) {
throw new Error(`Inspect failed: ${r.status}`);
}
const report = await r.json();
PDF to Markdown¶
The most common conversion.
Options¶
The options field is a JSON string. All fields are optional.
| Field | Type | Default | What it does |
|---|---|---|---|
page_break |
bool | false |
Inserts --- between pages |
with_images |
bool | false |
Embeds images inline as base64 data: URIs (self-contained .md) |
front_matter |
bool | true |
Emits YAML front matter at the top |
lang |
string | "pt-BR" |
Affects warning translations only |
Response shape¶
{
"md": "---\ntitle: \"Whitepaper\"\npages: 4\n---\n\n# Introduction\n\n...",
"front_matter": {
"title": "Whitepaper",
"author": "Author Name",
"date": "2026-04-12",
"source": "whitepaper.pdf",
"pages": 4
},
"warnings": [],
"stats": { "headings": 6, "tables": 1, "bullets": 14 },
"ocr_applied": false
}
curl¶
Bare conversion:
With options and saving the Markdown to disk:
curl -X POST http://localhost:8000/api/pdf-to-md \
-F "file=@whitepaper.pdf" \
-F 'options={"page_break": true, "front_matter": true, "lang": "en"}' \
| jq -r '.md' > whitepaper.md
Python¶
import json
import requests
with open("whitepaper.pdf", "rb") as fh:
r = requests.post(
"http://localhost:8000/api/pdf-to-md",
files={"file": ("whitepaper.pdf", fh, "application/pdf")},
data={"options": json.dumps({"front_matter": True, "lang": "en"})},
timeout=300,
)
r.raise_for_status()
result = r.json()
if result["warnings"]:
print("Warnings:", result["warnings"])
print(f"Extracted {result['stats']['headings']} headings, "
f"{result['stats']['tables']} tables, "
f"{result['stats']['bullets']} bullets")
with open("whitepaper.md", "w", encoding="utf-8") as fh:
fh.write(result["md"])
JavaScript¶
Browser flow that posts a <input type="file"> and downloads the
Markdown as a file:
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("options", JSON.stringify({ front_matter: true, lang: "en" }));
const r = await fetch("http://localhost:8000/api/pdf-to-md", {
method: "POST",
body: form,
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.error?.message ?? `HTTP ${r.status}`);
}
const data = await r.json();
const blob = new Blob([data.md], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "converted.md";
link.click();
URL.revokeObjectURL(url);
Node.js with undici's FormData (Node 20+):
import { readFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
const buf = await readFile("whitepaper.pdf");
const form = new FormData();
form.append("file", new Blob([buf], { type: "application/pdf" }), "whitepaper.pdf");
form.append("options", JSON.stringify({ lang: "en" }));
const r = await fetch("http://localhost:8000/api/pdf-to-md", { method: "POST", body: form });
const result = await r.json();
await writeFile("whitepaper.md", result.md);
Markdown to PDF¶
The renderer returns the PDF as the raw response body
(application/pdf), with a Content-Disposition: attachment;
filename="<name>.pdf" header.
Options¶
| Field | Type | Default | What it does |
|---|---|---|---|
lang |
string | "pt-BR" |
Sets <html lang> and affects hyphenation hints |
curl¶
With language option:
curl -X POST http://localhost:8000/api/md-to-pdf \
-F "file=@notes.md" \
-F 'options={"lang": "en"}' \
--output notes.pdf
Python¶
import json
import requests
with open("notes.md", "rb") as fh:
r = requests.post(
"http://localhost:8000/api/md-to-pdf",
files={"file": ("notes.md", fh, "text/markdown")},
data={"options": json.dumps({"lang": "en"})},
timeout=300,
)
r.raise_for_status()
with open("notes.pdf", "wb") as fh:
fh.write(r.content)
JavaScript¶
Browser flow that posts a Markdown file and triggers a download of the PDF:
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("options", JSON.stringify({ lang: "en" }));
const r = await fetch("http://localhost:8000/api/md-to-pdf", {
method: "POST",
body: form,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "rendered.pdf";
link.click();
URL.revokeObjectURL(url);
Error handling¶
Every failure returns the same envelope:
{
"error": {
"code": "<machine_readable>",
"message": "<human_readable>",
"detail": "<optional, varies by code>"
}
}
The HTTP status reflects the class of error:
| Status | Code examples | When |
|---|---|---|
400 |
wrong_file_type |
The upload's filename does not match the expected extension |
413 |
payload_too_large |
The upload exceeded 500 MB |
422 |
invalid_options |
The options JSON failed to parse or failed Pydantic validation |
500 |
depends | Renderer crashed, OS dep missing, etc. |
Reading the envelope¶
curl¶
curl -sf -X POST http://localhost:8000/api/md-to-pdf \
-F "file=@notes.txt" \
--output notes.pdf
echo $? # non-zero on failure; --fail-with-body prints the JSON
Pretty-printing the error body:
curl -X POST http://localhost:8000/api/md-to-pdf \
-F "file=@notes.txt" \
--fail-with-body \
--output - | jq .error
Python¶
import requests
r = requests.post(
"http://localhost:8000/api/md-to-pdf",
files={"file": ("notes.txt", b"# wrong extension", "text/plain")},
)
if not r.ok:
err = r.json().get("error", {})
raise RuntimeError(f"[{err.get('code')}] {err.get('message')}")
JavaScript¶
const r = await fetch("http://localhost:8000/api/md-to-pdf", {
method: "POST",
body: form,
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
const err = body.error ?? {};
throw new Error(`[${err.code ?? r.status}] ${err.message ?? "request failed"}`);
}
CORS and cross-origin requests¶
The dev server in apps/api/app/main.py allows requests from
http://localhost:5173 (the Vite dev server). For other origins,
set the MD_BRIDGE_CORS_ORIGINS environment variable to a
comma-separated list when starting the API:
MD_BRIDGE_CORS_ORIGINS="https://app.example.com,https://docs.example.com" \
uvicorn app.main:app --app-dir apps/api
If the browser console shows CORS policy: No 'Access-Control-Allow-Origin',
the origin making the call is not in that list.
Rate limits and concurrency¶
There is a built-in, opt-in rate limiter, off by default. Set
MD_BRIDGE_RATE_LIMIT to the number of requests allowed per window per
client IP on the conversion routes, and MD_BRIDGE_RATE_WINDOW_SECONDS
for the window (default 60). Over the limit, the route returns
429 rate_limited. Counters live in the process, so the limit is per
uvicorn worker: the shipped image runs one worker, and behind a reverse
proxy every request arrives from the proxy's address unless uvicorn runs
with --forwarded-allow-ips and the proxy sets X-Forwarded-For. There
is no shared store, so multi-worker or multi-instance counters do not
add up.
Work limits¶
Heavy conversions (PyMuPDF, Chromium, optional Tesseract) are gated so a large document or a burst cannot exhaust the box. These are in-process, per worker, and read at startup:
| Env var | Default | Effect |
|---|---|---|
MD_BRIDGE_MAX_CONCURRENCY |
2 |
Simultaneous heavy conversions. Over it, requests wait. |
MD_BRIDGE_QUEUE_MAX |
8 |
Requests allowed to wait for a slot; 0 rejects immediately when full. |
MD_BRIDGE_QUEUE_WAIT_SECONDS |
10 |
How long a request waits before 503 service_busy (with Retry-After). |
MD_BRIDGE_CONVERT_TIMEOUT_SECONDS |
300 |
Per-request wall clock; over it returns 504 conversion_timeout. 0 disables. |
MD_BRIDGE_MAX_PDF_PAGES |
0 |
Reject PDFs above this page count with 422 too_many_pages. 0 = unlimited. |
Unlike the auth and rate-limit knobs, concurrency and the timeout default to a safe value rather than off: unbounded concurrency is a resource footgun (two Chromium renders already compete), and a single-user local run never notices a cap of 2. The concurrency cap is a single global semaphore, so it does not multiply per route.
Ceiling: a timed-out conversion returns 504 to the client but its worker thread cannot be cancelled, so it keeps its slot until it finishes. A hard kill would need per-subprocess isolation.
- Process-level concurrency:
uvicorn --workers Nruns N separate processes; each keeps its own semaphore and rate-limit counters, so the effective caps are N times the configured values.
For a shared per-IP limit across workers or instances, put a reverse
proxy in front (nginx with limit_req, Traefik with the rate-limit
middleware, or Caddy with rate_limit).