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.

Packagist PHP version license


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

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