Skip to content

Repository files navigation

Vision API — PHP client

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.

PackagistPHP versionlicense


Install

composer require visionapi/visionapi-php

PHP 8.1+, with ext-curl and ext-json. No package dependencies.

Quick start

useVisionApi\Client;
$vision = newClient(); // 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 totalecho$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:

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

KeyDefaultWhat it does
presetA catalog name, or "auto" to let the API classify the file first (free).
schemaCustom fields, alone or on top of a preset.
schema_nameA schema saved in your dashboard. Excludes preset and schema.
pagesallPDF page selection, e.g. "1-3,7". You pay for selected pages only.
language_hintautoISO 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_textfalseAdds 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 requiredecho"{$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.

useVisionApi\Webhook;
useVisionApi\Exception\WebhookSignatureException;
// Laravelpublicfunction__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) {
returnresponse()->noContent(400); // never parse an unverified body
}
ProcessVisionResult::dispatch($event); // ack fast, work afterwardsreturnresponse()->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 anyv1= 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.

useVisionApi\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}");
}
ClassHTTPCodes
InvalidRequestException400invalid_request
AuthenticationException401invalid_api_key, unauthorized
InsufficientCreditsException402insufficient_credits — with required() / available()
PermissionDeniedException403forbidden, email_not_verified
NotFoundException404task_not_found, schema_not_found
ConflictException409conflict
ResultExpiredException410result_expired
PayloadTooLargeException413file_too_large, page_limit_exceeded
UnsupportedTypeException415unsupported_type
UnprocessableException422pdf_encrypted, invalid_page_selection, invalid_schema, schema_field_conflict, too_many_questions
RateLimitException429rate_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
InternalException500internal_error — with $requestId
ProviderException502provider_error
SyncTimeoutException504sync_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 = newClient([
'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 () => newClient(['api_key' => config('services.vision.key')]));

Account and usage

$credits = $vision->credits();
// $credits['buckets'] are spent in order: subscription → rollover → pack → welcomeforeach ($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:

LimitValue
Max file size20 MB
Max PDF pages per request50
Sync request timeout60 s

Per plan:

LimitFreeStarterGrowthProScale
Requests per minute, per key1060120300600
Burst capacity201202406001,200
Concurrent async tasks1481632
Active API keys per account15102050
Saved schemas31025100unlimited
Max questions per ask5551010

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

FileWhat it shows
analyze.phpThe smallest useful call, and how to read the result
custom_schema.phpCustom fields, line-item injection, saved schemas
detect_then_analyze.phpRouting a mixed inbox before spending on extraction
async_batch.phpA folder of long PDFs, queued and collected
webhook.phpA verified receiver, framework-free
ask.phpVisual 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

About

Official PHP client for the Vision API. Extract structured JSON from images and PDFs, with a confidence level on every field.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages