Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vision API — Java client

Official Java 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.

Maven Central javadoc license


Install

<dependency>
  <groupId>io.visionapi</groupId>
  <artifactId>visionapi-java</artifactId>
  <version>1.0.0</version>
</dependency>
implementation("io.visionapi:visionapi-java:1.0.0")

Java 17+. No dependenciesjava.net.http and a small internal JSON reader cover everything, so adding this client never forces a Jackson or Gson version on your application.

Quick start

import io.visionapi.*;
import java.nio.file.Path;

VisionApi vision = VisionApi.create(); // reads $VISION_API_KEY

AnalyzeResponse res = vision.analyze(AnalyzeRequest.builder()
        .file(Path.of("invoice.pdf"))
        .preset("invoice")
        .build());

res.result().field("invoice_id").asString().orElse("(none)"); // "A-10422"
res.result().field("total").asDouble().orElse(0);             // 1284.5
res.creditsUsed();                                            // 3

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 embed one in an Android app or anything else you ship to a device.

VisionApi is immutable and safe to share across threads; build one and inject it.


Reading a result

Two rules explain almost every surprise:

1. Every scalar is wrapped in a Field — value plus Confidence.LOW / MID / HIGH.

2. A preset response contains every field of that preset — including the ones the document does not carry, which come back null. A key being present does not mean a value was found.

The accessors are Optional-based and safe on a field that isn't there at all, so reading an optional field never needs a null check:

ExtractionResult r = res.result();

r.field("invoice_id").asString();                  // Optional<String>
r.field("total").asDouble();                       // OptionalDouble
r.field("is_paid").asBoolean();                    // Optional<Boolean>
r.field("invoice_date").asDate();                  // Optional<LocalDate> — the API normalizes to ISO 8601
r.field("reference_numbers").asStrings();          // List<String> for an array-of-scalars field
r.field("carrier").isNull();                       // true — in the preset, absent from the document
r.field("total").atLeast(Confidence.HIGH);         // false when it came back "mid"

Line-item arrays are the one shape worth looking at twice. The array itself is not wrapped; each cell inside each row is:

for (Row row : r.rows("line_item")) {
    row.field("description").asString().orElse("");
    row.field("quantity").asInt().orElse(0);
    row.field("amount").asDouble().orElse(0);
    // or row.unwrap() for a Map<String, Object> ready for a CSV writer
}

An object field works the same way: r.block("seller").field("name").asString().

For the whole thing at once:

r.unwrap();          // Map<String, Object>, wrappers dropped, nulls kept
r.unwrap(true);      // only what was actually found
r.present();         // ["invoice_id", "total", "line_item"]
r.missing();         // ["carrier", …]
r.belowConfidence(Confidence.HIGH);  // your review queue
r.raw();             // the response exactly as it arrived

What you can send

Exactly one source per call:

FileSource.of(Path.of("invoice.pdf"))            // read from disk
FileSource.ofBytes("scan.png", bytes)            // bytes you already hold
FileSource.ofStream("scan.png", inputStream)     // any InputStream (buffered, so retries resend)
FileSource.ofUrl("https://example.com/x.pdf")    // the API fetches it
FileSource.ofBase64(encoded)                     // "data:" prefix optional

AnalyzeRequest.builder().file(path) and .fileUrl(url) are shorthands for the first and fourth.

JPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic bytes — the filename is ignored.

AnalyzeRequest

Builder method 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 the whole transcription alongside the result.
minConfidence LOW Fields below the level come back null, with confidence preserved.

Mutually exclusive combinations are rejected by build() — before any HTTP call, and therefore before any credit could move.


Custom fields

A schema is a flat map: 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.

Map<String, Object> schema = Schema.builder()
        // Plain form — the string is the description, type defaults to string.
        .field("machine_serial", "Serial number of the machine being invoiced, without the \"SN:\" prefix")
        // Typed forms.
        .number("total_net", "Total before tax")
        .date("signed_on", "Date the contract was signed")
        .bool("is_paid", "Whether the invoice is stamped PAID")
        .array("reference_numbers", "All reference numbers", "string")
        // Reserved key: injects fields into every row of the preset's line-item array.
        .lineItem(Schema.of("lot_number", "The lot number printed on the line"))
        .build();

AnalyzeResponse res = vision.analyze(AnalyzeRequest.builder()
        .file(Path.of("invoice.pdf"))
        .preset("invoice")
        .schema(schema)
        .build());

Field names must match ^[a-z][a-z0-9_]{0,63}$. 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", schema);
vision.analyze(AnalyzeRequest.builder().file(path).schemaName("our-invoices").build());

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:

for (Preset preset : vision.presets()) {           // no API key required
    System.out.println(preset.name() + " — " + preset.fieldCount() + " fields");
}

Preset invoice = vision.preset("invoice");
invoice.fields().forEach(f -> System.out.println(f.name() + ": " + f.description()));

Three ways to choose:

// 1. You know what it is.
vision.analyze(AnalyzeRequest.builder().file(path).preset("receipt").build());

// 2. You don't, and you want the data anyway. Classification is free.
AnalyzeResponse res = vision.analyze(AnalyzeRequest.builder().file(path).preset("auto").build());
res.detection().ifPresent(d -> {
    d.preset();        // what ran
    d.isFallback();    // true = "shape unknown", not a match
    d.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.
DetectResponse guess = vision.detect(DetectRequest.builder().file(path).build());
if (!guess.isFallback()) {
    vision.analyze(AnalyzeRequest.builder().file(path).preset(guess.recommended()).build());
}

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 creditsUsed() == 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.

AskResponse res = vision.ask(AskRequest.builder()
        .file(Path.of("photo.jpg"))
        .questions("Is there a dog in the image?", "How many people are visible?")
        .build());

for (AskResponse.Answer a : res.answers()) {
    switch (a.verdict()) {
        case YES, NO -> handle(a.verdict());
        case UNCERTAIN -> review(a);      // the image does not settle it — a real answer
        case NOT_APPLICABLE -> log(a.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 task = vision.analyzeAndWait(
        AnalyzeRequest.builder()
                .file(Path.of("contract-80-pages.pdf"))
                .preset("contract")
                .pages("1-50")
                .build(),
        WaitOptions.defaults()
                .pollInterval(Duration.ofSeconds(2))
                .maxWait(Duration.ofMinutes(15))
                .onPoll(t -> log.info("still {}", t.status())));

// Or submit and walk away — the result comes to you.
TaskRef ref = vision.analyzeAsync(AnalyzeRequest.builder()
        .file(Path.of("contract.pdf"))
        .preset("contract")
        .webhookUrl("https://yourapp.com/hooks/vision")
        .build());

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.

@PostMapping("/hooks/vision")
ResponseEntity<Void> hook(@RequestBody byte[] body, @RequestHeader("X-Vision-Signature") String signature) {
    Webhook.Event event;
    try {
        event = Webhook.verify(body, signature, secret);
    } catch (WebhookSignatureException e) {
        return ResponseEntity.badRequest().build();   // never parse an unverified body
    }

    queue.submit(() -> process(event));               // ack fast, work afterwards
    return ResponseEntity.accepted().build();         // any 2xx is success
}

Take the body as byte[] (or @RequestBody String), not as a parsed object: Spring's JSON converter would re-serialize it and the HMAC would stop matching.

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. event.result() gives you the same ExtractionResult as a synchronous call. 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 API failure throws a subclass of ApiException carrying the HTTP status, the stable code(), and whatever details() the endpoint attached. Catch the class you mean, or switch on code() — never on the message text, which is prose and changes.

try {
    AnalyzeResponse res = vision.analyze(request);
} catch (InsufficientCreditsException e) {
    // Never retried — the balance cannot change as a result of retrying.
    alertOps("needs %d, has %d".formatted(e.required().orElse(0), e.available().orElse(0)));
} catch (SyncTimeoutException e) {
    Task task = vision.analyzeAndWait(request);   // re-submit on the queue
} catch (UnsupportedTypeException e) {
    quarantine("not an image or a PDF");
} catch (ApiException e) {
    log.error("vision {} ({}) request_id={}", e.code(), e.statusCode(), e.requestId().orElse("-"));
}
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

The ErrorCode constants hold every code, for a switch on e.code(). A code this client version has not seen still arrives as a structured ApiException with the code intact.

UsageException (bad arguments), ConnectionException (the request never got a response) and TaskFailedException / TaskTimeoutException come from the client itself. All of them extend VisionException, which extends RuntimeException — nothing here is checked.

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 job that re-runs, a queue that redelivers — because a fresh process generates a fresh key:

AnalyzeRequest.builder().file(path).preset("invoice").idempotencyKey("invoice-" + invoiceId).build();

Reusing a key with a different payload throws ConflictException, which is the mechanism working: it means the key already stands for something else.


Configuration

VisionApi vision = VisionApi.builder()
        .apiKey(System.getenv("VISION_API_KEY"))   // default: $VISION_API_KEY
        .baseUrl("https://api.visionapi.io")       // default; override for self-hosted
        .timeout(Duration.ofSeconds(120))          // per request
        .maxRetries(3)
        .autoIdempotency(true)
        .header("X-Trace-Id", traceId)             // sent on every request
        .httpClient(myHttpClient)                  // your own executor, proxy, TLS config
        .build();

Spring Boot

@Bean
VisionApi visionApi(@Value("${vision.api-key}") String key) {
    return VisionApi.create(key);
}

Account and usage

Credits credits = vision.credits();
credits.balance();  // buckets are spent in order: subscription → rollover → pack → welcome

String cursor = null;
do {
    RequestPage page = vision.requests(100, cursor);
    page.data().forEach(r -> System.out.println(r.createdAt() + " " + r.endpoint() + " " + r.creditsUsed()));
    cursor = page.nextCursor().orElse(null);
} while (cursor != null);

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 single-file programs in examples/ — Java 17 can run a .java file directly, so no build step is needed:

File What it shows
Analyze.java The smallest useful call, and how to read the result
CustomSchema.java Custom fields, line-item injection, saved schemas
DetectThenAnalyze.java Routing a mixed inbox before spending on extraction
AsyncBatch.java A folder of long PDFs, queued with bounded concurrency
WebhookServer.java A verified receiver on the JDK's own HTTP server
Ask.java Visual Q&A and the Verdict enum
mvn package
export VISION_API_KEY=sk_live_…
java -cp target/visionapi-java-1.0.0.jar examples/Analyze.java invoice.pdf

Development

mvn test       # offline: a JDK HTTP server stands in for the API, no key needed
mvn package
mvn javadoc:javadoc

Contributing

Issues and pull requests are welcome at https://github.com/devrobotlabs/visionapi-java. 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 Java client for the Vision API. Extract structured JSON from images and PDFs, with a confidence level on every field. Java 17+, no dependencies.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages