visionapi / visionapi-php
Official PHP client for the Vision API — credit-based OCR and visual intelligence. Extract structured JSON from images and PDFs.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.49
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
README
Official PHP 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
Install
composer require visionapi/visionapi-php
PHP 8.1+, with ext-curl and ext-json. No package dependencies.
Quick start
use VisionApi\Client; $vision = new Client(); // reads $_ENV['VISION_API_KEY'] $res = $vision->analyze(['file' => 'invoice.pdf', 'preset' => 'invoice']); echo $res['result']['invoice_id']['value']; // 'A-10422' echo $res['result']['total']['value']; // 1284.5, or null if the invoice has no total echo $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. Keep it in the environment, never in a repository and never in anything a browser downloads.
Reading a result
Responses are plain arrays with the wire's keys, so everything you already know about arrays applies. Two rules explain almost every surprise:
1. Every scalar is wrapped. ['value' => …, 'confidence' => 'low'|'mid'|'high']. Read
$res['result']['total']['value'], not $res['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.
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']], ], ]
VisionApi\Result covers the common readings, so you rarely have to spell that out:
use VisionApi\Result; Result::unwrap($res['result']); // ['invoice_id' => 'A-10422', 'carrier' => null, 'line_item' => [['description' => 'Widget', …]]] Result::unwrap($res['result'], dropNull: true); // only what was actually found Result::value($res['result'], 'total', 0); // 1284.5, or 0 when absent Result::rows($res['result'], 'line_item'); // [] when the invoice has no lines Result::present($res['result']); // ['invoice_id', 'total', 'line_item'] Result::missing($res['result']); // ['carrier', …] Result::belowConfidence($res['result'], 'high'); // fields to route to a human
What you can send
Exactly one file source per call:
$vision->analyze(['file' => 'invoice.pdf', 'preset' => 'invoice']); // a path $vision->analyze(['file' => fopen('invoice.pdf', 'rb'), …]); // an open stream $vision->analyze(['file' => $bytes, …]); // raw bytes $vision->analyze(['file' => ['scan.png', $bytes], …]); // bytes + a name $vision->analyze(['file_url' => 'https://example.com/invoice.pdf', …]); // a public URL $vision->analyze(['file_base64' => $encoded, …]); // "data:" prefix optional
A Laravel upload is a stream: ['file' => fopen($request->file('doc')->getRealPath(), 'rb')].
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
| Key | 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. |
schema_name |
— | 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. |
language_hint |
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. |
include_raw_text |
false |
Adds full_text, the whole transcription, alongside result. |
min_confidence |
"low" |
Fields below the level come back null, with confidence preserved. |
Custom fields
A schema is a flat array: 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.
$res = $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'], // 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:
$vision->createSchema('our-invoices', 'invoice', ['machine_serial' => '…']); $vision->analyze(['file' => 'invoice.pdf', 'schema_name' => '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:
foreach ($vision->presets() as $preset) { // no API key required echo "{$preset['name']} ({$preset['kind']}) — {$preset['field_count']} fields\n"; } $invoice = $vision->preset('invoice'); array_column($invoice['fields'], 'name');
Three ways to choose:
// 1. You know what it is. $vision->analyze(['file' => 'receipt.jpg', 'preset' => 'receipt']); // 2. You don't, and you want the data anyway. Classification is free. $res = $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. $guess = $vision->detect(['file' => 'unknown.pdf']); if (!$guess['fallback']) { $vision->analyze(['file' => 'unknown.pdf', 'preset' => $guess['recommended']]); }
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.
$res = $vision->ask([ 'file' => 'photo.jpg', 'questions' => ['Is there a dog in the image?', 'How many people are visible?'], ]); foreach ($res['answers'] as $answer) { match ($answer['verdict']) { 'yes', 'no' => handle($answer['verdict']), 'uncertain' => flagForReview($answer), // the image does not settle it — a real answer 'n/a' => print($answer['answer']), // it wasn't a yes/no question }; }
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 loop and the failure case. $task = $vision->analyzeAndWait([ 'file' => 'contract-80-pages.pdf', 'preset' => 'contract', 'pages' => '1-50', 'poll_interval' => 2.0, 'max_wait' => 900.0, 'on_poll' => fn (array $t) => Log::info($t['status']), ]); // Or submit and walk away — the result comes to you. $ref = $vision->analyzeAsync([ 'file' => 'contract.pdf', 'preset' => 'contract', 'webhook_url' => 'https://yourapp.com/hooks/vision', ]);
Results stay retrievable for 7 days; after that getTask throws ResultExpiredException
(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.
use VisionApi\Webhook; use VisionApi\Exception\WebhookSignatureException; // Laravel public function __invoke(Request $request) { try { $event = Webhook::verify( $request->getContent(), // the raw body, untouched $request->header('X-Vision-Signature'), config('services.vision.webhook_secret') ); } catch (WebhookSignatureException) { return response()->noContent(400); // never parse an unverified body } ProcessVisionResult::dispatch($event); // ack fast, work afterwards return response()->noContent(202); // any 2xx is success }
Webhook::verify 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 VisionApi\Exception\ApiException carrying the HTTP
status, the stable errorCode, and whatever details the endpoint attached. Catch the
class you mean, or switch on errorCode — never on the message text, which is prose and
changes.
use VisionApi\Exception\{ApiException, InsufficientCreditsException, SyncTimeoutException, UnsupportedTypeException}; try { $res = $vision->analyze(['file' => 'scan.pdf', 'preset' => 'invoice']); } catch (InsufficientCreditsException $e) { alertOps("needs {$e->required()}, has {$e->available()}"); // never retried — it cannot succeed } catch (SyncTimeoutException) { $task = $vision->analyzeAndWait(['file' => 'scan.pdf', 'preset' => 'invoice']); } catch (UnsupportedTypeException) { quarantine('not an image or a PDF'); } catch (ApiException $e) { Log::error("vision {$e->errorCode} ({$e->status}) request_id={$e->requestId}"); }
| Class | HTTP | Codes |
|---|---|---|
InvalidRequestException |
400 | invalid_request |
AuthenticationException |
401 | invalid_api_key, unauthorized |
InsufficientCreditsException |
402 | insufficient_credits — with required() / available() |
PermissionDeniedException |
403 | forbidden, email_not_verified |
NotFoundException |
404 | task_not_found, schema_not_found |
ConflictException |
409 | conflict |
ResultExpiredException |
410 | result_expired |
PayloadTooLargeException |
413 | file_too_large, page_limit_exceeded |
UnsupportedTypeException |
415 | unsupported_type |
UnprocessableException |
422 | pdf_encrypted, invalid_page_selection, invalid_schema, schema_field_conflict, too_many_questions |
RateLimitException |
429 | rate_limited — with retryAfter(); also too_many_tasks, the per-plan async concurrency cap, which clears when one of your own tasks finishes rather than on a timer |
InternalException |
500 | internal_error — with $requestId |
ProviderException |
502 | provider_error |
SyncTimeoutException |
504 | sync_timeout |
UsageException (bad arguments), ConnectionException / TimeoutException (the request
never got a response) and TaskFailedException / TaskTimeoutException come from the
client itself.
Retries and idempotency
The client retries 429, 500, 502 and network failures — three attempts 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
queued job that re-runs, a webhook that redelivers — because a fresh process generates a
fresh key:
$vision->analyze(['file' => $path, 'preset' => 'invoice', 'idempotency_key' => "invoice-{$invoice->id}"]);
Reusing a key with a different payload throws ConflictException, which is the mechanism
working: it means the key already stands for something else.
Configuration
$vision = new Client([ 'api_key' => $_ENV['VISION_API_KEY'], // default: $_ENV['VISION_API_KEY'] 'base_url' => 'https://api.visionapi.io', // default; override for a self-hosted deployment 'timeout' => 120.0, // per request, seconds 'max_retries' => 3, 'auto_idempotency' => true, 'headers' => ['X-Trace-Id' => $traceId], // sent on every request 'transport' => $myTransport, // swap the HTTP layer (tests, proxies, metrics) ]);
Every method takes per-call idempotency_key and timeout in its params array.
Laravel
// config/services.php 'vision' => [ 'key' => env('VISION_API_KEY'), 'webhook_secret' => env('VISION_WEBHOOK_SECRET'), ], // AppServiceProvider::register() $this->app->singleton(Client::class, fn () => new Client(['api_key' => config('services.vision.key')]));
Account and usage
$credits = $vision->credits(); // $credits['buckets'] are spent in order: subscription → rollover → pack → welcome foreach ($vision->eachRequest(limit: 100) as $record) { echo "{$record['created_at']} {$record['endpoint']} {$record['preset']} {$record['credits_used']}\n"; }
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.
Examples
Runnable scripts in examples/:
| File | What it shows |
|---|---|
analyze.php |
The smallest useful call, and how to read the result |
custom_schema.php |
Custom fields, line-item injection, saved schemas |
detect_then_analyze.php |
Routing a mixed inbox before spending on extraction |
async_batch.php |
A folder of long PDFs, queued and collected |
webhook.php |
A verified receiver, framework-free |
ask.php |
Visual Q&A and the verdict field |
export VISION_API_KEY=sk_live_…
php examples/analyze.php invoice.pdf
Development
composer install composer test # offline: the transport is stubbed, no key and no network needed composer analyse # phpstan level 6 composer lint
Contributing
Issues and pull requests are welcome at https://github.com/devrobotlabs/visionapi-php. 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