Receipt OCR API: photo to typed JSON in one call

Send a receipt photo and a JSON Schema, get merchant, date, line items and total back as typed JSON. One request, a poll, and what a receipt costs.

5 min readUpdated 17 October 2026

A receipt OCR API should return typed fields, not a block of text. With Veralens, a document extraction API (OCR plus schema-typed JSON), you send a receipt photo and a JSON Schema and get merchant, date, line items and total back as JSON, measured at $1.45 to $3.93 per 1,000 receipts on the receipt sample in pricing.

Veralens vera-1.0-lowVeralens vera-1.0-high
Cost per 1,000 receipts (sample on /docs/pricing)Veralens vera-1.0-low$1.45Veralens vera-1.0-high$3.93
OutputVeralens vera-1.0-lowYour JSON Schema, typedVeralens vera-1.0-highYour JSON Schema, typed
Best forVeralens vera-1.0-lowSimple receipts at volumeVeralens vera-1.0-highCrumpled or faded thermal paper
BillingVeralens vera-1.0-lowPer token, prepaid from $5Veralens vera-1.0-highPer token, prepaid from $5

Veryfi has a dedicated receipt model and mobile SDKs, which Veralens does not; see Veryfi alternative for a fair comparison. Your photos and schema will differ, so run twenty of your own and read usage.cost. Updated 2026-10-17.

Plain OCR hands back characters and leaves you to find the total in them. With Veralens you describe the output you want as a JSON Schema, send a photo of the receipt, and get a typed object back: merchant, date, line items, total.

This post makes that call end to end, in Node.

The schema

Write the schema for what your app stores. Descriptions steer the model, so say what each field means.

json
{
  "type": "object",
  "properties": {
    "merchant": { "type": "string", "description": "Store or merchant name." },
    "date": { "type": "string", "description": "Purchase date as YYYY-MM-DD." },
    "currency": { "type": "string", "description": "ISO 4217 code, for example EUR." },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "name": { "type": "string" },
          "quantity": { "type": "number" },
          "line_total": { "type": "number" }
        },
        "required": ["name", "line_total"]
      }
    },
    "total": { "type": "number", "description": "Grand total paid." }
  },
  "required": ["merchant", "total"]
}

A smaller schema means fewer output tokens, and output is the expensive side of the bill. Ask for the fields you use.

The call

Images go in as data URLs. Any raster image works (PNG, JPEG, WebP and others); SVG is rejected with job_image_mime_unsupported. The job comes back in pending and you fetch the result when it finishes.

ts
import { readFile } from "node:fs/promises"

const BASE = "https://veralens.ai/api/v1"
const headers = {
  Authorization: `Bearer ${process.env.VERALENS_API_KEY}`,
  "Content-Type": "application/json",
}

const schema = JSON.parse(await readFile("receipt.schema.json", "utf8"))
const photo = await readFile("receipt.jpg")

const create = await fetch(`${BASE}/jobs/create`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    sources: [
      { type: "image", data: `data:image/jpeg;base64,${photo.toString("base64")}` },
    ],
    schema,
    engine: "vera-1.0-low",
  }),
})
if (!create.ok) throw new Error(`create failed: ${(await create.json()).error_code}`)
const jobId = (await create.json()).data.id

for (let attempt = 0; attempt < 60; attempt++) {
  const res = await fetch(`${BASE}/jobs/${jobId}/get`, { headers })
  const job = (await res.json()).data
  if (job.status === "completed") {
    console.log(JSON.parse(job.output), "cost USD:", job.usage.cost)
    break
  }
  if (job.status === "failed") throw new Error(`job ${jobId} failed`)
  await new Promise((resolve) => setTimeout(resolve, 1000))
}

Things worth knowing:

  • output is a string. For a json target, JSON.parse it.
  • When you send a schema, target defaults to json. You can omit it.
  • Create returns 201. On failure you get { "status": "error", "error_code": ... }. Branch on the code. insufficient_balance means top up, job_image_mime_unsupported means convert the file.
  • For production use a webhook_url instead of polling; see Jobs & Queue.

Which engine

vera-1.0-high is the default, built for dense scans and strict schemas. vera-1.0-low is cheaper and fits simple documents at volume. Receipts are often simple, so try low first and compare on a sample of your own photos. Crumpled thermal paper and faded print is where high earns its price.

EngineInput / 1M tokensOutput / 1M tokens
Enginevera-1.0-highInput / 1M tokens$1.75Output / 1M tokens$4.00
Enginevera-1.0-lowInput / 1M tokens$0.50Output / 1M tokens$2.00

What a receipt costs

You pay per token, not per page or per receipt, so there is no page definition to argue about. The cost of a job is on the job itself in usage.cost, in USD. The receipt sample on /docs/pricing was measured at $0.0039 on vera-1.0-high ($3.93 per 1,000 receipts) and $0.0014 on vera-1.0-low ($1.45 per 1,000). Your photos and schema will differ, so run twenty of your own and read the number.

Output tokens are counted on the result as returned, indented JSON included.

Creating a job places a hold on your balance, settles to the real token cost on completion, and refunds in full if the job fails.

Make the numbers trustworthy

Totals are where receipts go wrong. Add a check in your own code: the sum of line_total against total, with tax in mind. For how calculated fields come back exact and how to pin money to two decimals, read extracted totals that reconcile to the cent.

Next step

Put it behind an upload form, or build the whole thing as an app with the receipt scanner and bill splitter tutorial. Create a key under API Keys in the dashboard and the example above runs as is.

Try it on your own documents

Add funds from $5, create a key, and send your first document. Every job reports exactly what it cost.

Add $5, get an API key

Keep reading