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"
const KEY = process.env.VISIONPARSE_KEY;
const BASE = 'https://api.visionparse.app';
const form = new FormData();
form.append('file', new Blob([await readFile('invoice.pdf')]), 'invoice.pdf');
form.append('languages', 'eng');
const { jobId } = await (
await fetch(`${BASE}/v1/ocr`, {
method: 'POST',
headers: { 'x-api-key': KEY },
body: form,
})
).json();
// Poll with backoff — recognition takes about a second a page.
let wait = 400;
for (;;) {
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 1.4, 3000);
const job = await (
await fetch(`${BASE}/v1/jobs/${jobId}`, { headers: { 'x-api-key': KEY } })
).json();
if (job.state === 'completed') return job.result.text;
if (job.state === 'failed') throw new Error(job.error.message);
}
import os, time, requests
KEY = os.environ["VISIONPARSE_KEY"]
BASE = "https://api.visionparse.app"
headers = {"x-api-key": KEY}
with open("invoice.pdf", "rb") as f:
job = requests.post(
f"{BASE}/v1/ocr",
headers=headers,
files={"file": f},
data={"languages": "eng"},
).json()
wait = 0.4
while True:
time.sleep(wait)
wait = min(wait * 1.4, 3.0)
r = requests.get(f"{BASE}/v1/jobs/{job['jobId']}", headers=headers).json()
if r["state"] == "completed":
print(r["result"]["text"])
break
if r["state"] == "failed":
raise RuntimeError(r["error"]["message"])
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.
| Field | Type | Notes |
|---|
| file | file, required | PDF, JPG, PNG, WEBP, TIFF, BMP, HEIC or HEIF. The magic bytes decide the type, never the extension. |
| languages | string | Comma-separated ISO codes, e.g. eng or eng,ara. Defaults to eng. See GET /v1/languages. |
| engine | string | auto (default), tesseract or easyocr. auto routes on language and quality. |
| lowQuality | true | false | Flag 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.
| Field | Type | Notes |
|---|
| file | file, required | PNG, JPG, WebP or PDF up to 25 pages. Magic bytes decide the type. |
| type | string | invoice (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.
| Field | Type | Notes |
|---|
| file | file, required | PNG, 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.
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
| Event | Raised when |
|---|
ocr.job.completed | A conversion finished. Carries the metrics and a resultUrl. |
ocr.job.failed | A conversion failed after every retry. Raised once, not once per attempt. |
webhook.test | You 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);
});
}
import hmac, hashlib, base64, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), None)
if t is None or abs(time.time() - int(t)) > 300:
return False
digest = hmac.new(
secret.encode(), (t + ".").encode() + raw_body, hashlib.sha256
).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
# Two v1 values during a secret rotation; either one is valid.
return any(
hmac.compare_digest(p[3:], expected)
for p in parts if p.startswith("v1=")
)
<?php
// Read the RAW body. A framework that hands you a parsed array has already
// thrown away the bytes the signature covers.
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_VP_SIGNATURE'] ?? '';
$parts = array_map('trim', explode(',', $header));
$t = null;
foreach ($parts as $p) { if (str_starts_with($p, 't=')) $t = substr($p, 2); }
if ($t === null || abs(time() - (int) $t) > 300) { http_response_code(400); exit; }
$digest = hash_hmac('sha256', $t . '.' . $raw, $secret, true);
$expected = rtrim(strtr(base64_encode($digest), '+/', '-_'), '=');
$ok = false;
foreach ($parts as $p) {
if (str_starts_with($p, 'v1=') && hash_equals(substr($p, 3), $expected)) $ok = true;
}
if (!$ok) { http_response_code(400); exit; }
http_response_code(200); // answer 2xx quickly, then do the work
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
| Plan | Pages a month | A file | API |
|---|
| No account | 5 files a day, 3 an hour | 3 MB · 3 pp | — |
| Free | 100 | 10 MB · 25 pp | Web only |
| Starter · $19 | 2,000 | 25 MB · 100 pp | Keys |
| Professional · $59 | 12,000 | 100 MB · 500 pp | Keys |
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"
}
}
| Status | Code | Meaning |
|---|
| 401 | UNAUTHORIZED | Missing, unknown or revoked key. Revocation takes effect within 60 seconds. |
| 400 | NO_FILE · EMPTY_FILE | No file part in the request, or the file had no bytes. |
| 400 | INVALID_OPTIONS · UNKNOWN_LANGUAGE | A parameter failed validation, or a language is not installed. |
| 404 | JOB_NOT_FOUND | Wrong id, or the job passed its retention window and was deleted. |
| 413 | FILE_TOO_LARGE | The file exceeds your plan’s per-file size limit. |
| 413 | FILE_TOO_LARGE_FREE | The file exceeds the anonymous limit of 3 MB. An account raises it. |
| 413 | TOO_MANY_PAGES_FREE | The PDF has more pages than your plan allows in one file. |
| 415 | UNSUPPORTED_TYPE | The magic bytes are not a format we read. |
| 429 | MONTHLY_LIMIT | Your monthly page allowance is spent. Carries Retry-After and states pages remaining. |
| 429 | HOURLY_LIMIT · DAILY_LIMIT | Anonymous web limits. API keys are metered monthly instead. |
| 502 | UPSTREAM | The 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