Official Go 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
go get github.com/devrobotlabs/visionapi-goGo 1.21+. Standard library only — no dependencies to vendor or audit.
package main
import (
"context"
"fmt"
"log"
"github.com/devrobotlabs/visionapi-go"
)
func main() {
client, err := visionapi.New() // reads $VISION_API_KEY
if err != nil {
log.Fatal(err)
}
res, err := client.Analyze(context.Background(), visionapi.AnalyzeParams{
Source: visionapi.FilePath("invoice.pdf"),
Preset: "invoice",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(res.Result.Field("invoice_id").String()) // "A-10422"
total, ok := res.Result.Field("total").Float() // ok is false when absent
fmt.Println(total, ok, res.CreditsUsed, res.CreditsRemaining)
}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 a client binary you ship.
Two rules explain almost every surprise:
1. Every scalar is wrapped in {value, confidence}, where confidence is Low, Mid
or High.
2. A preset response contains every field of that preset — including the ones the document does not carry, which come back with a nil value. A key being present does not mean a value was found.
Result gives you typed accessors that are safe on any response, including for a field
that isn't there:
r := res.Result
r.Field("invoice_id").String() // "" when absent
r.Field("total").Float() // (float64, bool)
r.Field("is_paid").Bool() // (bool, bool)
r.Field("invoice_date").Date() // (time.Time, bool) — the API normalizes to ISO 8601
r.Field("reference_numbers").Strings() // []string for an array-of-scalars field
r.Field("carrier").IsNull() // true — present in the preset, absent from the document
r.Field("total").Confidence.AtLeast(visionapi.High)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 := range r.Rows("line_item") {
fmt.Println(
row.Field("description").String(),
row.Field("quantity").Int(),
row.Field("amount").Float(),
)
// or row.Plain() for a map[string]any ready for a CSV writer
}An object field works the same way: r.Block("seller").Field("name").String().
Exactly one source per call:
visionapi.FilePath("invoice.pdf") // read from disk
visionapi.FileBytes("scan.png", data) // bytes you already hold
visionapi.FileReader("scan.png", r) // any io.Reader (buffered, so retries resend)
visionapi.FileURL("https://example.com/invoice.pdf") // the API fetches it
visionapi.FileBase64(encoded) // "data:" prefix optionalJPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic bytes — the filename is ignored.
| Field | 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 FullText, the whole transcription, alongside Result. |
MinConfidence |
Low |
Fields below the level come back null, with confidence preserved. |
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.
res, err := client.Analyze(ctx, visionapi.AnalyzeParams{
Source: visionapi.FilePath("invoice.pdf"),
Preset: "invoice",
Schema: visionapi.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": visionapi.FieldSpec{Type: "number", Description: "Total before tax"},
"signed_on": visionapi.FieldSpec{Type: "date", Description: "Date the contract was signed"},
// Reserved key: injects fields into every row of the preset's line-item array.
"line_item": visionapi.Schema{"lot_number": "The lot number printed on the line"},
},
})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:
client.CreateSchema(ctx, "our-invoices", "invoice", schema)
client.Analyze(ctx, visionapi.AnalyzeParams{Source: src, SchemaName: "our-invoices"})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:
presets, _ := client.Presets(ctx) // no API key required
invoice, _ := client.Preset(ctx, "invoice")
for _, f := range invoice.Fields {
fmt.Println(f.Name, f.Type, f.Description)
}Three ways to choose:
// 1. You know what it is.
client.Analyze(ctx, visionapi.AnalyzeParams{Source: src, Preset: "receipt"})
// 2. You don't, and you want the data anyway. Classification is free.
res, _ := client.Analyze(ctx, visionapi.AnalyzeParams{Source: src, 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, _ := client.Detect(ctx, visionapi.DetectParams{Source: src})
if guess.Recommended == "invoice" && !guess.Fallback {
client.Analyze(ctx, visionapi.AnalyzeParams{Source: src, Preset: "invoice"})
}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.
res, err := client.Ask(ctx, visionapi.AskParams{
Source: visionapi.FilePath("photo.jpg"),
Questions: []string{"Is there a dog in the image?", "How many people are visible?"},
})
for _, a := range res.Answers {
switch a.Verdict {
case visionapi.Yes:
// …
case visionapi.Uncertain:
// A yes/no question the image does not settle — a real answer, not a failure.
case visionapi.NotApplicable:
// It wasn't a yes/no question; read a.Answer.
}
}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, err := client.AnalyzeAndWait(ctx,
visionapi.AnalyzeParams{
Source: visionapi.FilePath("contract-80-pages.pdf"),
Preset: "contract",
Pages: "1-50",
},
visionapi.PollInterval(2*time.Second),
visionapi.MaxWait(15*time.Minute),
visionapi.OnPoll(func(t *visionapi.Task) { log.Println(t.Status) }),
)
// Or submit and walk away — the result comes to you.
ref, err := client.AnalyzeAsync(ctx, visionapi.AnalyzeParams{
Source: visionapi.FilePath("contract.pdf"),
Preset: "contract",
WebhookURL: "https://yourapp.com/hooks/vision",
})Results stay retrievable for 7 days; after that GetTask returns an *APIError with code
result_expired (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.
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := visionapi.VerifyWebhook(body, r.Header.Get("X-Vision-Signature"), secret, 0)
if err != nil {
w.WriteHeader(http.StatusBadRequest) // never parse an unverified body
return
}
w.WriteHeader(http.StatusAccepted) // any 2xx is success — ack fast, work afterwards
go process(event) // event.Event is "task.completed" | "task.failed"
}VerifyWebhook 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.ExtractionResult() decodes the payload into the same
Result type 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 is an *APIError carrying the HTTP status, the stable Code, and
whatever Details the endpoint attached. Branch on Code — never on the message text,
which is prose and changes.
res, err := client.Analyze(ctx, params)
if err != nil {
var apiErr *visionapi.APIError
switch {
case errors.As(err, &apiErr):
switch apiErr.Code {
case visionapi.CodeInsufficientCredits:
// Never retried — the balance cannot change as a result of retrying.
log.Printf("needs %d, has %d", apiErr.Required(), apiErr.Available())
case visionapi.CodeSyncTimeout:
task, err := client.AnalyzeAndWait(ctx, params) // re-submit on the queue
case visionapi.CodeUnsupportedType:
quarantine("not an image or a PDF")
default:
log.Printf("%s (%d) request_id=%s", apiErr.Code, apiErr.StatusCode, apiErr.RequestID)
}
default:
// *UsageError, *ConnectionError, *TaskFailedError, *TaskTimeoutError
log.Println(err)
}
}For a single-code check there is a shorthand:
if visionapi.IsCode(err, visionapi.CodeInsufficientCredits) { … }| HTTP | Code constants |
|---|---|
| 400 | CodeInvalidRequest |
| 401 | CodeInvalidAPIKey, CodeUnauthorized |
| 402 | CodeInsufficientCredits — with .Required() / .Available() |
| 403 | CodeForbidden, CodeEmailNotVerified |
| 404 | CodeTaskNotFound, CodeSchemaNotFound |
| 409 | CodeConflict |
| 410 | CodeResultExpired |
| 413 | CodeFileTooLarge, CodePageLimitExceeded |
| 415 | CodeUnsupportedType |
| 422 | CodePDFEncrypted, CodeInvalidPageSelection, CodeInvalidSchema, CodeSchemaFieldConflict, CodeTooManyQuestions |
| 429 | CodeRateLimited — with .RetryAfter() |
| 500 | CodeInternalError — with .RequestID |
| 502 | CodeProviderError |
| 504 | CodeSyncTimeout |
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:
params.IdempotencyKey = fmt.Sprintf("invoice-%d", invoiceID)Reusing a key with a different payload is a 409 CodeConflict, which is the mechanism
working: it means the key already stands for something else.
client, err := visionapi.New(
visionapi.WithAPIKey(os.Getenv("VISION_API_KEY")), // default: $VISION_API_KEY
visionapi.WithBaseURL("https://api.visionapi.io"), // default
visionapi.WithHTTPClient(&http.Client{Timeout: 120 * time.Second}),
visionapi.WithMaxRetries(3),
visionapi.WithAutoIdempotency(true),
visionapi.WithHeader("X-Trace-Id", traceID),
)Every call takes a context.Context, so cancellation and deadlines work the way you
expect — including during the sleep between retries and between task polls.
credits, _ := client.Credits(ctx)
// credits.Buckets are spent in order: Subscription → Rollover → Pack → Welcome
cursor := ""
for {
page, err := client.Requests(ctx, 100, cursor)
if err != nil { break }
for _, r := range page.Data {
fmt.Println(r.CreatedAt, r.Endpoint, r.Preset, r.CreditsUsed)
}
if page.NextCursor == "" { break }
cursor = page.NextCursor
}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 programs in examples/:
| Directory | What it shows |
|---|---|
analyze/ |
The smallest useful call, and how to read the result |
customschema/ |
Custom fields, line-item injection, saved schemas |
detect/ |
Routing a mixed inbox before spending on extraction |
asyncbatch/ |
A folder of long PDFs, queued with bounded concurrency |
webhook/ |
A verified receiver, in one file |
ask/ |
Visual Q&A and the Verdict field |
export VISION_API_KEY=sk_live_…
go run ./examples/analyze invoice.pdfgo test ./... # offline: a httptest server stands in for the API
go vet ./...
gofmt -l .Issues and pull requests are welcome at https://github.com/devrobotlabs/visionapi-go. For anything about the API itself — a preset, a limit, an error code — https://support.visionapi.io reaches the team faster.
MIT © Vision API