# Vision API — integration guide for coding agents

Machine-readable reference for writing code against the Vision API. Everything below is the
public v1 contract: HTTP statuses, error `code` values, parameter names and response field
names are stable and safe to branch on.

- Base URL: `https://api.visionapi.io`
- Auth: `Authorization: Bearer sk_live_...` on every endpoint except `GET /v1/presets`
- Live catalog (no key needed): `curl https://api.visionapi.io/v1/presets`
- Human docs: <https://docs.visionapi.io>
- Dashboard and API keys: <https://app.visionapi.io/dashboard/keys>
- Client libraries: <https://docs.visionapi.io/#clients> — nine official clients, one per ecosystem
- Terms and privacy: <https://legal.visionapi.io/terms>, <https://legal.visionapi.io/privacy>

What it does: you send an image or PDF, optionally describe the fields you want in plain
language, and get structured JSON back with a confidence level on every field. 1 credit per
image; 2 per selected PDF page on `/v1/analyze`, 1 on `/v1/ask`. Failures cost 0.

---

## Rules for the agent

Read these before writing code. They are the mistakes that cost the most time.

1. **Use the official client for the language you are writing in, if one exists.** Nine are
   listed under *Client libraries* below. They already implement the rules in this document —
   key from the environment, retry under an `Idempotency-Key`, `Retry-After` honoured, 402
   never retried, constant-time webhook verification — and those are the rules that cost real
   money when they are reimplemented from memory. Write raw HTTP only when no client covers
   the language, or the runtime forbids the dependency. Everything below is the contract the
   clients speak, so the two are never in conflict.
2. **Branch on `error.code`, never on the message text.** Messages are prose and change; codes
   are contract. Every failure has the same envelope: `{"error": {"code", "message", "details"?}}`.
3. **Never hardcode preset field names from memory.** Fetch `GET /v1/presets/{name}` and read
   the field list. Presets are versioned and the catalog is the source of truth.
4. **Every value is wrapped**: `{"value": ..., "confidence": "low"|"mid"|"high"}`. Read
   `result.total.value`, not `result.total`.
5. **A preset response contains every field of that preset**, including ones the document does
   not carry — those come back as `{"value": null, "confidence": "low"}`. Do not treat a
   present key as a found value; check `value !== null`.
6. **On 429, sleep for the `Retry-After` header value.** Do not invent a backoff. On 402
   (`insufficient_credits`) do not retry at all — retrying cannot succeed.
7. **Anything longer than a few pages must be `async=true`.** A sync request asking for more
   than `MAX_SYNC_PAGES` pages (3 by default) is refused up front with 400 `async_required`
   and charges nothing — `details.max_sync_pages` is the ceiling. Below the ceiling a sync
   request can still run out of time: it is killed at 60 s with a 504 `sync_timeout`, and
   `details.suggestion` says `"async"`. Narrowing with `pages` counts too — the ceiling is on
   pages selected, not on the length of the document.
8. **Send an `Idempotency-Key` header on any request you might retry.** Without it, a retried
   upload is a second charge. It also buys you the 504 recovery: when a sync request carrying
   one runs past the time limit, the work keeps going on our side and your retry — the same
   request, the same key — collects the finished result instead of re-extracting. You are
   charged when you collect it, and not at all if you never do. `details.suggestion` is
   `"retry"` rather than `"async"` when that is what happened, and `Retry-After` says when to
   come back; retry too early and you get the usual 409.
9. **Credits are only consumed on 2xx.** Every other outcome — bad input, unreadable file,
   provider failure, timeout — releases the reservation in full. You do not need compensating
   logic for failed requests.
10. **Do not put the API key in client-side code.** There is no publishable key and no test
    mode; a key is a live spending credential. The React and Vue clients exist precisely
    because of this and hold no key at all — see *Client libraries*.

---

## Endpoints

| Method | Path                  | Purpose                                                  | Key |
| ------ | --------------------- | -------------------------------------------------------- | --- |
| POST   | `/v1/analyze`         | Extraction — raw OCR, a preset, or a custom schema        | yes |
| POST   | `/v1/detect`          | Identify what a file is — ranked presets, no extraction   | yes |
| POST   | `/v1/ask`             | Visual Q&A, up to 5 questions per request                 | yes |
| GET    | `/v1/tasks/{task_id}` | Async task status and result                              | yes |
| GET    | `/v1/credits`         | Balance and per-bucket breakdown                          | yes |
| GET    | `/v1/requests`        | Usage history, metadata only, cursor-paginated            | yes |
| GET    | `/v1/presets`         | Preset catalog                                          | no  |
| GET    | `/v1/presets/{name}`  | One preset with its full field definitions                | no  |
| GET    | `/v1/status`          | Live service status — component states, no history        | no  |
| GET    | `/v1/schemas`         | List saved schemas                                        | yes |
| POST   | `/v1/schemas`         | Create a saved schema (`{name, preset?, schema?}`)        | yes |
| PUT    | `/v1/schemas/{name}`  | Replace a saved schema                                    | yes |
| DELETE | `/v1/schemas/{name}`  | Delete a saved schema                                     | yes |

Both `multipart/form-data` and `application/json` are accepted on `/v1/analyze` and `/v1/ask`
with identical field names. Use multipart when you have file bytes, JSON when you are passing
`file_url` or `file_base64`.

---

## POST /v1/analyze

### Parameters

| Parameter                            | Default    | Notes                                                                                     |
| ------------------------------------ | ---------- | ----------------------------------------------------------------------------------------- |
| `file` / `file_base64` / `file_url`  | —          | Exactly one is required. Type is detected by magic bytes; the filename is ignored.        |
| `preset`                             | —          | A catalog name, or `"auto"` to have the API classify the file first (free).             |
| `schema`                             | —          | Custom fields as a JSON object. Alone, or on top of a preset.                             |
| `schema_name`                        | —          | A schema saved in the dashboard. Mutually exclusive with `preset` and `schema`.           |
| `async`                              | `false`    | Return `202 {task_id, status:"queued"}` instead of waiting.                               |
| `pages`                              | all        | PDF page selection, e.g. `"1-3,7"`. You are charged for selected pages only.              |
| `language_hint`                      | auto       | ISO 639-1 code, e.g. `"es"`.                                                              |
| `detail`                             | `standard` | `"high"` renders pages at higher resolution. Same credit cost, slower.                    |
| `output`                             | `json`     | `"text"` returns raw OCR text. Rejected with 400 if a preset or schema is also set.       |
| `include_raw_text`                   | `false`    | Adds `full_text` (the whole transcription) alongside `result`.                            |
| `min_confidence`                     | `low`      | `low`\|`mid`\|`high`. Fields below the level come back `null`, confidence preserved.       |
| `webhook_url`                        | —          | Async only (400 otherwise). Must be HTTPS and must not resolve to a private address.      |

Header: `Idempotency-Key: <your id>` — replays the stored response for the same key + identical
request instead of charging again.

### Response — preset or custom schema (200)

```json
{
  "id": "req_8f2k1",
  "status": "completed",
  "credits_used": 3,
  "credits_remaining": 447,
  "pages": 3,
  "preset": "invoice",
  "result": {
    "invoice_id":   { "value": "A-10422",    "confidence": "high" },
    "invoice_date": { "value": "2026-02-14", "confidence": "high" },
    "total":        { "value": 1284.50,      "confidence": "mid"  },
    "carrier":      { "value": null,         "confidence": "low"  },
    "line_items": {
      "value": [
        { "description": "Widget", "quantity": 2, "unit_price": 12.5, "amount": 25.0 }
      ],
      "confidence": "high"
    }
  }
}
```

Optional members: `schema_name` (when the request used one), `detection` (only under
`preset: "auto"`), `full_text` (only with `include_raw_text=true`), `text` (only for
`output=text`, in which case there is no `result`).

### Response — `preset: "auto"`

```json
{
  "preset": "invoice",
  "detection": {
    "preset": "invoice",
    "modality": "document",
    "confidence": "high",
    "fallback": false,
    "alternatives": [
      { "preset": "table",    "confidence": "mid", "reason": "Tabular rows with amounts" },
      { "preset": "document", "confidence": "mid", "reason": "Generic printed document" }
    ]
  },
  "result": { }
}
```

Classification runs on page 1 only and is free — an image still costs 1 credit, a PDF still 2
per selected page. When nothing matches confidently the request resolves to the generic
`document` or `image` preset and `detection.fallback` is `true`; treat that as "shape unknown",
not as a match. `detection.alternatives` is the rest of the ranking, best first, and never
contains the preset that ran — on a fallback it is where the rejected guess went, which is how
you tell "we saw an invoice but weren't sure" from "we saw nothing". It is always an array,
sometimes empty. Custom fields still work under `auto`; a `line_item` injection is silently
ignored if the detected preset has no line items.

### Types and coercion

`number` fields come back as JSON numbers (both `1.234,56` and `1,234.56` conventions are
parsed), `date` fields as ISO 8601 `YYYY-MM-DD` strings, `boolean` as JSON booleans. A value
that cannot be coerced is demoted to `null` / `"low"` rather than returned malformed — so a
non-null number is always a real number.

---

## Custom schemas

A schema is a flat JSON object: each key is a field name, each value describes what to extract.
It is compiled and validated **before** any credit moves, so a bad schema costs nothing (422).

```jsonc
{
  // Plain form — the string is the description, type defaults to string.
  "machine_serial": "Serial number of the machine being invoiced",

  // 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 marked paid" },
  "reference_numbers": { "type": "array", "description": "All reference numbers", "items": "string" },
  "seller":      { "type": "object",  "description": "Seller block",
                   "fields": { "name": "Legal name", "vat_id": "VAT identifier" } },

  // 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" }
}
```

- Field names must match `^[a-z][a-z0-9_]{0,63}$`.
- Types: `string` (default), `number`, `boolean`, `date`, `array`, `object`.
- `line_item` is reserved. It is only meaningful with a preset that has line items
  (see the table below); with a bare custom schema it has nothing to inject into.
- A custom field whose name collides with a preset field is a 422 `schema_field_conflict` —
  rename it, or drop it and 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 absent or ambiguous
  if it matters.

Custom-schema responses contain exactly the requested fields — unlike presets, nothing extra.

---

## Presets

<!-- count is hand-synced with presets/*.json; nothing fails a build when it drifts -->
28 named schemas with stable `snake_case` field names. Fetch `GET /v1/presets/{name}` for the
field list before mapping anything; the counts below are only for picking a preset.

| Preset            | Kind     | Fields | Line items |
| ----------------- | -------- | -----: | ---------- |
| `invoice`         | document |     37 | yes        |
| `receipt`         | document |     14 | yes        |
| `purchase_order`  | document |     18 | yes        |
| `packing_slip`    | document |     13 | yes        |
| `bank_statement`  | document |     14 | yes        |
| `utility_bill`    | document |     21 | yes        |
| `form`            | document |      7 | yes        |
| `table`           | document |      7 | yes        |
| `menu`            | document |     10 | yes        |
| `check`           | document |     16 | no         |
| `contract`        | document |     17 | no         |
| `id_document`     | document |     16 | no         |
| `shipping_label`  | document |     18 | no         |
| `business_card`   | document |     12 | no         |
| `resume`          | document |     13 | no         |
| `insurance_card`  | document |     20 | no         |
| `ticket`          | document |     22 | no         |
| `certificate`     | document |     19 | no         |
| `nutrition_label` | document |     23 | no         |
| `document`        | document |     11 | no         |
| `product`         | image    |     13 | no         |
| `person`          | image    |     12 | no         |
| `vehicle`         | image    |     11 | no         |
| `scene`           | image    |     11 | no         |
| `animal`          | image    |     10 | no         |
| `food`            | image    |     10 | no         |
| `object`          | image    |     10 | no         |
| `image`           | image    |      8 | no         |

`document` and `image` are the deliberate generic fallbacks: full transcription plus the few
facts anything carries, and a description of what is in frame.

---

## POST /v1/detect

Ask what a file is without paying to extract it. Returns the presets that could handle it,
ranked, and nothing else. **1 credit per 5 calls** — an image and a 300-page PDF cost the same,
because detection only ever reads page 1. Calls are counted on your account and the credit comes
off the fifth, so four calls out of five report `"credits_used": 0`.

| Parameter                            | Default    | Notes                                          |
| ------------------------------------ | ---------- | ---------------------------------------------- |
| `file` / `file_base64` / `file_url`  | —          | Exactly one is required.                        |
| `detail`                             | `standard` | `"high"` renders page 1 at higher resolution.   |

`Idempotency-Key` works here. `pages`, `async` and `webhook_url` are rejected with 400 —
detection is synchronous and always reads page 1.

```json
{
  "id": "req_KAV4rf",
  "status": "completed",
  "credits_used": 0,
  "credits_remaining": 449,
  "pages": 1,
  "modality": "document",
  "recommended": "invoice",
  "fallback": false,
  "detections": [
    { "preset": "invoice",  "confidence": "high", "reason": "Invoice number, billing block, totals" },
    { "preset": "table",    "confidence": "mid",  "reason": "Tabular rows with amounts" },
    { "preset": "document", "confidence": "mid",  "reason": "Generic printed document" }
  ]
}
```

`recommended` is what `preset: "auto"` would have run on this file — the same classifier
decides both, so you can probe first and trust the answer. `detections` is the raw ranking with
no fallback substitution applied; `fallback` tells you whether `recommended` differs from
`detections[0]` because nothing was confident enough.

**When to use this instead of `preset: "auto"`.** Use `auto` when you want the data and don't
care which preset produces it — it costs the same as any extraction and detection is free.
Use `/v1/detect` when the *type* is the decision: routing a mixed inbox, refusing to spend on a
40-page PDF until you know what it is, or picking between two presets yourself. The pattern is
detect (a fifth of a credit, amortised) → analyze with the chosen preset (normal price).

---

## POST /v1/ask

Up to 5 questions about one file, in a single request, priced exactly like an extraction
(per image / per selected page — the questions themselves are free).

Parameters: the same input trio (`file` / `file_base64` / `file_url`), plus `questions`,
`async`, `pages`, `language_hint`, `detail`, `webhook_url`. `questions` accepts a single
string, a JSON array, or a repeated multipart field.

```json
{
  "id": "req_2m8p4",
  "status": "completed",
  "credits_used": 1,
  "credits_remaining": 446,
  "pages": 1,
  "answers": [
    {
      "question": "Is there a dog in the image?",
      "answer": "Yes, a golden retriever near the door.",
      "verdict": "yes",
      "confidence": "high"
    }
  ]
}
```

`verdict` is `"yes"`, `"no"`, `"uncertain"` (a yes/no question the images do not settle) or
`"n/a"` (not a yes/no question) — branch on it instead of parsing the prose. Answers come back
one per question, in the order asked.

---

## Async, tasks and webhooks

```
POST /v1/analyze  async=true            → 202 {"task_id": "task_9d3xk", "status": "queued"}
GET  /v1/tasks/task_9d3xk               → 200 {"task_id", "status", "endpoint", "created_at",
                                                "started_at", "completed_at", "expires_at", ...}
```

`status` is `queued` | `processing` | `completed` | `failed`. While pending the body carries
`credits_reserved`; on `completed` it carries `credits_used` plus the same payload members as
the sync response (`result` / `answers` / `text`, `pages`, `preset`, `detection`); on `failed`
it carries `credits_used: 0` and `error`.

Poll every 2 s or so, or — better — pass `webhook_url` and skip polling. Results stay
retrievable for 7 days, then the task returns 410 `result_expired` (metadata survives, the
payload does not).

### Webhook delivery

```
POST <your url>
X-Vision-Signature: t=1754287000,v1=<hex hmac>
X-Vision-Delivery: <delivery id>

{"event":"task.completed","task_id":"task_9d3xk","status":"completed",
 "credits_used":12,"created_at":"2026-08-06T10:00:00.000Z","result":{ }}
```

`event` is `task.completed` or `task.failed`; failures carry `error` instead of `result`.
Any 2xx is success. Failed deliveries retry at +1 m, +5 m, +15 m and +40 m, then stop.

Verify before parsing: recompute `HMAC-SHA256(secret, "{t}.{raw body}")` over the **exact bytes
received**, compare in constant time, and reject timestamps more than ~5 minutes old. After a
secret rotation the header carries several `v1=` parts — accept the delivery if **any** matches.

The URL must be HTTPS and must not resolve to a private range, so `localhost` receivers are
unreachable by design; use a tunnel when testing locally.

---

## Errors

Every failure: `{"error": {"code": "...", "message": "...", "details": {...}}}`.

| HTTP | `code`                                                 | Agent's move                                            |
| ---- | ------------------------------------------------------ | ------------------------------------------------------- |
| 400  | `invalid_request`                                       | Fix the call. Not retryable.                            |
| 400  | `async_required`                                        | Resubmit with `async=true`, or narrow it with `pages`. Never retry unchanged. |
| 401  | `invalid_api_key`                                       | Key missing, unknown or revoked. Not retryable.         |
| 402  | `insufficient_credits`                                  | `details.required` / `details.available`. Do not retry. |
| 403  | `forbidden`, `email_not_verified`                       | Not retryable.                                          |
| 404  | `task_not_found`, `schema_not_found`                    | Wrong id, or not yours.                                 |
| 409  | `conflict`                                              | Saved-schema name taken, or an `Idempotency-Key` reused with a different payload / still in flight. |
| 410  | `result_expired`                                        | Past the 7-day window. Re-submit the file.              |
| 413  | `file_too_large`, `page_limit_exceeded`                 | Over 20 MB / over 50 pages. Split the input.            |
| 415  | `unsupported_type`                                      | Magic-byte check failed. Not a supported image or PDF.  |
| 422  | `pdf_encrypted`, `invalid_page_selection`, `invalid_schema`, `schema_field_conflict`, `too_many_questions` | Semantic input error. Fix and re-send. |
| 422  | `render_failed`                                         | A PDF page could not be rasterized — usually an oversized sheet (a plan or a poster) or very heavy artwork. `details.page` is the page. Retry it with `detail: "standard"`, exclude it with `pages`, or send that page as an image. Do not retry unchanged. |
| 429  | `rate_limited`                                          | Sleep `Retry-After` seconds, then retry.                |
| 429  | `too_many_tasks`                                        | Too many async tasks in flight. Wait for one of *your* tasks to finish — sleeping alone will not clear it. |
| 500  | `internal_error`                                        | `details.request_id` is the correlation id. Retry once. |
| 502  | `provider_error`                                        | Model provider failed after retries. Retry with backoff.|
| 504  | `sync_timeout`                                          | Re-submit with `async=true` (`details.suggestion`).     |

---

## Limits and credits

These are the same for everyone:

| Limit                     | Value |
| ------------------------- | ----- |
| Max file size             | 20 MB |
| Max PDF pages per request | 50    |
| Sync request timeout      | 60 s  |

These depend on the 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 `/v1/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.

Cost is 1 credit per image, and per *selected* PDF page 2 on `/v1/analyze` and 1 on `/v1/ask` —
so `pages=1-3` on a 40-page PDF costs 6 to analyze and 3 to ask about. `POST /v1/detect` is
metered in batches — 1 credit per 5 calls whatever the page count, because it reads page 1 only —
so `credits_used` is 0 on four calls out of five and 1 on the fifth. Detection bundled into
`preset: "auto"` is free. `GET /v1/credits` returns `{"balance": 448, "buckets": {"subscription",
"rollover", "pack", "welcome"}}`; buckets are spent in that order. New verified accounts get 50
credits.

**Zero retention, every account.** Your file is never written to disk on a synchronous request.
An async submission stages it only so the worker process can read it, and deletes it the moment
the task ends. Async results persist for 7 days so they can be fetched, then the payload and the
request parameters are both dropped; request history keeps metadata only — never the file or the
extracted values.

---

## Client libraries

Nine official clients, one per ecosystem, all with zero runtime dependencies. They speak
exactly the contract above — same endpoints, same parameter names, same response shapes — and
add the parts that are tedious by hand: key from the environment, retry under a generated
`Idempotency-Key`, `Retry-After` honoured on 429, 402 and every input error never retried, and
a constant-time webhook verifier. Prefer one over hand-rolled HTTP (rule 1).

| Language | Package | Install | Repository |
| -------- | ------- | ------- | ---------- |
| Node.js 18.17+ | `@devrobotlabs/visionapi` (npm) | `npm install @devrobotlabs/visionapi` | https://github.com/devrobotlabs/visionapi-node |
| Python 3.9+ | `visionapi-client` (PyPI), imported as `visionapi` | `pip install visionapi-client` | https://github.com/devrobotlabs/visionapi-python |
| Go 1.21+ | `github.com/devrobotlabs/visionapi-go` | `go get github.com/devrobotlabs/visionapi-go` | https://github.com/devrobotlabs/visionapi-go |
| Ruby 3.0+ | `vision_api` (RubyGems) | `gem install vision_api` | https://github.com/devrobotlabs/visionapi-ruby |
| PHP 8.1+ | `visionapi/visionapi-php` (Packagist) | `composer require visionapi/visionapi-php` | https://github.com/devrobotlabs/visionapi-php |
| Java 17+ | `io.visionapi:visionapi-java` (Maven Central, reserved) | **not published yet** — install from the repository | https://github.com/devrobotlabs/visionapi-java |
| Swift 5.9+ | `VisionAPI` (SwiftPM, resolved by URL) | `.package(url: "https://github.com/devrobotlabs/visionapi-swift.git", from: "1.0.0")` | https://github.com/devrobotlabs/visionapi-swift |
| React 18+ | `@devrobotlabs/visionapi-react` (npm) | `npm install @devrobotlabs/visionapi-react` | https://github.com/devrobotlabs/visionapi-react |
| Vue 3.4+ | `@devrobotlabs/visionapi-vue` (npm) | `npm install @devrobotlabs/visionapi-vue` | https://github.com/devrobotlabs/visionapi-vue |

Java is tagged `v1.0.0` and works, but Maven Central does not have it yet. Do not emit the
`io.visionapi:visionapi-java` coordinate — it does not resolve today. Point at the repository, or
use one of the other eight.

The seven server-side clients expose the same surface, named the way each language names
things:

```
analyze / analyzeAsync / analyzeAndWait   ask / askAsync   detect
getTask / waitForTask                     credits / requests
presets / preset                          schemas CRUD
verifyWebhook
```

`analyzeAndWait` submits asynchronously, polls, and returns the finished task — it is the
right default for anything that might exceed 60 s. `askAndWait` exists **only** in the Node
client; everywhere else, submit with `askAsync` and poll `waitForTask`.

### Names that differ between clients

Do not guess these; they are the transcription errors that cost the most time.

| | |
| --- | --- |
| Construct | `new VisionAPI()` (Node) · `VisionAPI()` (Python) · `visionapi.New()` returning `(*Client, error)` (Go) · `VisionAPI.new` (Ruby) · `new VisionApi\Client()` (PHP) · `VisionApi.create()` (Java) · `try VisionAPI()` — an `actor`, so every call is awaited (Swift) |
| Method casing | camelCase (Node, Java, Swift) · snake_case (Python, Ruby) · PascalCase (Go) · camelCase methods with snake_case array keys (PHP) |
| Parameter names | camelCase mapped to the wire's snake_case (Node, Java, Swift); PascalCase struct fields (Go); the wire's snake_case verbatim (Python, Ruby, PHP, React, Vue) |
| Get one schema | `schema(name)` everywhere except Go, which is `GetSchema(ctx, name)` — `Schema` is already a type there |
| Verify a webhook | `verifyWebhook(...)` (Node) · `verify_webhook(...)` (Python) · `VerifyWebhook(...)` (Go) · `VisionAPI::Webhook.verify` (Ruby) · `VisionApi\Webhook::verify` (PHP) · `Webhook.verify` (Java) · `Webhook.verify(body:signature:secret:)` (Swift) |
| Failed task, do not raise | `throwOnFailure: false` (Node) · `raise_on_failure=False` (Python, Ruby) · `ReturnFailedTask()` (Go) · `returnFailedTask` (Java, Swift) |
| Timeout units | milliseconds (Node) · seconds (Python, Ruby, PHP, Swift) · `time.Duration` (Go) · `java.time.Duration` (Java) |

Every client reads the key from `VISION_API_KEY` when constructed with no argument.

### Two quick starts

```js
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' });
res.result.invoice_id.value;                       // 'A-10422' — every scalar is wrapped
res.result.total.value;                            // 1284.5, or null if absent
```

```python
from visionapi import VisionAPI

vision = VisionAPI()                               # reads $VISION_API_KEY
res = vision.analyze(file="invoice.pdf", preset="invoice")
res["result"]["invoice_id"]["value"]               # 'A-10422' — responses are plain dicts
res["result"]["total"]["value"]                    # 1284.5, or None if absent
```

### React and Vue hold no key

There is no publishable key and no test mode, so a key in a browser bundle is a spending
credential anyone can read out of it (rule 10). Those two packages are hooks and composables
that post to *your* endpoint, which holds the key and uses the Node client server-side, and
which is also where the caller's own auth and quota belong. Both ship that proxy as a copyable
file (Next.js, Express, Nuxt). If you are writing browser code, the deliverable is two pieces:
the component **and** the endpoint behind it.

Their surface is `useAnalyze`, `useAsk`, `useDetect`, `useTask`, `usePresets`, and their
parameters are the wire's snake_case, forwarded verbatim. Your proxy must forward the API's
error envelope unchanged, or the hooks cannot branch on `error.code`.

---

## Working examples

The three examples below are deliberately dependency-free. They are for the languages nothing
above covers — Rust, C#, Elixir — and for runtimes where adding a dependency is not an option.
They also double as the reference implementation of the retry and idempotency rules: if you
are hand-rolling, this is the shape to hand-roll.

### curl

```bash
curl https://api.visionapi.io/v1/analyze \
  -H "Authorization: Bearer $VISION_API_KEY" \
  -H "Idempotency-Key: batch-2026-08-06-0001" \
  -F "file=@invoice.pdf" \
  -F "preset=invoice" \
  -F "schema={\"machine_serial\":\"Serial number of the machine being invoiced\"}"
```

### Node (no dependencies)

```js
import { readFile } from 'node:fs/promises';

const KEY = process.env.VISION_API_KEY;
const BASE = 'https://api.visionapi.io';

export async function analyze(path, fields = {}, { idempotencyKey } = {}) {
  const form = new FormData();
  form.set('file', new Blob([await readFile(path)]), path.split('/').pop());
  for (const [k, v] of Object.entries(fields)) {
    form.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
  }

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${BASE}/v1/analyze`, {
      method: 'POST',
      headers: {
        authorization: `Bearer ${KEY}`,
        ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
      },
      body: form,
    });

    if (res.ok) return res.json();

    const { error } = await res.json();
    // Honor the server's own number rather than guessing at a backoff.
    if (res.status === 429 && attempt < 4) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 2) * 1000));
      continue;
    }
    // 402 can never succeed on retry; 504 only succeeds as an async submission.
    if (error.code === 'sync_timeout') return analyze(path, { ...fields, async: true });
    throw Object.assign(new Error(`${error.code}: ${error.message}`), { code: error.code });
  }
}

const { result } = await analyze('invoice.pdf', { preset: 'invoice' });
const total = result.total.value;                 // null when the document has no total
```

### Python (requests)

```python
import json, os, time, requests

KEY, BASE = os.environ["VISION_API_KEY"], "https://api.visionapi.io"
HEADERS = {"Authorization": f"Bearer {KEY}"}


def analyze(path, **fields):
    data = {k: json.dumps(v) if isinstance(v, (dict, list)) else str(v) for k, v in fields.items()}
    for attempt in range(5):
        with open(path, "rb") as fh:
            res = requests.post(f"{BASE}/v1/analyze", headers=HEADERS, files={"file": fh}, data=data)
        if res.ok:
            return res.json()

        error = res.json()["error"]
        if res.status_code == 429:                       # honor Retry-After, do not guess
            time.sleep(int(res.headers.get("Retry-After", 2)))
            continue
        if error["code"] == "sync_timeout":              # details.suggestion == "async"
            return analyze(path, **{**fields, "async": "true"})
        raise RuntimeError(f"{error['code']}: {error['message']}")
    raise RuntimeError("rate limited after retries")


def wait_for(task_id, timeout=600):
    deadline = time.time() + timeout
    while time.time() < deadline:
        task = requests.get(f"{BASE}/v1/tasks/{task_id}", headers=HEADERS).json()
        if task["status"] not in ("queued", "processing"):
            return task
        time.sleep(2)
    raise TimeoutError(task_id)


body = analyze("invoice.pdf", preset="invoice", min_confidence="mid")
print(body["result"]["invoice_id"]["value"], body["credits_used"])
```

---

## Checklist before shipping an integration

- [ ] Official client used where one exists, or a stated reason it could not be.
- [ ] Key read from the environment, never committed, never sent to a browser.
- [ ] `Idempotency-Key` on anything retryable.
- [ ] `error.code` switch covering 400 `async_required`, 402, 429, 502, 504 explicitly;
      everything else surfaced.
- [ ] `Retry-After` honored on 429.
- [ ] Anything over `MAX_SYNC_PAGES` pages (3 by default) or ~30 s submitted with `async=true`.
- [ ] `value === null` handled as "not present in the document", not as an API failure.
- [ ] `confidence` recorded, and a `min_confidence` policy chosen deliberately.
- [ ] Webhook receiver verifies the HMAC over the raw body before parsing, in constant time,
      and accepts any matching `v1=` part.
- [ ] Preset field names taken from `GET /v1/presets/{name}`, not from memory.
- [ ] Unknown-type files routed with `POST /v1/detect` (1 credit per 5 calls) before committing to a
      preset, rather than firing `auto` blind at a long PDF — and `detection.alternatives`
      checked when `detection.fallback` is `true`.
