Webhooks and callbacks
There is no delivery mechanism and no callback URL parameter. Poll GET /v1/jobs/<id>.
Post a PDF, get typed JSON back with a confidence score on every page and
an arithmetic verdict on the document. Everything below is HTTP and
multipart form data — if you can run curl, you
have a client.
The base URL is this site. There is no separate API hostname, no version negotiation and no staging environment — one key, one URL, the same pipeline the dashboard uses.
Create an account, open the API keys screen in the dashboard and issue one. Name it after the thing that will hold it — billing-prod, laptop — because the name is what you will read when you decide which one to revoke.
One multipart/form-data request with a field
called file. No other parameters exist: no
schema, no language hint, no options object. The pipeline decides per
page what it needs.
verified is true,
false or null, and
the third one is not a pass. See
the validation object before you write the
branch that acts on it.
Copy it, swap the key and the filename
-F and not --data-binary:
the body is multipart, and the part must be named
file. A raw PDF body is a
400.
Every route under /v1 requires a live key. There
is no public endpoint and no anonymous tier — the guard runs before the
route, so a new endpoint is authenticated before anyone writes it.
Keys are 40 characters: the fixed prefix
sk_live_ and 32 more from a 64-symbol
alphabet, which is 192 bits of randomness. Nothing about a key encodes
your account, your plan or when it was made.
We store the SHA-256 of the whole key and the first twelve characters for display. That is the entire record. A key you have lost cannot be recovered by us or by anyone who takes our database — it can only be revoked and replaced.
Missing, malformed, revoked or disabled
One message covers a malformed key, an unknown key, a revoked key and a disabled account. That is deliberate: telling you which of those it was would tell someone guessing keys that their guess was structurally right.
Issue one key per environment and revoke freely. There is no limit on how many you hold, revocation takes effect on the next request, and every key records its prefix and when it was last used — so a key nothing has touched in a month is safe to remove. Rotating means issuing the new one, deploying it, then revoking the old one; there is no grace period to wait out.
The same three limits on every plan and every endpoint that takes an upload. Each one fails with the status code that names it, rather than a generic rejection you have to guess at.
| Limit | Value | What you get if you exceed it |
|---|---|---|
| File type | 415. Checked by reading the first bytes for
%PDF — not the extension, and not the
content type, both of which are whatever the client wrote. |
|
| File size | 25 MB | 413 naming the byte limit. A request whose
whole body is far over the limit is cut off earlier, by the server,
with request_entity_too_large. |
| Pages per document | 50 | 422 with a detail that states the page count
it found and the limit. Counted before any page is processed, so an
oversized document costs you nothing. |
Plans differ by monthly credit allowance and by which options you may
ask for — see pricing. Every
paid key reaches the same endpoints and gets the same response body. A
key on the free plan is refused with 403
plan_has_no_api; it starts working again on any paid plan rather
than needing to be reissued.
Synchronous. The connection stays open while the pipeline runs, and the whole result — summary, per-page detail, extracted text and the validation verdict — comes back in one body. Nothing is persisted: this endpoint creates no job row and leaves no document on disk.
How long it takes depends on how many pages need OCR and how many of those need the model. A text-layer PDF returns in well under a second; a scanned document is bounded by the request timeout, which is why the batch endpoint exists.
Abridged by dropping keys — every key shown is real
error is always present, and it is
null on success. A client that branches
on whether the key exists will take the failure path on every successful
parse. Branch on the status code, or on
error !== null. When the pipeline cannot read
the file at all — a corrupt PDF, or more than 50 pages — the
response is a 422 carrying
{"error": "parse_failed", "detail": "..."}
instead of this body.
Same request as /v1/parse — multipart, one field
called file — but it returns as soon as the
document is queued. Use it for anything you would not want to hold an HTTP
connection open for: long scans, bulk imports, anything driven by a cron.
The job is attributed to the key's owner. Only that account can read it back.
A worker picks the job up, runs the same pipeline
/v1/parse runs, writes the result and deletes
the spooled PDF — success or failure, in the same code path, so a
failed parse cannot leave a document behind.
pages[].text is how a queued document is
collected — there is no separate download call. It is present while the
job is inside its delivery window (24 hours after completion, whatever
your retention setting) and afterwards for as long as your account keeps
text. Once both have passed it becomes null
while word_count stays, so a caller can always
tell "no longer kept" from "nothing on the page".
GET /v1/jobs/<id> until
status is done or
failed. A few seconds between polls is
plenty; nothing here rewards a tight loop.
The page limit is enforced by the pipeline rather than at the door on
this endpoint, so an oversized document is accepted here and comes back
as a failed job with the same sentence
/v1/parse would have returned.
A job that belongs to another account answers 404,
not 403. Confirming that a guessed id exists is
the entire value of guessing one.
A finished job
summary is null
until the job finishes. Once it is set it holds the same object
/v1/parse returns, minus
pages_detail and
text — including
verified and the full
validation block.
On a failed job,
error carries the sentence explaining why.
The document is deleted either way.
Tuning a document pipeline without knowing the escalation thresholds is guesswork, so they are readable. Four objects come back:
| Key | What is in it |
|---|---|
| thresholds | The escalation policy in force — the confidence floors, the minimum word counts, and the text-layer quality ratios that decide whether a page moves up a tier. |
| accurate_tier | Whether the paid tier is on, which provider resolved, the endpoint and model in use, and the cost per page in euros. residency_confirmed is the flag behind the claims on the security page. |
| cost_model | The infrastructure inputs the per-page cost is derived from: monthly cost, vCPUs, assumed utilisation, and the resulting euros per CPU-second. |
| ocr | The languages local OCR is configured for and the rasterisation DPI. |
Also present: store_extracted_text, which says
whether this deployment persists extracted text at all. It is off by
default, and the security page explains what that means for retention.
How many pages reached each tier, what they cost, and the average confidence — the numbers that tell you whether your documents are expensive to read and where the scanner settings are letting you down.
Read the scope before you build a dashboard on it. This endpoint aggregates every page this service has processed, not only the ones sent with your key. It is an operational figure for the pipeline as a whole. Per-account totals are on your dashboard, and the numbers that describe a single document come back in that document's own response.
The payload also carries margin_at_plans: what the
measured cost per page implies for the margin on each published plan. It is
there because the same number decides whether the prices on this site are
sustainable, and hiding it would be a strange thing for this particular
product to do.
The summary object, returned by /v1/parse and
stored on a finished job as summary.
| Key | Type | Meaning |
|---|---|---|
| filename | string | The name you sent, echoed back. |
| error | string · null | Null on success. Present on every response — see the warning above. |
| pages | integer | Pages processed — one page of one PDF, not one API call. |
| verified | bool · null | The document-level verdict. Tri-state; null means nothing was checkable. |
| validation | object · null | Every check that ran and every one that failed. Detailed below. |
| tiers | object | Pages finished by each engine: text, ocr, accurate. |
| escalated_pages | integer | Pages that actually reached the paid model. |
| escalation_rate | float | The same as a fraction of pages, to four places. |
| wants_accurate_pages | integer | Pages the policy judged as needing the model — equal to escalated_pages when the paid tier is on and healthy, higher when it is off. |
| wants_accurate_rate | float | That number as a fraction of pages. |
| escalation_reasons | object | Count per reason: no_text_layer, text_layer_garbage, text_layer_sparse, low_ocr_confidence, too_many_weak_words, too_few_words, ocr_found_nothing, engine_failed. |
| escalation_trails | object | Count per path through the ladder, e.g. "no_text_layer -> low_ocr_confidence". Two documents with the same rate and different trails have different problems. |
| duration_ms | integer | Total pipeline time across every attempt on every page. |
| cost_eur | float | What this document cost us to process, to six places. |
| cost_per_page_eur | float | That, divided by pages. |
| projected_cost_per_page_eur | float | What it would cost with the paid tier enabled for every page that wanted it. |
| mean_confidence | float | Mean of the per-page confidences, to four places. A model that reports no uncertainty scores a nominal value here — which is exactly why the validation layer exists. |
Added by /v1/parse only. One entry per page, in
order, each carrying page,
tier, confidence,
escalated, the decisive
reason, and attempts —
every engine that tried the page, with its confidence, duration, whether it
was judged good enough, and any error. That array is the audit trail for
why a page cost what it cost.
Checks run over the whole document once every page is in, because line items on page one are summed against a total on page three. They are arithmetic and checksums, never "does this look like an invoice".
A check carries kind,
status, severity, a
one-sentence detail naming what was found and
what was expected, and evidence — the exact
source substrings the verdict rests on, so a person can find them
without re-running anything.
kind is one of
line_items_sum,
vat_arithmetic,
vat_rate,
iban_checksum,
vat_id_checksum or
date_plausible.
status is
pass, fail or
not_applicable — and the third is not a pass,
it means the inputs that check needs were not found.
Only one of the two can fail a document
verified to false.VAT-ID checksums and date ranges are advisory: a Dutch sole trader's btw-id is randomly generated, and a plausible but wrong date is indistinguishable from a right one. Line-item sums and VAT arithmetic are definitive, because arithmetic has no exceptions.
Do not treat null as a pass. A
page with no totals on it is not valid, it is unverifiable. If your code
reads if (verified) you have merged "checked and
clean" with "nothing to check", which is the exact path a fabricated
invoice takes to being approved.
Every failure is JSON: a stable error slug to
branch on, and a detail sentence written for a
human reading a log. Branch on the slug — the sentences are improved when
they turn out to be unclear.
| Status | error | Cause |
|---|---|---|
| 400 | invalid_upload | No file field in the request, or the file is empty. |
| 401 | unauthorized | No bearer header, or a key that is malformed, unknown, revoked or attached to a disabled account. Body also carries docs. |
| 404 | not_found | No such job, or a job belonging to another account. |
| 413 | invalid_upload | The file is over the size limit. The detail names the limit in bytes. |
| 413 | request_entity_too_large | The whole request body is over the limit, and was cut off before the handler ran. |
| 415 | invalid_upload | Not a PDF. Decided on the file's leading bytes. |
| 422 | parse_failed | The pipeline could not read the document: corrupt PDF, or more than 50 pages. |
| 429 | credit_cap_exceeded | This document would take the account past its monthly credit allowance. The body carries credits_used, credit_cap, credits_remaining, credits_for_document and pages_in_document, and the response carries a Retry-After giving the seconds until the allowance resets on the first of next month. |
| 503 | unavailable | The batch queue would not accept the job. The spooled document is deleted rather than left behind, so retrying is safe. |
| 500 | internal_error | Something broke on our side. The body carries a request_id and no exception text. |
Every response, successful or not, carries an
X-Request-Id header. Quote it if you write to us —
it is what matches your report to our logs.
Listed because finding out by trying is worse. None of these exist, and none of them is hidden behind a plan.
There is no delivery mechanism and no callback URL parameter. Poll GET /v1/jobs/<id>.
No SDKs. One multipart POST and one GET is not enough surface to justify a package you would have to trust and upgrade.
PDF only. A PNG or a .docx is a 415 at the door, not a silent conversion you find out about later.
There is no parameter that asks for named fields back. You get the text, the per-page detail and the checks; the mapping to your own schema is yours.
The API never reads your plan. Every key reaches every endpoint and gets the same response body.
Jobs are fetched one id at a time over the API. The list of everything you have sent is in the dashboard.