Receipt Parser
Parse receipt and invoice PDFs or photos into structured JSON/CSV with Workers AI vision - expense automation at the edge
This is an experimental Worker. Use it as a starting point for your own projects.
Upload a receipt or invoice and get back vendor, date, totals, and line items. The Worker converts the file to Markdown with Workers AI Markdown Conversion (env.AI.toMarkdown), then extracts fields with a JSON mode LLM and validates the result in code.
Features
- Accepts PDF, JPEG, PNG, and WebP (detected from file bytes, not the
Content-Typeheader) - Raw request body or multipart
filefield, up to 10MB - Two-step pipeline:
toMarkdown(vision model describes images) then@cf/meta/llama-3.3-70b-instruct-fp8-fastwith a JSON schema - Normalizes model output: money strings coerced to numbers, invalid dates and unknown fields become
null - Sanity-check
warningssuch as "line items don't sum to subtotal" ?format=csvreturns a spreadsheet-ready CSV row
API Reference
POST /parse
Parse a receipt or invoice. Send the file as the raw body, or as multipart form field file.
Prop
Type
Example Request
curl -X POST "https://your-worker.workers.dev/parse?name=lunch.jpg" \
-H "Content-Type: image/jpeg" \
--data-binary @lunch.jpgMultipart upload with CSV output:
curl -X POST "https://your-worker.workers.dev/parse?format=csv" -F "file=@invoice.pdf"Example Response
{
"fileName": "lunch.jpg",
"mimeType": "image/jpeg",
"markdownPreview": "# lunch.jpg\n\n## Description\nA receipt from Corner Cafe...",
"receipt": {
"vendor": "Corner Cafe",
"date": "2026-09-01",
"currency": "USD",
"subtotal": 7,
"tax": 0.56,
"tip": 1.5,
"total": 9.06,
"lineItems": [
{ "description": "Bagel", "quantity": 2, "unitPrice": 2.5, "amount": 5 },
{ "description": "Tea", "quantity": 1, "unitPrice": 2, "amount": 2 }
],
"paymentMethod": "Visa ****4242",
"category": "food"
},
"warnings": []
}receipt.category is one of food, travel, lodging, office, software, other. Any field the model could not find is null. markdownPreview is the first 500 characters of the converted Markdown.
With ?format=csv, the response is text/csv with a header line and one data row:
fileName,vendor,date,currency,subtotal,tax,tip,total,paymentMethod,category,lineItemCount,warnings
lunch.jpg,Corner Cafe,2026-09-01,USD,7,0.56,1.5,9.06,Visa ****4242,food,2,Error Responses
{ "error": "Unsupported file type. Use PDF, JPEG, PNG, or WebP", "code": "UNSUPPORTED_MEDIA_TYPE" }400-MISSING_FILE(empty body or nofilefield),INVALID_QUERY(badformat)413-PAYLOAD_TOO_LARGE(over 10MB)415-UNSUPPORTED_MEDIA_TYPE502-CONVERSION_ERROR(Markdown conversion failed or returned no text),AI_ERROR(extraction model failed, includingJSON Mode couldn't be met)
Implementation Details
Read and sniff the upload
src/lib/upload.ts reads the raw body or multipart file field, enforces the size limit, and detects the type from magic bytes (%PDF, JPEG FF D8 FF, PNG, RIFF....WEBP).
Convert to Markdown
src/lib/convert.ts calls env.AI.toMarkdown({ name, blob }). PDFs are converted to text; images are described by Workers AI vision models. A format: "error" result becomes CONVERSION_ERROR.
Extract fields with JSON mode
src/lib/extract.ts sends the Markdown (capped at 12,000 characters) to @cf/meta/llama-3.3-70b-instruct-fp8-fast with response_format: { type: "json_schema", json_schema }. The schema lives in src/constants/schema.ts. If the model returns truncated JSON (it occasionally repeats itself until it hits max_tokens), the call is retried once.
Normalize and check
src/lib/normalize.ts coerces values like "$1,234.50" or "1.234,56 €" to numbers, validates dates and ISO 4217 currency codes, and derives missing line amounts from quantity × unit price. src/lib/checks.ts compares sums with a 0.02 tolerance and returns warnings.
Use Cases
- Expense reports - Drop receipt photos in and get rows for a spreadsheet
- Bookkeeping inboxes - Pair with an Email Worker to parse forwarded invoices
- Reimbursement apps - Pre-fill amount, date, and category from a photo
- Data entry QA - Use
warningsto flag receipts that need human review
Limitations
- Model accuracy: Extraction quality depends on the scan; always review
warningsand low-quality photos - Image conversion cost:
toMarkdownuses Workers AI models for images, which counts against Workers AI usage - Single receipt per file: Multi-receipt PDFs are treated as one document
- Text cap: Only the first 12,000 characters of Markdown are sent to the model
- Currency symbols:
$is assumed to be USD when the model returns a bare symbol
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/receipt-parser.
import { MAX_FILE_BYTES, MIME_EXTENSIONS } from "../constants/defaults";import type { SupportedMimeType, UploadResult } from "../types/receipt";const MULTIPART_OVERHEAD_BYTES = 64 * 1024;function startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean { return signature.every((byte, i) => bytes[offset + i] === byte);}/** Detects the file type from magic bytes so a wrong or missing Content-Type doesn't matter. */export function detectMimeType(data: ArrayBuffer): SupportedMimeType | null { const bytes = new Uint8Array(data, 0, Math.min(data.byteLength, 12)); if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46])) return "application/pdf"; if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; if ( startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8) ) { return "image/webp"; } return null;}function sanitizeFileName(name: string | null | undefined): string | null { const cleaned = (name ?? "") .replace(/[^\w.\- ]+/g, "") .trim() .slice(0, 100); return cleaned || null;}/** Reads a receipt from a raw request body or a multipart `file` field. */export async function readUpload(request: Request, nameParam?: string): Promise<UploadResult> { const tooLarge = { ok: false, code: "PAYLOAD_TOO_LARGE", message: `File exceeds the ${MAX_FILE_BYTES / (1024 * 1024)}MB limit`, status: 413, } as const; const missing = { ok: false, code: "MISSING_FILE", message: "Send the receipt as the raw request body or as multipart field 'file'", status: 400, } as const; const declaredLength = Number(request.headers.get("content-length") ?? 0); if (declaredLength > MAX_FILE_BYTES + MULTIPART_OVERHEAD_BYTES) return tooLarge; const contentType = (request.headers.get("content-type") ?? "").toLowerCase(); let data: ArrayBuffer; let name: string | null; if (contentType.startsWith("multipart/form-data")) { let form: FormData; try { form = await request.formData(); } catch { return missing; } // workers-types declares FormData.get() as string | null, but file fields are File at runtime. const field: unknown = form.get("file"); if (!(field instanceof File)) return missing; data = await field.arrayBuffer(); name = sanitizeFileName(field.name); } else { data = await request.arrayBuffer(); name = sanitizeFileName(nameParam); } if (data.byteLength === 0) return missing; if (data.byteLength > MAX_FILE_BYTES) return tooLarge; const mimeType = detectMimeType(data); if (!mimeType) { return { ok: false, code: "UNSUPPORTED_MEDIA_TYPE", message: "Unsupported file type. Use PDF, JPEG, PNG, or WebP", status: 415, }; } return { ok: true, file: { name: name ?? `receipt.${MIME_EXTENSIONS[mimeType]}`, mimeType, data }, };}Deployment
Deploy
The AI binding is configured in wrangler.json. Requires a Cloudflare account with Workers AI enabled.
Test your deployment
curl -X POST "https://your-worker.workers.dev/parse" -F "file=@receipt.jpg"Local Development
cd apps/experiments/receipt-parser
npm install
npm run devcurl -X POST "http://localhost:8787/parse" -F "file=@receipt.jpg"The AI binding always runs remotely, so local development needs a logged-in Wrangler session.
Configuration
wrangler.json declares a single Workers AI binding:
{
"ai": { "binding": "AI" }
}Models and limits are in src/constants/defaults.ts (TEXT_MODEL, MAX_FILE_BYTES, MAX_MARKDOWN_CHARS, SUM_TOLERANCE).
Cloudflare Features Used
- Workers - Edge compute runtime
- Markdown Conversion -
env.AI.toMarkdownfor PDFs and images - JSON Mode - Schema-constrained output from
@cf/meta/llama-3.3-70b-instruct-fp8-fast