PDF to JSON
POST a PDF and get its tables and labelled fields back as JSON, with a bounding box on every value. No OCR, no model, no prompt — the same document always gives the same answer.
Prefer to try it before reading? The extraction playground runs this endpoint live — drop a PDF in and watch the tables come out.
Start here
/v1/extractSame API key as rendering, same error shape, same idempotency header. If you already call /v1/render, you are already set up.
curl -X POST https://api.pdfcraft.dev/v1/extract \
-H "Authorization: Bearer $PDFCRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"file\": \"$(base64 -w0 invoice.pdf)\"}"What comes back
Three things: the labelled fields it found, the tables it reconstructed, and where each one sits on the page.
{
"id": "ext_01M2BBQQPGWPF8PYPG4C1HQBSC",
"pages": 1,
"page_count": 1,
"fields": {
"Invoice Number": {
"value": "INV-2026-0417",
"raw": "INV-2026-0417",
"page": 1,
"bbox": [115, 55, 176, 64]
}
},
"tables": [
{
"page": 1,
"header": ["DESCRIPTION", "QUANTITY", "UNIT PRICE", "TOTAL"],
"rows": [
["Professional Services", "10", "1,000.00", "10,000.00"],
["Laptop — MacBook Air M4", "2", "50,000.00", "100,000.00"]
],
"bbox": [30, 133, 565, 230]
}
],
"usage": { "pages": 1 },
"duration_ms": 14
}Tables
Column detection is geometric, not a guess about borders — a table with no ruling lines reconstructs exactly as well as one with them. Cells are matched by position across the row, which is what makes right-aligned money columns work: 10 and 50,000.00 start at completely different x positions but belong to the same column.
A table broken across pages is stitched back together. Consecutive pages whose header matches become one table, and the repeated header rows are dropped rather than landing in your data — a fourteen-page bank statement comes back as one table of 560 rows.
A header is only reported when the geometry proves one: a column that is numeric all the way down the body, with a non-numeric cell at the top. A totals block that is data all the way down gets "header": null rather than an invented first row.
Fields and schemas
Without a schema, every Label: value pair on the page comes back keyed by the label as printed. With one, you name the fields and the types you want.
{
"file": "JVBERi0xLjQK…",
"schema": {
"invoice_number": { "type": "string", "match": "Invoice Number" },
"issued": { "type": "date", "match": "Invoice Date" },
"total": { "type": "number", "match": "Total" }
}
}Matching ignores case and punctuation, so invoice_number finds Invoice Number: without being told. Numbers accept thousands separators, Indian digit grouping, a currency symbol and accounting negatives in parentheses.
A field the document does not contain comes back as null with HTTP 200. You asked whether it was there; no is a real answer, not an error.
Ambiguous dates are left as text on purpose. 03/04/2026 is two different days depending on who printed it, and an extraction API that quietly moves a due date by nine months is not something you can recover from. 21/08/2026 is unambiguous and becomes 2026-08-21; the original is always in raw.
Bounding boxes
Every value carries the page it came from and a bbox of [x0, y0, x1, y1] in PDF points, origin top-left — the same origin a browser uses, so you can draw the box over a rendered page without converting anything.
This is the part a prompt cannot reproduce. A model reading your document never sees coordinates, so it cannot tell you where it found a number. When a customer disputes a figure, the box is how you show them the line it came from.
PDF, URL or HTML
Exactly one of file, url or html. file is base64 PDF bytes or an https link to one; url and html are rendered first with the same pinned Chromium that powers the render API, then extracted. One call, one charge.
curl -X POST https://api.pdfcraft.dev/v1/extract \
-H "Authorization: Bearer $PDFCRAFT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/invoices/1042",
"schema": { "total": { "type": "number", "match": "Total" } }
}'Use options.pages to read part of a long document — "pages": "1-5" — and you are billed for those pages only.
Async and polling
/v1/extract/asyncSame shape plus a callback_url. Returns 202 with an id immediately and signs the completion callback with the same webhook secret render callbacks use.
/v1/extractions/:idPolls one extraction and hands back a signed URL to the result once it has succeeded. An extraction id is not a render id: each endpoint answers only for its own kind, and the other returns 404.
What this does not do
Worth reading before you try it, because these are the answers that decide whether this is the right tool.
- No OCR. A scanned page or a photograph has no text layer to read. You get HTTP 422 with
extraction_failed, and you are not billed, because charging for a no that took twenty milliseconds to determine would be rude. - No model, no prompt, nothing to tune. That is the point: the output is reproducible and the cost does not scale with how hard your document is.
- No password-protected PDFs. Decrypt before sending. We do not accept a password because we would then have your document password in a request log.
- PDF in, JSON out. Not DOCX, not XLSX. If your source is a web page or HTML, send it directly — that path is supported above.
- 500 pages per request. Use
options.pagesbeyond that.
FAQ
Does it work on scanned PDFs?
No. This endpoint reads the text layer a PDF already carries. A scan or a photo has no text layer, so it returns HTTP 422 with code extraction_failed and you are not billed for it. There is no OCR.
Does it use an LLM?
No. Extraction is geometric: text runs are grouped into lines, lines are split into columns on horizontal whitespace, and a table is a run of consecutive lines with the same column count. The same PDF always produces the same JSON, and nothing is guessed.
Can it extract a table that continues across several pages?
Yes. Consecutive pages carrying the same header are stitched into one table and the repeated header rows are dropped, so a fourteen-page statement comes back as one table rather than fourteen.
How is it billed?
Per page read, not per request. A page range means you are charged only for the pages in it. Failures that never read a page are free.
Can I extract from a URL or from HTML instead of a PDF?
Yes. Send url or html instead of file and it is rendered with the same Chromium that powers the render API, then extracted. One call.
Where to go next
The API reference covers authentication, idempotency and rate limits — all of which work identically here. The error reference lists every code including unsupported_file and extraction_failed, and pricing is per page.