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';
const vision = new VisionAPI(); // reads process.env.VISION_API_KEY
const res = await vision.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 total
console.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';
const res = await vision.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 found
value(res.result, 'total', 0); // 1284.5, or 0 when absent
rows(res.result, 'line_item'); // Row[] — [] when the invoice has no lines
present(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:
await vision.analyze({ file: 'invoice.pdf', preset: 'invoice' }); // a path
await vision.analyze({ file: buffer, preset: 'invoice' }); // Buffer / Uint8Array
await vision.analyze({ file: { data: bytes, filename: 'scan.png' }, … }); // bytes + a name
await vision.analyze({ fileUrl: 'https://example.com/invoice.pdf', … }); // a public URL
await vision.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.
const res = await vision.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:
await vision.createSchema({ name: 'our-invoices', preset: 'invoice', schema: { machine_serial: '…' } });
await vision.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:
const presets = await vision.presets(); // no API key required
const invoice = await vision.preset('invoice');
invoice.fields.map((f) => `${f.name}: ${f.description}`);Three ways to choose:
// 1. You know what it is.
await vision.analyze({ file: 'receipt.jpg', preset: 'receipt' });
// 2. You don't, and you want the data anyway. Classification is free.
const res = await vision.analyze({ file: 'unknown.pdf', preset: 'auto' });
res.detection.preset; // what ran
res.detection.fallback; // true = "shape unknown", not a match
res.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.
const guess = await vision.detect({ file: 'unknown.pdf' });
if (guess.recommended === 'invoice' && !guess.fallback) {
await vision.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.
const res = await vision.ask({
file: 'photo.jpg',
questions: ['Is there a dog in the image?', 'How many people are visible?'],
});
for (const a of res.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.
const task = await vision.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 } = await vision.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.
import express from 'express';
import { verifyWebhook, WebhookSignatureError } from '@devrobotlabs/visionapi';
app.post('/hooks/vision', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.header('x-vision-signature'), process.env.VISION_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof WebhookSignatureError) return res.status(400).end();
throw err;
}
res.status(202).end(); // any 2xx is success — ack fast, work afterwards
queue.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 any v1= 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 {
await vision.analyze({ file: 'scan.pdf', preset: 'invoice' });
} catch (err) {
if (err instanceof InsufficientCreditsError) {
alertOps(`needs ${err.required}, has ${err.available}`); // never retried — it cannot succeed
} else if (err instanceof SyncTimeoutError) {
await vision.analyzeAndWait({ file: 'scan.pdf', preset: 'invoice' });
} else if (err instanceof UnsupportedTypeError) {
quarantine('not an image or a PDF');
} else if (err instanceof VisionAPIError) {
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:
await vision.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.
const vision = new VisionAPI({
apiKey: process.env.VISION_API_KEY, // default: process.env.VISION_API_KEY
baseUrl: 'https://api.visionapi.io', // default; override for a self-hosted deployment
timeout: 120_000, // per request, ms
maxRetries: 3,
autoIdempotency: true,
headers: { 'x-trace-id': traceId }, // sent on every request
fetch: myInstrumentedFetch, // swap the transport (tests, proxies, metrics)
});Every method takes per-call { idempotencyKey, timeout, signal }; signal is a standard
AbortSignal.
const { balance, buckets } = await vision.credits();
// buckets are spent in order: subscription → rollover → pack → welcome
for await (const record of vision.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.js
import { VisionAPI } from '@devrobotlabs/visionapi';
const vision = new VisionAPI();
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get('file') as File;
// Your own authorization, your own quota, your own audit trail.
await requireUser(request);
const res = await vision.analyze({ file, preset: String(form.get('preset') ?? 'auto') });
return Response.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" } } ] }