Official Node.js and TypeScript client for Vision API — send an image or a PDF, describe the fields you want in plain language, get structured JSON back with a confidence level on every value.
- Website — https://visionapi.io
- Documentation — https://docs.visionapi.io
- API keys — https://app.visionapi.io/dashboard/keys
- Preset catalog — https://visionapi.io/presets
- Playground — https://visionapi.io/playground
- Support — https://support.visionapi.io · https://visionapi.io/contact-us
npm install @devrobotlabs/visionapiNode 18.17 or newer. No dependencies — the client uses the runtime's own fetch,
FormData and crypto.
import{VisionAPI}from'@devrobotlabs/visionapi';constvision=newVisionAPI();// reads process.env.VISION_API_KEYconstres=awaitvision.analyze({file: 'invoice.pdf',preset: 'invoice'});console.log(res.result.invoice_id.value);// 'A-10422'console.log(res.result.total.value);// 1284.5 — or null, if the invoice has no totalconsole.log(res.credits_used,res.credits_remaining);Requests are metered in credits, per image and per selected PDF page — see pricing for current rates. Failures cost nothing: the reservation is released in full on any non-2xx, so there is no compensating logic to write.
Server-side only. There is no publishable key and no test mode — an API key is a live spending credential. Never ship one to a browser or a mobile app. For a frontend, put this library behind your own endpoint (see Using it from a browser).
Two rules explain almost every surprise:
1. Every scalar is wrapped.{ value, confidence }, where confidence is 'low',
'mid' or 'high'. Read result.total.value, not result.total.
2. A preset response contains every field of that preset — including the ones the
document does not carry, which come back as { value: null, confidence: 'low' }. A key
being present does not mean a value was found. Check value !== null.
Line-item arrays are the one shape worth looking at twice. The array itself is not wrapped; each cell inside each row is:
The library ships helpers for the common readings, so you rarely have to spell that out:
import{unwrap,value,rows,present,missing,belowConfidence}from'@devrobotlabs/visionapi';constres=awaitvision.analyze({file: 'invoice.pdf',preset: 'invoice'});unwrap(res.result);// { invoice_id: 'A-10422', carrier: null, line_item: [{ description: 'Widget', … }] }unwrap(res.result,{dropNull: true});// only what was actually foundvalue(res.result,'total',0);// 1284.5, or 0 when absentrows(res.result,'line_item');// Row[] — [] when the invoice has no linespresent(res.result);// ['invoice_id', 'total', 'line_item']missing(res.result);// ['carrier', …]belowConfidence(res.result,'high');// fields to route to a humanTypes are exported for all of it — AnalyzeResponse, Field, Row, ResultValue,
Detection, Task, and the isField / isRows narrowing guards.
Exactly one file source per call:
awaitvision.analyze({file: 'invoice.pdf',preset: 'invoice'});// a pathawaitvision.analyze({file: buffer,preset: 'invoice'});// Buffer / Uint8Arrayawaitvision.analyze({file: {data: bytes,filename: 'scan.png'}, … });// bytes + a nameawaitvision.analyze({fileUrl: 'https://example.com/invoice.pdf', … });// a public URLawaitvision.analyze({fileBase64: b64, … });// base64, `data:` prefix optionalJPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic bytes — the filename is ignored.
| Option | Default | What it does |
|---|---|---|
preset | — | A catalog name, or 'auto' to let the API classify the file first (free). |
schema | — | Custom fields, alone or on top of a preset. |
schemaName | — | A schema saved in your dashboard. Excludes preset and schema. |
pages | all | PDF page selection, e.g. '1-3,7'. You pay for selected pages only. |
languageHint | auto | ISO 639-1 code, e.g. 'es'. |
detail | 'standard' | 'high' renders pages at higher resolution. Same cost, slower. |
output | 'json' | 'text' returns raw OCR text instead of fields. |
includeRawText | false | Adds full_text, the whole transcription, alongside result. |
minConfidence | 'low' | Fields below the level come back null, with confidence preserved. |
A schema is a flat object: each key is a field name, each value describes what to extract. It is compiled before any credit moves, so a bad schema costs nothing.
constres=awaitvision.analyze({file: 'invoice.pdf',preset: 'invoice',schema: {// Plain form — the string is the description, type defaults to string.machine_serial: 'Serial number of the machine being invoiced, without the "SN:" prefix',// Typed form.total_net: {type: 'number',description: 'Total before tax'},signed_on: {type: 'date',description: 'Date the contract was signed'},is_paid: {type: 'boolean',description: 'Whether the invoice is stamped PAID'},// Reserved key: injects fields into every row of the preset's line-item array.line_item: {lot_number: 'The lot number printed on the line, if present'},},});Field names must match ^[a-z][a-z0-9_]{0,63}$. Types are string (default), number,
boolean, date, array and object. A custom name that collides with a preset field is
a 422 schema_field_conflict — rename it, or use the preset's own field.
Descriptions are the prompt. "The invoice number exactly as printed, without the #"
extracts better than "invoice number". Say what to do when the value is missing or
ambiguous if it matters.
Reuse a combination by saving it:
awaitvision.createSchema({name: 'our-invoices',preset: 'invoice',schema: {machine_serial: '…'}});awaitvision.analyze({file: 'invoice.pdf',schemaName: 'our-invoices'});28 presets ship with the API. Fetch the catalog rather than hardcoding field names from memory — presets are versioned, and the catalog is the source of truth:
constpresets=awaitvision.presets();// no API key requiredconstinvoice=awaitvision.preset('invoice');invoice.fields.map((f)=>`${f.name}: ${f.description}`);Three ways to choose:
// 1. You know what it is.awaitvision.analyze({file: 'receipt.jpg',preset: 'receipt'});// 2. You don't, and you want the data anyway. Classification is free.constres=awaitvision.analyze({file: 'unknown.pdf',preset: 'auto'});res.detection.preset;// what ranres.detection.fallback;// true = "shape unknown", not a matchres.detection.alternatives;// the rest of the ranking, best first// 3. The *type* is the decision — routing a mixed inbox, or refusing to spend// on a 40-page PDF until you know what it is. Far cheaper than extracting.constguess=awaitvision.detect({file: 'unknown.pdf'});if(guess.recommended==='invoice'&&!guess.fallback){awaitvision.analyze({file: 'unknown.pdf',preset: 'invoice'});}detect reads page 1 only, so an image and a 300-page PDF cost the same, and it is metered
in batches rather than per call: most calls report credits_used: 0 and an occasional one
carries the charge. See pricing for the rate.
Up to 5 questions about one file, priced exactly like an extraction. The questions themselves are free.
constres=awaitvision.ask({file: 'photo.jpg',questions: ['Is there a dog in the image?','How many people are visible?'],});for(constaofres.answers){console.log(a.question,'→',a.verdict,a.answer);}verdict is 'yes', 'no', 'uncertain' (a yes/no question the image does not settle)
or 'n/a' (not a yes/no question). Branch on it instead of parsing the prose.
Synchronous requests are killed at 60 seconds with a 504 sync_timeout. Anything that
might run longer — a long PDF, detail: 'high', a batch — belongs on the queue.
// Submit, then poll. waitForTask handles the polling loop and the failure case.consttask=awaitvision.analyzeAndWait({file: 'contract-80-pages.pdf',preset: 'contract',pages: '1-50'},{pollInterval: 2000,maxWait: 15*60_000,onPoll: (t)=>console.log(t.status)},);console.log(task.result.parties.value);// Or submit and walk away — the result comes to you.const{ task_id }=awaitvision.analyzeAsync({file: 'contract.pdf',preset: 'contract',webhookUrl: 'https://yourapp.com/hooks/vision',});Results stay retrievable for 7 days; after that the task returns 410 result_expired
(metadata survives, the payload does not).
Deliveries are signed. Verify over the raw bytes before parsing — a re-serialized body has different bytes and will not match.
importexpressfrom'express';import{verifyWebhook,WebhookSignatureError}from'@devrobotlabs/visionapi';app.post('/hooks/vision',express.raw({type: 'application/json'}),(req,res)=>{letevent;try{event=verifyWebhook(req.body,req.header('x-vision-signature'),process.env.VISION_WEBHOOK_SECRET);}catch(err){if(errinstanceofWebhookSignatureError)returnres.status(400).end();throwerr;}res.status(202).end();// any 2xx is success — ack fast, work afterwardsqueue.push(event);// event.event is 'task.completed' | 'task.failed'});verifyWebhook rejects a bad signature, a malformed header and a timestamp more than 5
minutes old, and accepts a delivery if anyv1= part matches — which is what makes a
secret rotation seamless. Get the secret from
https://app.visionapi.io/dashboard/webhooks. Failed deliveries retry at +1 m, +5 m,
+15 m and +40 m, then stop.
Every failure throws a subclass of VisionAPIError carrying the HTTP status, the stable
code, and whatever details the endpoint attached. Branch on the class or on code —
never on the message text, which is prose and changes.
import{InsufficientCreditsError,RateLimitError,SyncTimeoutError,UnsupportedTypeError,VisionAPIError,}from'@devrobotlabs/visionapi';try{awaitvision.analyze({file: 'scan.pdf',preset: 'invoice'});}catch(err){if(errinstanceofInsufficientCreditsError){alertOps(`needs ${err.required}, has ${err.available}`);// never retried — it cannot succeed}elseif(errinstanceofSyncTimeoutError){awaitvision.analyzeAndWait({file: 'scan.pdf',preset: 'invoice'});}elseif(errinstanceofUnsupportedTypeError){quarantine('not an image or a PDF');}elseif(errinstanceofVisionAPIError){log(err.code,err.status,err.requestId);}}| Class | HTTP | Codes |
|---|---|---|
InvalidRequestError | 400 | invalid_request |
AuthenticationError | 401 | invalid_api_key, unauthorized |
InsufficientCreditsError | 402 | insufficient_credits — with .required / .available |
PermissionError | 403 | forbidden, email_not_verified |
NotFoundError | 404 | task_not_found, schema_not_found |
ConflictError | 409 | conflict |
ResultExpiredError | 410 | result_expired |
PayloadTooLargeError | 413 | file_too_large, page_limit_exceeded |
UnsupportedTypeError | 415 | unsupported_type |
UnprocessableError | 422 | pdf_encrypted, invalid_page_selection, invalid_schema, schema_field_conflict, too_many_questions |
RateLimitError | 429 | rate_limited — with .retryAfter |
TooManyTasksError | 429 | too_many_tasks — the per-plan async concurrency cap, with .max. Subclasses RateLimitError, but is not auto-retried: it clears when one of your tasks finishes |
InternalError | 500 | internal_error — with .requestId |
ProviderError | 502 | provider_error |
SyncTimeoutError | 504 | sync_timeout |
UsageError (bad arguments), ConnectionError / TimeoutError (the request never got a
response) and TaskFailedError / TaskTimeoutError are thrown by the client itself.
The client retries 429, 500, 502 and network failures — maxRetries: 3 by default, with
the server's own Retry-After honored on 429 and exponential backoff with jitter
elsewhere. Input errors and insufficient_credits are never retried, because they cannot
succeed.
Every billable POST is sent with a generated Idempotency-Key, so a retried upload replays
the first response instead of paying twice. Supply your own when the caller may retry — a
job that re-runs, a queue that redelivers — because a fresh process generates a fresh key:
awaitvision.analyze({ file,preset: 'invoice'},{idempotencyKey: `invoice-${invoiceId}`});Reusing a key with a different payload is a 409 ConflictError, which is the mechanism
working: it means the key already stands for something else.
constvision=newVisionAPI({apiKey: process.env.VISION_API_KEY,// default: process.env.VISION_API_KEYbaseUrl: 'https://api.visionapi.io',// default; override for a self-hosted deploymenttimeout: 120_000,// per request, msmaxRetries: 3,autoIdempotency: true,headers: {'x-trace-id': traceId},// sent on every requestfetch: myInstrumentedFetch,// swap the transport (tests, proxies, metrics)});Every method takes per-call { idempotencyKey, timeout, signal }; signal is a standard
AbortSignal.
const{ balance, buckets }=awaitvision.credits();// buckets are spent in order: subscription → rollover → pack → welcomeforawait(constrecordofvision.iterRequests({limit: 100})){console.log(record.created_at,record.endpoint,record.preset,record.credits_used);}Usage history is metadata only — never the file, never the extracted values. Uploaded files are never retained: a synchronous request holds yours in memory for the length of the call, and an async request stages it only until the worker finishes with it.
Same for everyone:
| Limit | Value |
|---|---|
| Max file size | 20 MB |
| Max PDF pages per request | 50 |
| Sync request timeout | 60 s |
Per plan:
| Limit | Free | Starter | Growth | Pro | Scale |
|---|---|---|---|---|---|
| Requests per minute, per key | 10 | 60 | 120 | 300 | 600 |
| Burst capacity | 20 | 120 | 240 | 600 | 1,200 |
| Concurrent async tasks | 1 | 4 | 8 | 16 | 32 |
| Active API keys per account | 1 | 5 | 10 | 20 | 50 |
| Saved schemas | 3 | 10 | 25 | 100 | unlimited |
Max questions per ask | 5 | 5 | 5 | 10 | 10 |
The rate-limit bucket is per API key, not per account — splitting a workload across
keys splits the limit too. The concurrency cap is per account and does not split that way:
over it, an async submission answers 429 too_many_tasks and is charged nothing.
Higher limits on paid plans: https://visionapi.io/pricing.
Don't. A key in frontend code is a spending credential anyone can read. Put a thin endpoint in front of it instead:
// app/api/vision/analyze/route.ts — Next.jsimport{VisionAPI}from'@devrobotlabs/visionapi';constvision=newVisionAPI();exportasyncfunctionPOST(request: Request){constform=awaitrequest.formData();constfile=form.get('file')asFile;// Your own authorization, your own quota, your own audit trail.awaitrequireUser(request);constres=awaitvision.analyze({ file,preset: String(form.get('preset')??'auto')});returnResponse.json(res);}Then call that endpoint from the browser — or use
@devrobotlabs/visionapi-react or
@devrobotlabs/visionapi-vue, which are built for exactly
this shape.
Runnable scripts in examples/:
| File | What it shows |
|---|---|
analyze.mjs | The smallest useful call, and how to read the result |
custom-schema.mjs | Custom fields, line-item injection, saved schemas |
detect-then-analyze.mjs | Routing a mixed inbox before spending on extraction |
async-batch.mjs | Queueing a folder of PDFs with bounded concurrency |
webhook-server.mjs | A verified receiver, in ~40 lines |
ask.mjs | Visual Q&A and the verdict field |
export VISION_API_KEY=sk_live_…
node examples/analyze.mjs path/to/invoice.pdfnpm install
npm run build # tsc → dist/
npm test# offline: fetch is stubbed, no key and no network needed
npm run typecheckIssues and pull requests are welcome at https://github.com/devrobotlabs/visionapi-node. For anything about the API itself — a preset, a limit, an error code — https://support.visionapi.io reaches the team faster.
MIT © Vision API
{ "invoice_id": { "value": "A-10422", "confidence": "high" }, "carrier": { "value": null, "confidence": "low" }, "line_item": [ { "description": { "value": "Widget", "confidence": "high" }, "quantity": { "value": 2, "confidence": "high" }, "amount": { "value": 25.0, "confidence": "mid" } } ] }