API documentation
Quickstart
1. Get a free key (200 pages/month, no card):
2. Extract tables from any PDF:
curl -X POST https://<host>/v1/extract \ -H "X-API-Key: your_key" \ -F "file=@report.pdf" \ -F "format=json,markdown"
3. Read the response — the parts that matter:
{
"tables": [{
"id": "t1",
"page_range": [3, 4], // table spanned two pages: stitched back together
"confidence": 0.94, // calibrated: ~94% of tables scored here are fully correct
"headers": ["Region", "Q1 2026", "Q2 2026"],
"rows": [["EMEA", "1,204", "1,377"]], // merged cells resolved, values duplicated
"spans": [...], // original merged-cell geometry
"markdown": "<!-- pages 3-4 -->\n| Region | ... |", // drops straight into a RAG pipeline
"notes": ["stitched_across_pages"]
}],
"warnings": [], // {code, message, page?} — switch on code
"usage": {"pages_billed": 10, "vision_pages": 1, "vision_page_multiplier": 3}
}
Pricing
200 pages/mo
$0
2,000 pages/mo
$15/mo
15,000 pages/mo
$49/mo
Scanned pages bill at 3× . Pages that need the vision
fallback (scans, image-only pages) count as 3 billed pages each — this is
the headline rule, not fine print. Every response's usage
block shows exactly what was billed and why. Native digital PDFs bill 1
page per page.
What to expect (honest capability notes)
- Native/digital PDFs extract in well under a second and make up the large majority of documents; financial statements get column normalization and header recovery automatically.
- Scanned pages engage the vision fallback automatically (when
configured), capped at 30 vision pages per synchronous request
(
413 vision_page_limit— split larger documents). - Confidence scores are honest: low means low. Tables the pipeline
couldn't structure well say so via
notes,warnings, and a lower score, never silently. - Known limitations: borderless term/definition-style layouts on
unruled pages may be conservatively skipped; header labels for unlabeled
columns come back as
col_N.
Interactive reference (auto-generated): /docs (Swagger UI).
Full endpoint reference
Rendered from the canonical contract
(docs/API_SPEC.md) — single source of truth, includes the
complete error-code and warning-code tables.
API Specification
Status: stable draft — this contract is what Week 2 builds against. Changes require a DECISIONS.md entry.
Base URL: https://<host>/v1 — all routes are versioned under /v1
(decision 2026-07-12). /v2 is the future escape hatch; nothing lives at the
root.
Authentication
Every endpoint except GET /v1/health requires an API key.
| Channel | Mechanism |
|---|---|
| Direct (Stripe-issued keys) | X-API-Key: <key> request header |
| RapidAPI listing | RapidAPI proxy headers (X-RapidAPI-Proxy-Secret), validated by the RapidAPI auth provider |
The auth middleware resolves either channel to an internal principal (key id, plan, quota state). Handlers never see platform-specific headers.
Resolution order (production): a request carrying
X-RapidAPI-Proxy-Secret is RapidAPI traffic — an invalid secret is
rejected outright (origin-direct requests forging RapidAPI headers never
fall through to key auth). Otherwise X-API-Key resolves against our
issued keys. Otherwise 401. The web tool's /web/extract path is
separate (anonymous, rate-limited). The response envelope is identical on
every channel. Quotas: direct keys are metered here (vision pages at 3x);
RapidAPI plan limits are enforced by RapidAPI's gateway — we record
consumption for telemetry only.
Missing/invalid credentials → 401. Exhausted monthly page quota → 429 with
code quota_exceeded. Request-rate throttling → 429 with code
rate_limited and a Retry-After header.
GET /v1/health
Unauthenticated liveness probe.
Response 200:
{ "status": "ok", "version": "0.1.0" }
POST /v1/extract
Extract tables from a single PDF.
Request
Two accepted content types:
1. multipart/form-data (preferred)
| Field | Type | Required | Description |
|---|---|---|---|
file |
file | yes | The PDF. |
pages |
string | no | Page selection (see below). |
format |
string | no | Output format selection (see below). |
2. application/json (for callers that can't do multipart)
{
"file_base64": "<base64-encoded PDF bytes>",
"filename": "report.pdf",
"pages": "1,3,5-10",
"format": "json,markdown"
}
pages and format may also be given as query parameters with either content
type; query parameters take precedence over body fields.
Parameters
pages — which pages to process. Default "all".
1-indexed. Comma-separated page numbers and inclusive ranges: "1,3,5-10".
Out-of-range pages → 400 invalid_pages. Pages are billed only for pages
actually processed.
format — which representations each table carries. Default "json".
Comma-separated set of:
| Value | Effect on each table object |
|---|---|
json |
headers + rows + spans populated |
markdown |
markdown populated (GitHub-flavored table) |
csv |
csv populated (RFC 4180, \r\n line endings) |
Structural metadata (page_range, confidence, extraction_layer, notes)
is always present regardless of format. Unknown format value → 400
invalid_format.
Limits
| Limit | Value | Violation |
|---|---|---|
| Max file size | 50 MB | 413 file_too_large |
| Max pages per request | 200 | 413 too_many_pages |
| Max vision-fallback pages per request | 30 | 413 vision_page_limit |
| Content type | PDF only (%PDF magic bytes) |
415 unsupported_file_type |
| Encrypted/corrupt PDF | must be openable | 422 unprocessable_pdf |
The vision-page cap bounds worst-case latency and cost for the synchronous
API. Requests that would exceed it fail fast with guidance to split the
document or narrow pages; nothing is billed. (Async processing for large
scanned documents: see POST /v1/jobs, planned.)
Response 200
{
"document": {
"filename": "report.pdf",
"page_count": 42,
"pages_processed": 8
},
"tables": [
{
"id": "t1",
"page_range": [3, 4],
"extraction_layer": "fast_path",
"confidence": 0.94,
"headers": ["Region", "Q1 2026", "Q2 2026"],
"rows": [
["EMEA", "1,204", "1,377"],
["APAC", "980", "1,041"]
],
"spans": [
{ "row": 0, "col": 1, "rowspan": 1, "colspan": 2 }
],
"markdown": "<!-- pages 3-4 -->\n| Region | Q1 2026 | Q2 2026 |\n| --- | --- | --- |\n| EMEA | 1,204 | 1,377 |\n| APAC | 980 | 1,041 |",
"csv": "Region,Q1 2026,Q2 2026\r\nEMEA,\"1,204\",\"1,377\"\r\nAPAC,980,\"1,041\"\r\n",
"notes": ["stitched_across_pages", "merged_cells_resolved"]
}
],
"warnings": [],
"usage": {
"pages_billed": 10,
"vision_pages": 1,
"vision_page_multiplier": 3
},
"processing_time_ms": 843,
"request_id": "req_8f2c1a"
}
Field reference
document
| Field | Type | Description |
|---|---|---|
filename |
string | null | As uploaded / provided; null if unknown. |
page_count |
int | Total pages in the PDF. |
pages_processed |
int | Pages actually examined after pages selection. |
tables[] — ordered by first page, then vertical position.
| Field | Type | Description |
|---|---|---|
id |
string | Stable within the response (t1, t2, …). |
page_range |
[int, int] | First and last 1-indexed page the table spans. Single-page tables repeat the page: [3, 3]. |
extraction_layer |
"fast_path" | "vision_fallback" |
Which layer produced the cells. Stitched tables mixing layers report "vision_fallback" (the costlier/weaker signal wins). |
confidence |
float | 0.0–1.0, per-table. Calibrated: ~0.9 should mean ~90% of such tables are fully correct. Honest scores are a product feature — never inflate. |
headers |
string[] | null | Detected header row(s), flattened to one label per column. null if no header detected (rows then start at the first data row — nothing is guessed). |
rows |
(string | null)[][] | Data rows, header rows excluded. Merged cells are resolved: the value is duplicated into every covered position. Empty cells are null, not "". |
spans |
object[] | Original merged-cell geometry: {row, col, rowspan, colspan}, 0-indexed against rows (header spans indexed against headers are not reported). Empty array if no merges. |
markdown |
string | Only when format includes markdown. Starts with a <!-- pages N-M --> provenance comment (RAG pipelines want source pages). If no header was detected, a synthesized col_1, col_2, … header row is used (GFM requires one) and the table's confidence is capped. |
csv |
string | Only when format includes csv. Header row present only when actually detected — never synthesized. |
notes |
string[] | Machine-readable processing flags. Known values: stitched_across_pages, merged_cells_resolved, header_inferred, ragged_rows_padded, low_confidence_cells. |
warnings[] — document/page-level conditions, structured (decision
2026-07-14):
{ "code": "vision_unavailable", "message": "page 7: vision fallback not configured; fast-path results returned (may be low quality)", "page": 7 }
| Field | Type | Description |
|---|---|---|
code |
string | Stable snake_case identifier — switch on this, never parse message. Vocabulary below; new codes may be added (treat unknown codes as informational). |
message |
string | Human-readable; wording may change freely between releases. |
page |
int | Optional, 1-indexed; present when the warning is page-scoped. |
Scoping rule: warnings are document/page level; table-scoped
conditions are machine-readable flags in each table's notes. When a
table-level condition is worth surfacing at document level (e.g.
ambiguous_merge), the warning carries the page and the table keeps its
note — clients rendering per-table UI should read notes; clients triaging
whole documents should read warnings. The two mechanisms never replace
each other.
Warning code vocabulary:
code |
page? |
Emitted when |
|---|---|---|
vision_unavailable |
yes | The page needed the vision fallback but no provider is configured; fast-path results returned instead. |
vision_parse_failed |
yes | The vision provider responded but never produced parseable table JSON (after one correction retry); fast-path results returned instead. |
vision_page_limit_partial |
yes | Reserved — not currently emitted. Requests over the vision-page cap fail with 413 vision_page_limit today; this code is pre-registered so a future partial-processing mode is non-breaking. |
ambiguous_merge |
yes | Empty cells look like merged cells but the pattern is ambiguous; left empty rather than guessed, table confidence lowered (table note: ambiguous_merge_left_empty). |
stitch_uncertain |
yes | A multi-page table was stitched on column alignment alone (no repeated header on the continuation page); verify the join (table note: stitched_across_pages). |
usage
| Field | Type | Description |
|---|---|---|
pages_billed |
int | Pages counted against the monthly quota. Vision pages bill at 3x: pages_billed = (pages_processed − vision_pages) + 3 × vision_pages. |
vision_pages |
int | Pages that required the vision fallback. |
vision_page_multiplier |
int | Currently always 3. Echoed so billing is auditable per response. Must appear on any pricing page — it is not fine print. |
A PDF with no detectable tables is a success: 200 with tables: [] and
an explanatory warning.
Response headers
| Header | Description |
|---|---|
X-Request-Id |
Mirrors request_id; include in support requests. |
Retry-After |
On 429 only. Seconds to wait. |
Errors
All errors use one envelope:
{
"error": {
"code": "file_too_large",
"message": "PDF is 61.2 MB; the limit is 50 MB.",
"request_id": "req_8f2c1a"
}
}
message is neutral, human-readable prose — no marketing copy, and wording
may change; switch on code. An optional docs_url field may appear when
there is a relevant pointer (e.g. rate_limited includes where to get an
API key); it is omitted otherwise.
| HTTP | code |
When |
|---|---|---|
| 400 | invalid_pages |
Unparseable or out-of-range pages value. |
| 400 | invalid_format |
Unknown format value. |
| 400 | invalid_request |
Missing file, malformed base64, malformed multipart. |
| 401 | unauthorized |
Missing or invalid API key. |
| 413 | file_too_large |
File exceeds 50 MB. |
| 413 | too_many_pages |
Page selection exceeds 200 pages. |
| 413 | vision_page_limit |
Document needs more than 30 vision-fallback pages in one request. Message includes guidance: split the document or narrow pages. Nothing is billed. |
| 415 | unsupported_file_type |
Not a PDF. |
| 422 | unprocessable_pdf |
Encrypted, corrupt, or zero-page PDF. |
| 429 | rate_limited |
Request-rate throttle. Has Retry-After. |
| 429 | quota_exceeded |
Monthly page quota exhausted. |
| 500 | internal_error |
Our bug. Nothing billed. |
| 503 | vision_unavailable |
Vision provider outage and the document required fallback. Fast-path-only documents are unaffected by provider outages. |
Billing rule: pages are billed only on 2xx responses, per the formula in
usage above.
POST /v1/jobs — planned, not implemented
Reserved for async processing of documents that exceed the synchronous vision-page cap (decision 2026-07-12). Do not build against this section; it exists so adding async later is non-breaking. Intended shape:
POST /v1/jobs— same payload as/v1/extract→202with{ "job_id": "job_...", "status": "queued" }GET /v1/jobs/{job_id}—{ "status": "queued" | "processing" | "done" | "failed", ... }with the standard extract response embedded underresultwhen done.
Open design questions (deliberately unresolved): result retention window vs. the process-and-discard invariant, webhook completion callbacks, job-level page limits.
Plans (for reference)
| Plan | Pages / month | Price |
|---|---|---|
| Free | 200 | $0 |
| Starter | 2,000 | $15 |
| Pro | 15,000 | $49 |