Skip to content

Latest commit

 

History

History
488 lines (369 loc) · 20.3 KB

File metadata and controls

488 lines (369 loc) · 20.3 KB

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