npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devrobotlabs/visionapi

v1.0.0

Published

Official Node.js client for the Vision API — credit-based OCR and visual intelligence. Extract structured JSON from images and PDFs.

Readme

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.

npm license


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

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


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

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 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:

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

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


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.

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

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:

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.


Questions instead of fields

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.


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

Verifying a delivery

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.


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

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:

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.


Configuration

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.


Account and usage

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.


Limits

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.


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


Examples

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