Skip to content

Repository files navigation

Vision API — Node.js client

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.

npmlicense


Install

npm install @devrobotlabs/visionapi

Node 18.17 or newer. No dependencies — the client uses the runtime's own fetch, FormData and crypto.

Quick start

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


Reading a result

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:

{
"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" }
}
]
}

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 human

Types are exported for all of it — AnalyzeResponse, Field, Row, ResultValue, Detection, Task, and the isField / isRows narrowing guards.


What you can send

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 optional

JPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic bytes — the filename is ignored.

Options

OptionDefaultWhat it does
presetA catalog name, or 'auto' to let the API classify the file first (free).
schemaCustom fields, alone or on top of a preset.
schemaNameA schema saved in your dashboard. Excludes preset and schema.
pagesallPDF page selection, e.g. '1-3,7'. You pay for selected pages only.
languageHintautoISO 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.
includeRawTextfalseAdds full_text, the whole transcription, alongside result.
minConfidence'low'Fields below the level come back null, with confidence preserved.

Custom fields

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'});

Picking a preset

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.


Questions instead of fields

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.


Long jobs: async and webhooks

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

Verifying a delivery

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.


Errors

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);}}
ClassHTTPCodes
InvalidRequestError400invalid_request
AuthenticationError401invalid_api_key, unauthorized
InsufficientCreditsError402insufficient_credits — with .required / .available
PermissionError403forbidden, email_not_verified
NotFoundError404task_not_found, schema_not_found
ConflictError409conflict
ResultExpiredError410result_expired
PayloadTooLargeError413file_too_large, page_limit_exceeded
UnsupportedTypeError415unsupported_type
UnprocessableError422pdf_encrypted, invalid_page_selection, invalid_schema, schema_field_conflict, too_many_questions
RateLimitError429rate_limited — with .retryAfter
TooManyTasksError429too_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
InternalError500internal_error — with .requestId
ProviderError502provider_error
SyncTimeoutError504sync_timeout

UsageError (bad arguments), ConnectionError / TimeoutError (the request never got a response) and TaskFailedError / TaskTimeoutError are thrown by the client itself.

Retries and idempotency

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.


Configuration

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.


Account and usage

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.


Limits

Same for everyone:

LimitValue
Max file size20 MB
Max PDF pages per request50
Sync request timeout60 s

Per plan:

LimitFreeStarterGrowthProScale
Requests per minute, per key1060120300600
Burst capacity201202406001,200
Concurrent async tasks1481632
Active API keys per account15102050
Saved schemas31025100unlimited
Max questions per ask5551010

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.


Using it from a browser

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.


Examples

Runnable scripts in examples/:

FileWhat it shows
analyze.mjsThe smallest useful call, and how to read the result
custom-schema.mjsCustom fields, line-item injection, saved schemas
detect-then-analyze.mjsRouting a mixed inbox before spending on extraction
async-batch.mjsQueueing a folder of PDFs with bounded concurrency
webhook-server.mjsA verified receiver, in ~40 lines
ask.mjsVisual Q&A and the verdict field
export VISION_API_KEY=sk_live_…
node examples/analyze.mjs path/to/invoice.pdf

Development

npm install
npm run build # tsc → dist/
npm test# offline: fetch is stubbed, no key and no network needed
npm run typecheck

Contributing

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

License

MIT © Vision API

About

Official Node.js & TypeScript client for the Vision API. Extract structured JSON from images and PDFs, with a confidence level on every field.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages