Official Swift 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
Swift Package Manager:
dependencies: [
.package(url: "https://github.com/devrobotlabs/visionapi-swift.git", from: "1.0.0"),
]Or in Xcode: File → Add Package Dependencies… and paste the URL.
Swift 5.9+, on macOS 12 / iOS 15 / tvOS 15 / watchOS 8 / visionOS 1, and Linux. On Apple
platforms it has no dependencies — CryptoKit is built in; swift-crypto is pulled in on
Linux only.
import VisionAPI
let vision = try VisionAPI() // reads $VISION_API_KEY
let res = try await vision.analyze(
AnalyzeRequest(file: .file(URL(fileURLWithPath: "invoice.pdf")), preset: "invoice"))
res.result["invoice_id"].string // "A-10422"
res.result["total"].double // 1284.5 — nil when the invoice has no total
res.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.
Do not ship a key inside an app. There is no publishable key and no test mode — an API key is a live spending credential, and anything in an app bundle can be read out of it. Use this library from a server (Vapor, Hummingbird, a Lambda), and have the app talk to that. On-device, the only safe pattern is your own endpoint holding the key.
VisionAPI is an actor, so it is safe to share across tasks.
Two rules explain almost every surprise:
1. Every scalar is wrapped in a Field — value plus .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.
Accessors are optional-typed and safe on a field that isn't there at all:
let r = res.result
r["invoice_id"].string // String?
r["total"].double // Double?
r["is_paid"].bool // Bool?
r["invoice_date"].date // Date? — the API normalizes to ISO 8601
r["reference_numbers"].strings // [String] for an array-of-scalars field
r["carrier"].isNull // true — in the preset, absent from the document
r["total"].atLeast(.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 in r.rows("line_item") {
row["description"].string
row["quantity"].int
row["amount"].double
// or row.unwrap() for a [String: Any] ready for a CSV writer
}An object field works the same way: r.block("seller")["name"].string.
For the whole thing at once:
r.unwrap() // [String: Any], wrappers dropped, nulls kept as NSNull
r.unwrap(dropNull: true) // only what was actually found
r.present // ["invoice_id", "total", "line_item"]
r.missing // ["carrier", …]
r.belowConfidence(.high) // your review queue
r.raw // the response exactly as it arrived, as JSONValueExactly one source per call:
.file(URL(fileURLWithPath: "invoice.pdf")) // read from disk
.bytes(imageData, filename: "scan.png") // bytes you already hold
.url("https://example.com/invoice.pdf") // the API fetches it
.base64(encoded) // "data:" prefix optionalA UIImage or NSImage becomes .bytes(image.jpegData(compressionQuality: 0.9)!, filename: "photo.jpg").
JPEG, PNG, WebP, TIFF and PDF, up to 20 MB and 50 pages. The type is detected from magic bytes — the filename is ignored.
| Property | 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. |
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.
var request = AnalyzeRequest(file: .file(url), preset: "invoice")
request.schema = Schema()
// 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", of: "string")
// Reserved key: injects fields into every row of the preset's line-item array.
.lineItem(Schema("lot_number", "The lot number printed on this line"))
let res = try await vision.analyze(request)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:
try await vision.createSchema("our-invoices", preset: "invoice", schema: schema)
var request = AnalyzeRequest(file: .file(url))
request.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:
for preset in try await vision.presets() { // no API key required
print(preset.name, preset.kind, preset.fieldCount)
}
let invoice = try await vision.preset("invoice")
invoice.fields.map(\.name)Three ways to choose:
// 1. You know what it is.
try await vision.analyze(AnalyzeRequest(file: .file(url), preset: "receipt"))
// 2. You don't, and you want the data anyway. Classification is free.
let res = try await vision.analyze(AnalyzeRequest(file: .file(url), 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.
let guess = try await vision.detect(DetectRequest(file: .file(url)))
if !guess.fallback {
try await vision.analyze(AnalyzeRequest(file: .file(url), 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 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.
let res = try await vision.ask(AskRequest(
file: .file(photoURL),
questions: ["Is there a dog in the image?", "How many people are visible?"]))
for answer in res.answers {
switch answer.verdict {
case .yes, .no: handle(answer.verdict)
case .uncertain: review(answer) // the image does not settle it — a real answer
case .notApplicable: print(answer.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.
let task = try await vision.analyzeAndWait(
request,
options: WaitOptions(pollInterval: 2, maxWait: 900))
// Or submit and walk away — the result comes to you.
var queued = AnalyzeRequest(file: .file(url), preset: "contract")
queued.webhookURL = "https://yourapp.com/hooks/vision"
let ref = try await vision.analyzeAsync(queued)Results stay retrievable for 7 days; after that getTask throws 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.
// Vapor
app.post("hooks", "vision") { req async throws -> HTTPStatus in
let body = Data(buffer: req.body.data ?? ByteBuffer())
let event: WebhookEvent
do {
event = try Webhook.verify(
body: body,
signature: req.headers.first(name: "X-Vision-Signature"),
secret: Environment.get("VISION_WEBHOOK_SECRET")!)
} catch {
return .badRequest // never parse an unverified body
}
Task { await process(event) } // ack fast, work afterwards
return .accepted // 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. 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.
Everything throws VisionError. The API's own failures arrive as .api(APIError) carrying
the HTTP status, the stable code, and whatever details the endpoint attached. Switch on
the code — never on the message text, which is prose and changes.
do {
let res = try await vision.analyze(request)
} catch let error as VisionError {
switch error {
case .api(let apiError):
switch apiError.code {
case .insufficientCredits:
// Never retried — the balance cannot change as a result of retrying.
alertOps("needs \(apiError.required ?? 0), has \(apiError.available ?? 0)")
case .syncTimeout:
_ = try await vision.analyzeAndWait(request) // re-submit on the queue
case .unsupportedType:
quarantine("not an image or a PDF")
default:
log(apiError.code.rawValue, apiError.statusCode, apiError.requestID)
}
case .usage(let message):
assertionFailure(message) // a mistake in the call — nothing was charged
case .connection, .taskFailed, .taskTimeout, .webhookSignature:
log(error.description)
}
}For a single check there is a shorthand: error.isCode(.insufficientCredits).
ErrorCode |
HTTP | Meaning |
|---|---|---|
.invalidRequest |
400 | The call is malformed. Not retryable. |
.invalidAPIKey |
401 | Key missing, unknown or revoked. |
.insufficientCredits |
402 | With required / available. Never retryable. |
.forbidden, .emailNotVerified |
403 | Not allowed. |
.taskNotFound, .schemaNotFound |
404 | Wrong id, or not yours. |
.conflict |
409 | Name taken, or an idempotency key reused differently. |
.resultExpired |
410 | Past the 7-day window. Re-submit the file. |
.fileTooLarge, .pageLimitExceeded |
413 | Over 20 MB / 50 pages. Split the input. |
.unsupportedType |
415 | Not a supported image or PDF. |
.pdfEncrypted, .invalidPageSelection, .invalidSchema, .schemaFieldConflict, .tooManyQuestions |
422 | Semantic input error. |
.rateLimited |
429 | With retryAfter. |
.internalError |
500 | With requestID. |
.providerError |
502 | The model provider failed after retries. |
.syncTimeout |
504 | Re-submit asynchronously. |
A code this client version has not seen arrives as .unknown(String) with the value intact.
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
background task that re-runs, a queue that redelivers:
request.idempotencyKey = "invoice-\(invoice.id)"Reusing a key with a different payload is a .conflict, which is the mechanism working:
it means the key already stands for something else.
let vision = try VisionAPI(
apiKey: ProcessInfo.processInfo.environment["VISION_API_KEY"],
baseURL: "https://api.visionapi.io", // default; override for a self-hosted deployment
session: .shared, // your own URLSession: proxy, caching, mocks
timeout: 120,
maxRetries: 3,
autoIdempotency: true,
headers: ["X-Trace-Id": traceID])let credits = try await vision.credits()
credits.balance // buckets are spent in order: subscription → rollover → pack → welcome
var cursor: String?
repeat {
let page = try await vision.requests(limit: 100, cursor: cursor)
page.data.forEach { print($0.createdAt ?? .distantPast, $0.endpoint, $0.creditsUsed) }
cursor = page.nextCursor
} while cursor != nilUsage 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 snippets in Examples/:
| File | What it shows |
|---|---|
Analyze.swift |
The smallest useful call, and how to read the result |
CustomSchema.swift |
Custom fields, line-item injection, saved schemas |
DetectThenAnalyze.swift |
Routing a mixed inbox before spending on extraction |
AsyncBatch.swift |
A folder of long PDFs, queued with bounded concurrency |
Ask.swift |
Visual Q&A and the Verdict enum |
SwiftUIScanner.swift |
The app-side half of the backend-proxy pattern |
swift build
swift test # offline: URLProtocol intercepts every request, no key neededIssues and pull requests are welcome at https://github.com/devrobotlabs/visionapi-swift. For anything about the API itself — a preset, a limit, an error code — https://support.visionapi.io reaches the team faster.
MIT © Vision API