One endpoint. Format version 4. Base URL https://api.consensis.app.
Every request carries an API key — Authorization: Bearer pk_live_… or the X-API-Key header. Sign in (Google or email, signing in creates the account) and issue keys on your account page. The plaintext key is shown once; we store only a digest. A key we cannot match returns 401 — a mistyped key is never silently downgraded to the anonymous trial, so you always know which account a job was charged to.
mode=batch), minimum top-up US$3.50. Failed pages are never charged.The free trial works straight from the API: 10 free pages a day per address, no Authorization header at all (the web uploader has its own separate 10; past the quota the response carries Retry-After).
# completely anonymous — no key, no signup
curl -X POST https://api.consensis.app/v1/parse -F "[email protected]"
# → 202 {"id": "…", "status": "queued", ...} — poll the same way as below
curl -X POST https://api.consensis.app/v1/parse \
-H "Authorization: Bearer pk_live_…" \
-F "[email protected]" -F "pages=10"
# → 202 {"id": "b7f395cf7fbc", "status": "queued", ...}
curl -H "Authorization: Bearer pk_live_…" \
https://api.consensis.app/v1/parse/b7f395cf7fbc
# → poll until "status": "done", then usage and result are included
curl -OJ -H "Authorization: Bearer pk_live_…" \
"https://api.consensis.app/v1/parse/b7f395cf7fbc/export?format=xlsx"
json and zip carry everything (confidence, sources, readings, bbox); xlsx keeps the confidence flags visible — disputed cells shaded red, unverified yellow — plus a flat cells sheet for audit; csv is values only: it cannot express which cells are disputed, so pair it with the JSON when trust matters.Asynchronous — POST returns 202 immediately; follow up with the id. 10 pages take ≈600 s from cold; cache hits are far faster. Don't infer completion from stage — that string is for humans; watch status.
-F "mode=batch" to the POST. Same extraction, same guarantees, same result shape; the difference is time — one reading leg runs on a batch endpoint. Measured 4–55 minutes across nine runs, but that is an observation, not a promise: the upstream batch service targets 24 hours and says so. What we do promise is a floor — if the batch leg hasn't come back in 4 hours we re-run it on the realtime pipeline and you still get your result, still at the batch price. We absorb the difference. Poll the same way; the job reports "mode": "batch". Pick it for overnight runs and large backfills where price beats latency.This is what the API sells. Not "better extraction" — every cell can account for itself.
{"value": "1432",
"confidence": "verified",
"sources": ["vision-1", "vision-2"],
"bbox": [436.5, 577.7, 459.8, 591.9]}
Two independent readings agreed, so the cell is verified and carries no readings — the value is the value.
readings is the key difference: when readings disagree you get every version — we don't silently pick one. When the pick is wrong, you'd have no clue where to look. Present only when there is disagreement:
{"value": "1432",
"confidence": "disputed",
"provisional": true,
"sources": ["vision-1", "vision-2"],
"readings": {"vision-1": "1432", "vision-2": "1482"}}
Appears only when confidence is disputed, and is always true.
readings before you act on it, and don't import it straight into a live system.| confidence | Meaning |
|---|---|
exact | read directly from a structured file — no interpretation involved |
verified | multiple independent readings agree |
confirmed | settled by a human looking at the original page |
reviewed | adjudicated by the AI reviewer against the original page image — fast and measured, but not independently verified |
resolved | a majority of readings agree |
unverified | only one reading exists — nothing to cross-check against |
disputed | independent readings disagree — these cells need a human |
These are not vendor names. An audit asks "how many independent readings", not "which vendor".
| sources | What it is |
|---|---|
ocr-1 | an OCR engine from one model family |
ocr-2 | an OCR engine from a different family — only different families can catch same-source bias |
ocr-3 | a third OCR family. Not enabled in the current service — the name stays in the vocabulary because the contract shouldn't change when our configuration does. |
pdf-layout | the PDF's own layout geometry (most accurate — and free — when rulings and a text layer exist) |
pdf-grid | grid lines inferred from filled rectangles |
pdf-model | a trained layout model reading the PDF's internal structure — no OCR, no reliance on rulings |
pdf-text | the PDF's text layer |
source-data | read directly from a structured file (XLSX/CSV — no interpretation) |
vision-review | adjudicated against the original page image |
layout | the layout reading found a figure at this position |
ocr | a second reading independently found a figure overlapping the same box — this is the one that makes an image verified |
pdf-image | the PDF itself has an embedded image object here. On its own this is not a second opinion — it says the file contains an image, not that we framed the right one |
vision-1 | one of two independent vision readers, each looking at the page image and reconstructing the table grid |
vision-2 | the second vision reader — a different model family from vision-1, so the two can disagree independently |
vision-single | read by vision, but only one reading exists for this cell — no second opinion corroborated it |
vision-audit | confirmed by the audit pass — a separate reading of the original page image that reviewed the whole table and raised no objection to this cell. It is a genuine second opinion, but it reviews rather than transcribes. |
vision-arbiter | an extra reading that settled a disagreement between the two vision readers — not one of the two |
vision-grid | recovered by re-reading the page image to rebuild the grid, where the first pass left holes or a misaligned row |
human-review | checked by a human on the side-by-side review page — every other source interprets the page; this one is a person confirming or correcting it |
[x0, y0, x1, y1] in PDF points (1/72 inch), origin top-left; the page number is on the table's page.
usage.cells_with_bbox reports exactly how many cells have one for your document — the number isn't hidden.The section above says what one cell carries. This says how a table is shaped around them.
{
"page": 7,
"index": 0,
"order": null,
"kind": null,
"layout_disputed": true,
"column_major": false,
"header": [
"model",
"d"
],
"row_groups": [],
"header_cells": [
{
"value": "model",
"confidence": "unverified",
"sources": []
},
{
"value": "d",
"confidence": "unverified",
"sources": []
}
],
"rows": [
[
{
"value": "LB 08",
"confidence": "unverified",
"sources": []
},
{
"value": "1432",
"confidence": "verified",
"sources": [
"vision-1",
"vision-2"
]
}
]
]
}
header is the plain column names — derived from header_cells, never computed separately, so the two can't drift. header_cells is the same names with confidence and sources attached: a wrong column name makes the whole column mean something else, and you can't tell by looking.
rows is a list of rows, each a list of cells. column_major tells you whether the source table ran across or down; we always deliver rows, this records what the page did.
row_groups is the rotated side classification you see on spec tables (Dimensions (mm), Load capacity) — one label per row. It isn't a column, because column count is what cross-checking is built on, but without it you can't tell whether C₀ is a load or a length. Empty when the table has no grouping.
These follow the same rule as unreadable: a false on every table is noise, and what you want to scan for is the exception.
| Field | When it appears |
|---|---|
caption | A qualifier that belongs to the whole table (e.g. HG - 15, 20), inherited from the group it was delivered under. |
kind | What sort of table we think it is. Present on every table; the value is a short label, not a promise. |
layout_disputed | The layout readings don't agree there is a table here. We deliver what we extracted anyway — but check this one against the original page before you trust it. This is a different axis from the cell-level confidence: it questions whether the table exists, not whether a value was read right. |
ocr_unsupported | No independent OCR reading found overlapping content for this table. Same spirit as above: flagged, not dropped. |
span_grid | The merged-cell grid behind a table whose cells span rows or columns. The flat rows you get is that grid flattened; this is the original shape. |
colbands | Column bands — a header that groups several columns under one label. |
unfolded | This table was reassembled across a fold-out page. |
sheet | The worksheet name. Only for spreadsheet input, which is switched off for now — the field stays described so the contract doesn't change when we turn spreadsheets back on. |
segments (each part of the code: what it's called, which values are allowed) and one example_code. The table itself is delivered unchanged — anything you already read keeps working.Each table also carries bbox — the whole table's frame on the page in the same PDF-point coordinates as cells, prose and images (the union of its cell boxes; absent when none of its cells has one) — and order, its position in the page's reading order. When the layout reading disagrees about how many tables a page has, order is measured from bbox instead: it equals the order of the last prose or image block above the table. Together with pages[].width/height that is enough to rebuild the page as a structured layout.
The result is not only tables. Every document also carries prose (the running text — headings and paragraphs) and images (cropped figures). Both are cross-checked the same way cells are, and both say who corroborated them.
{"page": 7,
"index": 2,
"text": "Preload classes are selected according to the rail size.",
"confidence": "verified",
"sources": ["ocr-1", "ocr-2"],
"bbox": [56.2, 98.6, 213.6, 236.9],
"role": "paragraph",
"level": 0,
"order": 4}
One entry per text block, numbered per page. role is heading or paragraph; level is the heading depth (0 for paragraphs); order is the position in the page's reading order, shared with images so you can interleave them.
The reading that renders the text is not counted as its own witness. A paragraph is verified when a different reading of the page contains the same content — so the sources you see are the ones that agreed, never the one being checked. Paragraph boundaries don't have to match: readings split text differently, and that is a formatting difference, not a disagreement.
verified across 7 documents (2,812 paragraphs). The spread is real and depends on the document — the same caveat as bbox. Scans sit at the low end.{"page": 7,
"index": 1,
"path": "images/p007_00.png",
"confidence": "verified",
"bbox": [56.2, 98.6, 213.6, 236.9],
"order": 3,
"also_on": [9],
"sources": ["layout", "ocr", "pdf-image"]}
path is relative to the result bundle (the zip export); also_on lists other pages carrying a byte-identical copy — repeats are reported, not removed, because the same dimension drawing legitimately appears under two part numbers.
Images use their own source vocabulary, because the question is "is there a figure here", not "was this value read correctly". Note what does not make an image verified: pdf-image alone. The PDF containing an embedded image at that spot says the file has a picture, not that we framed the right one — an earlier version treated it as sufficient and three documents came back 100% verified while the odds of "missed a figure entirely" were structurally zero.
[{"page": 6, "width": 612.0, "height": 792.0},
{"page": 7, "width": 612.0, "height": 792.0, "unreadable": true}]
source_pages sits next to it when you asked for a page range: it maps the position in the delivery back to the page number in your original file, so a result can always be pointed at the page it came from.
One entry per page you asked for. width and height are the canvas size in PDF points — without them a bbox has no frame to sit in.
unreadable: true does not mean "this page had no tables." It means we read the page and both independent readings failed — nothing from that page is in this delivery, and you should look at the original. A page that genuinely has nothing on it carries no flag at all.
The flag appears only on pages that failed, so scanning for it is cheap. One bad page no longer sinks the document: the other pages are delivered normally. If every page fails we refuse the job instead of handing you an empty result.
| Limit | Value | Why this number |
|---|---|---|
| File size | 600 MB | Measured from real scanned catalogues (121, 266, 443, 456 MB) — not guessed. |
| Pages | 500 pages | The thickest catalogue we've measured is 235 pages, and one document at 500 pages already occupies the queue for roughly two hours — past that a single job would hold up everyone behind it. The free trial covers 10 pages; self-hosted installs tune PDFPAGES_MAX_PAGES. |
| Formats | Extensions don't count — we look at the content. Images and spreadsheets are switched off for now — the pipeline handles them, but they'll come back with their own pricing. | |
| Concurrency | one document at a time | The pipeline saturates the CPU; the queue is a single worker. Extra submissions queue up — they don't fail. |
| Restarts | re-queued at most 2 times | Jobs interrupted by a service restart are re-queued (cached, so the re-run costs almost nothing); beyond that they fail honestly instead of looping forever. |
| Retention | 24 hours after completion | We don't keep your data. Results are available synchronously; fetch crops within the window — then the whole job, OCR cache included, is deleted. DELETE /v1/parse/{id} deletes earlier. The trade-off: resending the same document later is processed — and billed — as a new job; no copy survives to shortcut it. |
Python-urllib/3.x) are refused by our CDN's bot rules before they reach us — you get a CDN error page, not one of the errors below, so it reads like the service is down. requests, httpx and curl are fine as they are; with urllib, set any User-Agent of your own.Always {"detail": "…"}, written for humans — never a server path, never a key name.
| Status | When | Notes |
|---|---|---|
400 | Unrecognised format | The file extension doesn't count — we look at the content. Supported: PDF. |
401 | Unknown API key | The key in Authorization / X-API-Key does not match any account. A mistyped key is never downgraded to the anonymous trial; omit the key to use it deliberately. |
400 | A format we've switched off | We're temporarily accepting PDF only. Images and spreadsheets are supported by the pipeline but switched off — they'll come back with their own pricing. This is a different error from the one above: the file is fine, we're just not taking it right now. |
400 | File too large, or empty | The limit is 600 MB. |
400 | pages below 1 | Ask for as many pages as you need; the server enforces the ceiling. |
400 | Unsupported export format | GET accepts xlsx, csv, json and zip (the result bundle: dataset.json plus every cropped image); POST /v1/export accepts xlsx and csv. |
413 | Stateless export body too large | POST /v1/export accepts datasets up to 600 MB. |
402 | Insufficient balance | Prepaid plans: this document's cost (pages × per-page price) exceeds your balance — jobs run only when the balance covers them. The response states your remaining balance and what this document needs — top up and resend; nothing is queued and nothing is charged. |
429 | Anonymous trial rate limit | The no-signup trial has hourly/daily page limits (abuse protection, not pricing). The response carries Retry-After; signed-in prepaid accounts are not subject to it. |
404 | Extraction not found | Wrong job_id, or the service restarted and this job wasn't recovered. |
404 | format=zip when nothing was cropped | The bundle is the cropped images plus dataset.json; a document we cropped no figures from (a spreadsheet, or a page of pure text) has no bundle. The other three formats still work — json carries the same data. We'd rather 404 than hand you an archive that looks like a result and isn't. |
409 | Export requested before completion | Poll until status is done first. |
500 | Finished but the result file is missing | That's a bug on our side. We won't pretend it's still running and leave you polling forever. |
| Not available | Why |
|---|---|
POST /v1/webhooks | Completion callbacks. Poll GET /v1/parse/{id} for now; at 10 pages (≈600 s cold) polling holds up fine. |
page_range | You can only ask for the first N pages, not pages 100–110. For thick catalogues this is a real limitation: a 235-page catalogue keeps its spec tables in the back half, and pages counts from page 1. |
GET /v1/jobs, POST /v1/cancel/{id} | Listing and cancelling. Not needed yet, so not built. (Deletion exists: DELETE /v1/parse/{id}.) |
Scans degrade noticeably. We use the PDF's text layer as an extra independent reading; without it, cross-check coverage drops to 1/3–1/18 in our measurements. text_layer is reported in the 202 response — you don't have to wait for the run to find out.
Accuracy varies a lot per document. How we measure, the sample size, and where we fail are documented, with human-checked lower bounds. We won't put a blanket "99%+" here.