Developers

API documentation

Send a document, poll a job id, get text back. Two calls, no SDK required.

Every sample captured from the live API on

Quickstart

Recognition never runs inside your request. You submit a document, receive a job id immediately, and poll it — so a forty-page PDF cannot hold a connection open until it times out.

# 1 — send the document. Returns immediately with a job id.
curl -X POST https://api.visionparse.app/v1/ocr \
  -H "x-api-key: $VISIONPARSE_KEY" \
  -F "file=@invoice.pdf" \
  -F "languages=eng"

# 202 Accepted
{
  "jobId": "8f122018-6967-4fc7-b5a5-7aa720244794",
  "state": "queued",
  "statusUrl": "/v1/jobs/8f122018-6967-4fc7-b5a5-7aa720244794"
}

# 2 — read the result. 202 while running, 200 when done.
curl https://api.visionparse.app/v1/jobs/8f122018-6967-4fc7-b5a5-7aa720244794 \
  -H "x-api-key: $VISIONPARSE_KEY"

Authentication

Every request carries your key in an x-api-key header. Keys are created in your account, shown once, and stored by us only as a hash — we cannot recover one for you, so treat it like a password and keep it server-side.

x-api-key: vp_live_…

Revoking a key stops it working within 60 seconds. /healthz and /readyz need no key, so uptime monitors do not need a credential.

Endpoints

Base URL https://api.visionparse.app

POST/v1/ocr

Submit a document. Returns 202 with a job id.

multipart/form-data. Recognition never runs inside your request — a forty-page PDF would hold the connection open for minutes, so the work is queued and you poll for it.

FieldTypeNotes
filefile, requiredPDF, JPG, PNG, WEBP, TIFF, BMP, HEIC or HEIF. The magic bytes decide the type, never the extension.
languagesstringComma-separated ISO codes, e.g. eng or eng,ara. Defaults to eng. See GET /v1/languages.
enginestringauto (default), tesseract or easyocr. auto routes on language and quality.
lowQualitytrue | falseFlag a phone photo or a poor scan. Enables deskew and denoise, and prefers the higher-accuracy engine.

GET/v1/jobs/{jobId}

202 while the job runs, 200 with the result when it finishes.

The status code is the signal: poll while you get 202. Jobs and their files are deleted an hour after conversion on free usage, so read the result promptly.

POST/v1/extract

Structured extraction. Answers in the request — one AI credit a document.

multipart/form-data. Unlike OCR this is synchronous: a single document answers in 2–5 seconds, so there is no job to poll. The page image and our own OCR text go to the model together with a fixed schema, and the response is fields, never prose. Refuses with 402 when the account has no AI credits.

FieldTypeNotes
filefile, requiredPNG, JPG, WebP or PDF up to 25 pages. Magic bytes decide the type.
typestringinvoice (default), receipt, purchase_order, delivery_note, goods_received_note or business_card. Decides the field schema of the response.

POST/v1/transcribe

AI transcription for handwriting and degraded documents. One credit.

multipart/form-data, synchronous. For material classical OCR cannot read — measured on a real 19th-century manuscript at 95.3% where the classical engine scored 43.9%. Returns { text, language, uncertainPassages }: line breaks preserved, illegible spans marked [illegible] rather than invented, and the passages the model is unsure of named. Refuses with 402 when the account has no AI credits.

FieldTypeNotes
filefile, requiredPNG, JPG, WebP or PDF up to 25 pages. Magic bytes decide the type.

GET/v1/languages

The 161 recognition languages installed on the server.

Returns { "count": 161, "languages": [...] }. Reject unknown codes against this list rather than at conversion time.

GET/healthz

Liveness. Public — no key required.

For uptime monitors. /readyz additionally reports queue readiness.

The result object

A completed job carries the full text, a confidence score, and every word with its position on the page — enough to highlight a value back on the original document.

{
  "jobId": "8f122018-6967-4fc7-b5a5-7aa720244794",
  "state": "completed",
  "createdAt": "2026-08-01T06:59:45.007Z",
  "startedAt": "2026-08-01T06:59:45.018Z",
  "finishedAt": "2026-08-01T06:59:45.122Z",
  "originalName": "invoice.pdf",
  "sizeBytes": 615,
  "result": {
    "text": "INVOICE 2026-114\nAcme Trading Ltd\nTotal due USD 1,240.50",
    "confidence": 100,
    "pageCount": 1,
    "engine": "text-layer",
    "languages": ["eng"],
    "engineMs": 101,
    "preprocessMs": 0,
    "words": [
      { "text": "INVOICE", "confidence": 100, "box": [125, 90, 103, 23], "page": 1 }
    ]
  }
}

engine tells you how the text was obtained. text-layer means the PDF carried its own characters and we read them exactly — confidence 100, no recognition involved. tesseract or easyocr mean the page was recognised from pixels, and the confidence is a real estimate worth checking.

Structured extraction

/v1/extract turns a document into fields — parties, references, dates, totals and every line item — instead of raw text. Five types are supported: invoice, receipt, purchase_order, delivery_note and goods_received_note. It answers inside the request (2–5 seconds for a single document), costs one AI credit, and the response is schema-bound JSON: the model cannot answer in prose.

Each type returns its own field set, because the documents differ: a delivery note carries quantities and no prices, and a goods received note reports ordered, received and rejected quantities side by side — the three numbers that make three-way matching against a purchase order and an invoice possible.

checks is arithmetic we run ourselves, in ordinary code with no model involved: do the line items sum to a printed figure, does subtotal less discount plus tax reach the total, does every line multiply out, can a goods received note report more received than ordered, do the dates run in order. verifiedis true when all of them pass. A failure does not necessarily mean the reading is wrong — a document whose own printed totals disagree fails too, which is worth knowing — so treat it as "look at this", not "this is broken". When a check fails we re-read the document once with a stronger model at no extra credit, and escalated records that we did. low_confidence_fields names any values the model itself would not stand behind, which in practice is mostly numeric dates where day and month could be swapped.

# Structured extraction answers in the request — no job to poll.
curl -X POST https://api.visionparse.app/v1/extract \
  -H "x-api-key: $VISIONPARSE_KEY" \
  -F "file=@invoice.pdf" \
  -F "type=invoice"
{
  "type": "invoice",
  "data": {
    "document_type": "invoice",
    "document_type_confidence": 1,
    "vendor_name": "MERIDIAN OFFICE SUPPLIES LLC",
    "vendor_address": "Building 14, Salwa Industrial Road, Doha, Qatar",
    "vendor_tax_id": "QA-5501-8837",
    "customer_name": "Petrotec Trading WLL",
    "customer_address": "Street 45, Zone 57, Industrial Area, Doha, Qatar",
    "invoice_number": "INV-2417",
    "invoice_date": "2026-07-12",
    "due_date": "2026-08-11",
    "po_number": "PO-88231",
    "currency": "USD",
    "subtotal": 730,
    "discount_amount": null,
    "tax_amount": 36.5,
    "total": 766.5,
    "line_items": [
      { "description": "A4 copy paper 80gsm, box of 5 reams", "quantity": 12, "unit_price": 24.5, "amount": 294 },
      { "description": "Toner cartridge HP 26X black", "quantity": 3, "unit_price": 118, "amount": 354 },
      { "description": "Stapler, heavy duty metal", "quantity": 5, "unit_price": 16.4, "amount": 82 }
    ]
  },
  "model": "gemini-3.5-flash-lite",
  "verified": true,
  "checks": [
    { "name": "line_items_sum", "ok": true, "detail": "Line items total 730, matching the subtotal." },
    { "name": "subtotal_tax_make_total", "ok": true, "detail": "Subtotal plus tax comes to 766.5." },
    { "name": "line_quantity_times_price", "ok": true, "detail": "Every line multiplies out." },
    { "name": "dates_in_order", "ok": true, "detail": "2026-08-11 falls on or after 2026-07-12." }
  ],
  "escalated": false,
  "creditsUsed": 1,
  "creditsRemaining": 249
}

document_typeis the model's honest classification of what you sent, independent of what you asked for. A purchase order submitted as an invoice says purchase_order; a document that is not commercial paperwork at all says other and carries nulls rather than invented values. Every scalar field is nullable — null means the document does not say, never that the field was skipped. AI credits come with any paid plan or as one-time packs; a first pack of $15 or more also unlocks API keys with no subscription.

Webhooks

Rather than polling a job until it finishes, you can have us post to your server the moment it does. Add an endpoint under Account → Webhooks; you get a signing secret, shown once. Webhooks are part of paid plans.

Events

EventRaised when
ocr.job.completedA conversion finished. Carries the metrics and a resultUrl.
ocr.job.failedA conversion failed after every retry. Raised once, not once per attempt.
webhook.testYou pressed Send test delivery. Travels the identical path as the others.

Extraction and transcription raise no events. They answer inside their own HTTP response, so you already hold the data, the checks and the credit breakdown when the call returns; an event would be a slower copy of a message already delivered.

What arrives

{
  "id": "evt_9b8098e187eb4f4aa82e2d454e7a1680",
  "type": "ocr.job.completed",
  "createdAt": "2026-08-07T14:49:28.476Z",
  "apiVersion": "2026-08-07",
  "data": {
    "jobId": "b912ecb7-2272-4039-bb15-aa5424438ffb",
    "state": "completed",
    "originalName": "invoice-1.pdf",
    "sizeBytes": 2179,
    "pageCount": 1,
    "confidence": 100,
    "engine": "text-layer",
    "languages": ["eng"],
    "engineMs": 107,
    "preprocessMs": 0,
    "createdAt": "2026-08-07T14:49:28.189Z",
    "startedAt": "2026-08-07T14:49:28.195Z",
    "finishedAt": "2026-08-07T14:49:28.304Z",
    "resultUrl": "https://api.visionparse.app/v1/jobs/b912ecb7-2272-4039-bb15-aa5424438ffb"
  }
}

The payload carries no recognised text. Fetch it from resultUrlwith your API key when you want it — your documents are not posted to a URL typed into a form. Deduplicate on id: it is stable across every retry and any replay, while the delivery id in the header changes each time.

Verifying the signature

Every delivery carries a header of the form VP-Signature: t=<unix seconds>,v1=<signature>. The signature is an HMAC-SHA256 over the timestamp, a dot, and the exact raw body, encoded as base64url— not hex, and not over a re-serialised object. Reject anything older than five minutes.

import crypto from 'node:crypto';

// The RAW body. Parse it only after the signature checks out: a JSON
// round-trip changes the bytes, and the signature covers the bytes.
export function verify(rawBody, header, secret) {
  const parts = header.split(',').map((x) => x.trim());
  const t = Number(parts.find((x) => x.startsWith('t='))?.slice(2));
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('base64url');            // base64url, NOT hex

  // During a rotation the header carries two v1 values. Either is valid.
  return parts
    .filter((x) => x.startsWith('v1='))
    .some((x) => {
      const given = Buffer.from(x.slice(3));
      const want = Buffer.from(expected);
      return given.length === want.length && crypto.timingSafeEqual(given, want);
    });
}

The timestamp changes on every attempt.The body does not, so a retry has a different signature to the delivery it repeats — recompute it, never cache it. During a secret rotation the header carries two v1 values and either is valid, which is what lets you change a secret without an outage.

Retries, and what we expect back

Answer 2xxas soon as you have stored the message, then do the work. Anything else is a failure and we try again: immediately, then after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours — six attempts across roughly eight hours.

  • Redirects are not followed. A 3xx is a failure; register the final URL.
  • 410 Gone stops delivery permanently and pauses the endpoint.
  • After repeated failures over time we pause the endpoint and email you. Nothing queues up while it is paused; you resume it and send a test delivery to prove the fix before real events depend on it.
  • Every attempt is listed under Account → Webhooks with the status your server returned and the first part of its response.

Limits

PlanPages a monthA fileAPI
No account5 files a day, 3 an hour3 MB · 3 pp
Free10010 MB · 25 ppWeb only
Starter · $192,00025 MB · 100 ppKeys
Professional · $5912,000100 MB · 500 ppKeys

Requests are metered in pages, not files. Full plans and credit packs are on the pricing page.

Errors

Every error has the same shape, so one handler covers all of them. The message is written to be shown to a person; the code is what you branch on.

{
  "error": {
    "message": "That PDF has 40 pages. Your plan reads up to 25 a file.",
    "code": "TOO_MANY_PAGES_FREE"
  }
}
StatusCodeMeaning
401UNAUTHORIZEDMissing, unknown or revoked key. Revocation takes effect within 60 seconds.
400NO_FILE · EMPTY_FILENo file part in the request, or the file had no bytes.
400INVALID_OPTIONS · UNKNOWN_LANGUAGEA parameter failed validation, or a language is not installed.
404JOB_NOT_FOUNDWrong id, or the job passed its retention window and was deleted.
413FILE_TOO_LARGEThe file exceeds your plan’s per-file size limit.
413FILE_TOO_LARGE_FREEThe file exceeds the anonymous limit of 3 MB. An account raises it.
413TOO_MANY_PAGES_FREEThe PDF has more pages than your plan allows in one file.
415UNSUPPORTED_TYPEThe magic bytes are not a format we read.
429MONTHLY_LIMITYour monthly page allowance is spent. Carries Retry-After and states pages remaining.
429HOURLY_LIMIT · DAILY_LIMITAnonymous web limits. API keys are metered monthly instead.
502UPSTREAMThe recognition tier could not be reached. Safe to retry.

How it behaves

  • Digital PDFs are read, not recognised.If a PDF carries a text layer we take the characters exactly — faster and without OCR's mistakes. Scanned pages fall back to recognition automatically, page by page, so a mixed document is handled correctly throughout.
  • Poll with backoff. Start around 400 ms and grow to a few seconds. A fixed fast interval wastes your own rate budget on a long document.
  • Line structure is preserved. Paragraphs and line breaks come back as they appear on the page, not flattened into one string.
  • Files are deleted.Within the hour on free usage, per your plan's retention otherwise. Documents are never used to train models.
  • Retry 502 and network failures. Submitting the same document twice is safe — each submission is its own job.

Ready to build?

Keys live in your account. The API is part of paid plans, from $19 a month or a one-time $15 credit pack.

See the plansYour API keys