API documentation

One endpoint. Format version 4. Base URL https://api.consensis.app.

Authentication

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.

Anonymous trial: 10 free pages a day per address on the API — and another 10 a day on the web uploader — no account needed. Paid usage is prepaid — $0.06 per delivered page ($0.036 with mode=batch), minimum top-up US$3.50. Failed pages are never charged.

Try it free — no key, no account

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

Three lines to start

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"
Choosing an export format: 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.

Batch mode — $0.036/page: add -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.

What every cell carries

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"}}

provisional

Appears only when confidence is disputed, and is always true.

When sources disagree we still deliver a value — data has to be usable, and an empty cell is a value silently lost. But that value is provisional: check it against readings before you act on it, and don't import it straight into a live system.

confidence

confidenceMeaning
exactread directly from a structured file — no interpretation involved
verifiedmultiple independent readings agree
confirmedsettled by a human looking at the original page
reviewedadjudicated by the AI reviewer against the original page image — fast and measured, but not independently verified
resolveda majority of readings agree
unverifiedonly one reading exists — nothing to cross-check against
disputedindependent readings disagree — these cells need a human

sources

These are not vendor names. An audit asks "how many independent readings", not "which vendor".

sourcesWhat it is
ocr-1an OCR engine from one model family
ocr-2an OCR engine from a different family — only different families can catch same-source bias
ocr-3a 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-layoutthe PDF's own layout geometry (most accurate — and free — when rulings and a text layer exist)
pdf-gridgrid lines inferred from filled rectangles
pdf-modela trained layout model reading the PDF's internal structure — no OCR, no reliance on rulings
pdf-textthe PDF's text layer
source-dataread directly from a structured file (XLSX/CSV — no interpretation)
vision-reviewadjudicated against the original page image
layoutthe layout reading found a figure at this position
ocra second reading independently found a figure overlapping the same box — this is the one that makes an image verified
pdf-imagethe 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-1one of two independent vision readers, each looking at the page image and reconstructing the table grid
vision-2the second vision reader — a different model family from vision-1, so the two can disagree independently
vision-singleread by vision, but only one reading exists for this cell — no second opinion corroborated it
vision-auditconfirmed 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-arbiteran extra reading that settled a disagreement between the two vision readers — not one of the two
vision-gridrecovered by re-reading the page image to rebuild the grid, where the first pass left holes or a misaligned row
human-reviewchecked 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

bbox

[x0, y0, x1, y1] in PDF points (1/72 inch), origin top-left; the page number is on the table's page.

Coverage is not 100%, and varies a lot per document (measured 3%–48%). usage.cells_with_bbox reports exactly how many cells have one for your document — the number isn't hidden.

What a table looks like

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.

Fields that appear only when they apply

These follow the same rule as unreadable: a false on every table is noise, and what you want to scan for is the exception.

FieldWhen it appears
captionA qualifier that belongs to the whole table (e.g. HG - 15, 20), inherited from the group it was delivered under.
kindWhat sort of table we think it is. Present on every table; the value is a short label, not a promise.
layout_disputedThe 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_unsupportedNo independent OCR reading found overlapping content for this table. Same spirit as above: flagged, not dropped.
span_gridThe 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.
colbandsColumn bands — a header that groups several columns under one label.
unfoldedThis table was reassembled across a fold-out page.
sheetThe 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.
Order codes are extra, not a replacement. When a table is a stepped order-code table, we also deliver 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.

Prose and images

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.

prose

{"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.

Measured: 83–91% of paragraphs come back 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.

images

{"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.

pages

[{"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.

Limits

LimitValueWhy this number
File size600 MBMeasured from real scanned catalogues (121, 266, 443, 456 MB) — not guessed.
Pages500 pagesThe 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.
FormatsPDFExtensions 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.
Concurrencyone document at a timeThe pipeline saturates the CPU; the queue is a single worker. Extra submissions queue up — they don't fail.
Restartsre-queued at most 2 timesJobs 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.
Retention24 hours after completionWe 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.
Set a User-Agent. Requests with the Python standard library's default UA (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.

Errors

Always {"detail": "…"}, written for humans — never a server path, never a key name.

StatusWhenNotes
400Unrecognised formatThe file extension doesn't count — we look at the content. Supported: PDF.
401Unknown API keyThe 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.
400A format we've switched offWe'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.
400File too large, or emptyThe limit is 600 MB.
400pages below 1Ask for as many pages as you need; the server enforces the ceiling.
400Unsupported export formatGET accepts xlsx, csv, json and zip (the result bundle: dataset.json plus every cropped image); POST /v1/export accepts xlsx and csv.
413Stateless export body too largePOST /v1/export accepts datasets up to 600 MB.
402Insufficient balancePrepaid 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.
429Anonymous trial rate limitThe 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.
404Extraction not foundWrong job_id, or the service restarted and this job wasn't recovered.
404format=zip when nothing was croppedThe 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.
409Export requested before completionPoll until status is done first.
500Finished but the result file is missingThat's a bug on our side. We won't pretend it's still running and leave you polling forever.

What's not here

Not availableWhy
POST /v1/webhooksCompletion callbacks. Poll GET /v1/parse/{id} for now; at 10 pages (≈600 s cold) polling holds up fine.
page_rangeYou 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}.)

The honest part

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.