This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
AI & Machine Learning

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-Type header)
  • Raw request body or multipart file field, up to 10MB
  • Two-step pipeline: toMarkdown (vision model describes images) then @cf/meta/llama-3.3-70b-instruct-fp8-fast with a JSON schema
  • Normalizes model output: money strings coerced to numbers, invalid dates and unknown fields become null
  • Sanity-check warnings such as "line items don't sum to subtotal"
  • ?format=csv returns 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.jpg

Multipart 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 no file field), INVALID_QUERY (bad format)
  • 413 - PAYLOAD_TOO_LARGE (over 10MB)
  • 415 - UNSUPPORTED_MEDIA_TYPE
  • 502 - CONVERSION_ERROR (Markdown conversion failed or returned no text), AI_ERROR (extraction model failed, including JSON 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 warnings to flag receipts that need human review

Limitations

  • Model accuracy: Extraction quality depends on the scan; always review warnings and low-quality photos
  • Image conversion cost: toMarkdown uses 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.

DependenciesNoneBindingsWorkers AI (AI binding)PlatformWorkers AI Markdown Conversion, Workers AI JSON mode
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

Click the deploy button

Deploy to Cloudflare Workers

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 dev
curl -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.toMarkdown for PDFs and images
  • JSON Mode - Schema-constrained output from @cf/meta/llama-3.3-70b-instruct-fp8-fast

Next Steps

On this page