Documentation

One resource, five endpoints, no SDK required

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.

Quick start

Three steps to a parsed invoice

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.

  1. 1

    Issue a key

    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.

  2. 2

    Send a PDF

    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.

  3. 3

    Read the verdict, not just the text

    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.

A request that works

Copy it, swap the key and the filename

200
curl -X POST https://sharpocr.com/v1/parse \ -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \ -F "file=@factuur-2026-08-14.pdf"

-F and not --data-binary: the body is multipart, and the part must be named file. A raw PDF body is a 400.

Authentication

One header, on every endpoint

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.

The header

Authorization: Bearer sk_live_...

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.

When it is rejected

Missing, malformed, revoked or disabled

401
{ "error": "unauthorized", "detail": "That API key is not valid. It may have been revoked. Check the key list in the dashboard at /app, or issue a new one.", "docs": "/docs" }

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.

Limits

What the door accepts

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.

LimitValueWhat you get if you exceed it
File typePDF 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 size25 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 document50 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.

POST /v1/parse 200 · 422

Parse now, answer in the response

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.

Request

Content typemultipart/form-data
Field namefile
Other parametersnone
Idempotentno — each call parses

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.

200

Abridged by dropping keys — every key shown is real

{ "filename": "factuur-2026-08-14.pdf", "error": null, "pages": 2, "verified": true, "validation": { "verified": true, "checks_run": 5, "failures": [], "checks": [ ... ] }, "tiers": { "text": 0, "ocr": 2, "accurate": 0 }, "escalated_pages": 0, "escalation_rate": 0.0, "escalation_reasons": {}, "duration_ms": 1840, "mean_confidence": 0.9614, "pages_detail": [ ... ], "text": "Factuur 2026-08-14\n\n..." }

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.

POST /v1/parse/batch 202

Queue it and poll

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.

202 Accepted

{ "job_id": "8f2b1c7e-...-9a41", "status": "queued" }

The job is attributed to the key's owner. Only that account can read it back.

What happens next

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

There is no webhook and no callback URL. Poll 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.

GET /v1/jobs/<job_id> 200 · 404

One job, scoped to your account

A job that belongs to another account answers 404, not 403. Confirming that a guessed id exists is the entire value of guessing one.

200

A finished job

{ "id": "8f2b1c7e-...-9a41", "filename": "factuur-2026-08-14.pdf", "status": "done", "page_count": 2, "escalated_pages": 0, "cost_eur": 0.000214, "duration_ms": 1840, "summary": { ... }, "error": null, "created_at": "2026-08-14T09:12:03+00:00", "finished_at": "2026-08-14T09:12:05+00:00", "pages": [ { "page": 1, "tier": "text_layer", "confidence": 1.0, "word_count": 412, "text": "Factuur 2026-08-14 ..." }, { "page": 2, "tier": "ocr", "confidence": 0.968, "word_count": 157, "text": "Bankgegevens ..." } ] }

The four states

Accepted, not startedqueued
A worker has itrunning
Finisheddone
Did not finishfailed

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.

GET /v1/config 200

The thresholds your documents are being judged by

Tuning a document pipeline without knowing the escalation thresholds is guesswork, so they are readable. Four objects come back:

KeyWhat 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.

GET /v1/metrics 200

Escalation rate and cost per page

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.

Response reference

Every key, and what it means

The summary object, returned by /v1/parse and stored on a finished job as summary.

KeyTypeMeaning
filenamestringThe name you sent, echoed back.
errorstring · nullNull on success. Present on every response — see the warning above.
pagesintegerPages processed — one page of one PDF, not one API call.
verifiedbool · nullThe document-level verdict. Tri-state; null means nothing was checkable.
validationobject · nullEvery check that ran and every one that failed. Detailed below.
tiersobjectPages finished by each engine: text, ocr, accurate.
escalated_pagesintegerPages that actually reached the paid model.
escalation_ratefloatThe same as a fraction of pages, to four places.
wants_accurate_pagesintegerPages 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_ratefloatThat number as a fraction of pages.
escalation_reasonsobjectCount 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_trailsobjectCount 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_msintegerTotal pipeline time across every attempt on every page.
cost_eurfloatWhat this document cost us to process, to six places.
cost_per_page_eurfloatThat, divided by pages.
projected_cost_per_page_eurfloatWhat it would cost with the paid tier enabled for every page that wanted it.
mean_confidencefloatMean 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.

pages_detail

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.

The validation object

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

verifiedtrue · false · null
checks_runinteger
failuresarray of checks
checksarray of checks

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.

Severity decides the verdict

Only one of the two can fail a document

definitive — no legitimate document fails this. A failure flips verified to false.
advisory — real documents fail these for benign reasons. Reported, never the sole cause of a rejection.

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.

Errors

Two keys, always the same two

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.

{ "error": "invalid_upload", "detail": "That file is empty." }
StatuserrorCause
400invalid_uploadNo file field in the request, or the file is empty.
401unauthorizedNo bearer header, or a key that is malformed, unknown, revoked or attached to a disabled account. Body also carries docs.
404not_foundNo such job, or a job belonging to another account.
413invalid_uploadThe file is over the size limit. The detail names the limit in bytes.
413request_entity_too_largeThe whole request body is over the limit, and was cut off before the handler ran.
415invalid_uploadNot a PDF. Decided on the file's leading bytes.
422parse_failedThe pipeline could not read the document: corrupt PDF, or more than 50 pages.
429credit_cap_exceededThis 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.
503unavailableThe batch queue would not accept the job. The spooled document is deleted rather than left behind, so retrying is safe.
500internal_errorSomething 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.

Not in the API

Things you may be looking for

Listed because finding out by trying is worse. None of these exist, and none of them is hidden behind a plan.

Webhooks and callbacks

There is no delivery mechanism and no callback URL parameter. Poll GET /v1/jobs/<id>.

Client libraries

No SDKs. One multipart POST and one GET is not enough surface to justify a package you would have to trust and upgrade.

Images and Office files

PDF only. A PNG or a .docx is a 415 at the door, not a silent conversion you find out about later.

A field schema you supply

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.

Per-plan feature gates

The API never reads your plan. Every key reaches every endpoint and gets the same response body.

A job list endpoint

Jobs are fetched one id at a time over the API. The list of everything you have sent is in the dashboard.

Something here not match what you got back?

Then this page is wrong and we want to know, with the X-Request-Id if you still have it. A documentation bug is a bug.