Receipts to CSV: extract, validate and export

Turn a folder of receipt photos into one CSV for expense reports. Typed JSON per receipt from an API, then a short script for the spreadsheet.

6 min read

A receipt to CSV pipeline has one job: take a pile of photos and produce rows your accountant or expense tool can import. The hard part is not writing the CSV. It is getting the same columns out of a crumpled thermal printout, a restaurant slip and a PDF e-receipt, so every row means the same thing.

This guide does it in two steps. An API call turns each receipt into typed JSON, then a short script flattens the JSON into one CSV.

Decide the columns first

CSV has no types. If one receipt gives 12.5 and another gives $12,50, the spreadsheet breaks on import. So fix the columns before you extract, and let the schema enforce them.

For an expense report, a good minimum is:

ColumnTypeNote
ColumndateTypestring, YYYY-MM-DDNoteAsk for ISO format so sorting works
ColumnmerchantTypestringNote
ColumncurrencyTypestring, ISO 4217NoteEUR, USD
ColumntotalTypenumberNoteAmount paid, tax included
ColumntaxTypenumberNoteOptional, null when not printed

Resist adding fields you will not use. Output is the expensive side of the per-token bill, and extra fields give the model more chances to get something wrong. See pricing.

Step 1: one job per receipt

Send each photo as an image source with the same schema. Images go in as base64 data URLs, and any raster format works (PNG, JPEG, WebP, HEIC and others). A PDF e-receipt uses a document source instead. Same schema, same output shape. Source: API reference.

Creating a job returns right away with the job in pending. Do not hold a connection open. Create all jobs first, then collect results.

Two limits matter for a batch. POST /jobs/create allows 20 requests per 10 seconds and 60 per minute per key, and GET /jobs/:jobId/get allows 10 per 10 seconds and 30 per minute. A loop over 200 receipts will hit 429 rate_limit_exceeded if it ignores that, so wait for Retry-After seconds when you see it. Better, register a webhook_url and skip polling entirely (Jobs and queue).

Step 2: validate before you export

A CSV is a one-way door. Once a wrong total is in a sheet nobody reopens the photo. So filter at the edge:

ts
type Receipt = { date: string | null; merchant: string; currency: string; total: number; tax: number | null }

function valid(r: Receipt): boolean {
  return (
    /^\d{4}-\d{2}-\d{2}$/.test(r.date ?? "") &&
    r.total > 0 &&
    (r.tax === null || r.tax <= r.total)
  )
}

Valid receipts go to the CSV. The rest go to a short "needs a look" list with the job id, so someone can open the original. This is the same habit as the invoice case in extracted totals that reconcile.

What goes wrong on real receipts

Three cases show up in every pile. Faded thermal paper may leave the date unreadable, so date must be allowed to come back null, which is why the validator above sends it to review instead of guessing. Receipts in several currencies need the currency column, because a total without one cannot be summed. And a duplicate photo of the same receipt produces two rows, so keep the merchant, date and total together as a key and drop repeats before you export.

Step 3: write the CSV

Quote every field and escape quotes. Merchant names contain commas.

ts
const cell = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`
const header = ["date", "merchant", "currency", "total", "tax"]
const rows = receipts.filter(valid).map((r) =>
  [r.date, r.merchant, r.currency, r.total, r.tax].map(cell).join(","),
)
await writeFile("expenses.csv", [header.join(","), ...rows].join("\n"))

If your accountant uses a locale with decimal commas, format on export, not in the schema. Keep numbers as numbers in the JSON.

Cost, and what to measure

Each job reports usage.cost once settled, so total the batch from your own responses. Do not assume a per-receipt price from a blog post, including this one. Run 20 of your real receipts, sum usage.cost, and multiply. A failed job refunds its hold in full, so a retry costs you nothing for the failed attempt. Top up (from $5) before the first job; there is no free balance.

For the single-receipt version with a longer schema, see Receipt OCR API: photo to typed JSON. For splitting a bill between people, see receipt scanner and bill splitter.

A working example

One receipt photo in, one typed object out. Replace the data URL with your own base64 image and the key with yours.

bash
curl -X POST https://veralens.ai/api/v1/jobs/create \
  -H "Authorization: Bearer vl_your_secret_key" \
  -H "Content-Type: application/json" \
  -d '{
    "sources": [{ "type": "image", "data": "data:image/jpeg;base64,/9j/4AAQ..." }],
    "schema": {
      "type": "object",
      "properties": {
        "date": { "type": "string", "format": "date", "description": "Purchase date as YYYY-MM-DD." },
        "merchant": { "type": "string" },
        "currency": { "type": "string", "format": "currency" },
        "total": { "type": "number", "description": "Amount paid, tax included." },
        "tax": { "type": "number" }
      },
      "required": ["merchant", "total", "currency"]
    }
  }'

Poll GET https://veralens.ai/api/v1/jobs/:jobId/get every 2 to 3 seconds until status is completed, then JSON.parse(job.output). Get a key in Getting started and check every error code in the API reference.

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