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.
- 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
<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 dependencies — java.net.http and a small internal JSON reader cover
everything, so adding this client never forces a Jackson or Gson version on your
application.
importio.visionapi.*;
importjava.nio.file.Path;
VisionApivision = VisionApi.create(); // reads $VISION_API_KEYAnalyzeResponseres = 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.5res.creditsUsed(); // 3Requests 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.
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:
ExtractionResultr = res.result();
r.field("invoice_id").asString(); // Optional<String>r.field("total").asDouble(); // OptionalDoubler.field("is_paid").asBoolean(); // Optional<Boolean>r.field("invoice_date").asDate(); // Optional<LocalDate> — the API normalizes to ISO 8601r.field("reference_numbers").asStrings(); // List<String> for an array-of-scalars fieldr.field("carrier").isNull(); // true — in the preset, absent from the documentr.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 (Rowrow : 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 keptr.unwrap(true); // only what was actually foundr.present(); // ["invoice_id", "total", "line_item"]r.missing(); // ["carrier", …]r.belowConfidence(Confidence.HIGH); // your review queuer.raw(); // the response exactly as it arrivedExactly one source per call:
FileSource.of(Path.of("invoice.pdf")) // read from diskFileSource.ofBytes("scan.png", bytes) // bytes you already holdFileSource.ofStream("scan.png", inputStream) // any InputStream (buffered, so retries resend)FileSource.ofUrl("https://example.com/x.pdf") // the API fetches itFileSource.ofBase64(encoded) // "data:" prefix optionalAnalyzeRequest.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.
| 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.
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();
AnalyzeResponseres = 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());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 (Presetpreset : vision.presets()) { // no API key requiredSystem.out.println(preset.name() + " — " + preset.fieldCount() + " fields");
}
Presetinvoice = 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.AnalyzeResponseres = vision.analyze(AnalyzeRequest.builder().file(path).preset("auto").build());
res.detection().ifPresent(d -> {
d.preset(); // what rand.isFallback(); // true = "shape unknown", not a matchd.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.DetectResponseguess = 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.
Up to 5 questions about one file, priced exactly like an extraction. The questions themselves are free.
AskResponseres = 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.Answera : res.answers()) {
switch (a.verdict()) {
caseYES, NO -> handle(a.verdict());
caseUNCERTAIN -> review(a); // the image does not settle it — a real answercaseNOT_APPLICABLE -> log(a.answer()); // it wasn't a yes/no question
}
}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.Tasktask = 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.TaskRefref = 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).
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(@RequestBodybyte[] body, @RequestHeader("X-Vision-Signature") Stringsignature) {
Webhook.Eventevent;
try {
event = Webhook.verify(body, signature, secret);
} catch (WebhookSignatureExceptione) {
returnResponseEntity.badRequest().build(); // never parse an unverified body
}
queue.submit(() -> process(event)); // ack fast, work afterwardsreturnResponseEntity.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 anyv1= 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.
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 {
AnalyzeResponseres = vision.analyze(request);
} catch (InsufficientCreditsExceptione) {
// 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 (SyncTimeoutExceptione) {
Tasktask = vision.analyzeAndWait(request); // re-submit on the queue
} catch (UnsupportedTypeExceptione) {
quarantine("not an image or a PDF");
} catch (ApiExceptione) {
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.
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.
VisionApivision = 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();@BeanVisionApivisionApi(@Value("${vision.api-key}") Stringkey) {
returnVisionApi.create(key);
}Creditscredits = vision.credits();
credits.balance(); // buckets are spent in order: subscription → rollover → pack → welcomeStringcursor = null;
do {
RequestPagepage = 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.
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.
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.pdfmvn test# offline: a JDK HTTP server stands in for the API, no key needed
mvn package
mvn javadoc:javadocIssues 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.
MIT © Vision API