# Receipt Parser

URL: /docs/experiments/receipt-parser
> Parse receipt and invoice PDFs or photos into structured JSON/CSV with Workers AI vision - expense automation at the edge
Tags: ai, workers-ai, ocr, vision, receipts, invoices, structured-output
Bindings: AI
Markdown: /llms.mdx/experiments/receipt-parser/content.md



<Callout type="warning">
  This is an experimental Worker. Use it as a starting point for your own projects.
</Callout>

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](https://developers.cloudflare.com/workers-ai/features/markdown-conversion/) (`env.AI.toMarkdown`), then extracts fields with a [JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/) LLM and validates the result in code.

## Features [#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 [#api-reference]

### POST /parse [#post-parse]

Parse a receipt or invoice. Send the file as the raw body, or as multipart form field `file`.

<TypeTable
  type="{
  format: {
    description: &#x22;`json` (default) or `csv`.&#x22;,
    type: &#x22;string&#x22;,
    required: false,
  },
  name: {
    description: &#x22;File name to report for raw bodies. Defaults to `receipt.<ext>`.&#x22;,
    type: &#x22;string&#x22;,
    required: false,
  },
}"
/>

#### Example Request [#example-request]

```bash
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:

```bash
curl -X POST "https://your-worker.workers.dev/parse?format=csv" -F "file=@invoice.pdf"
```

#### Example Response [#example-response]

```json
{
  "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&#x60;. &#x2A;*`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:

```text
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-responses]

```json
{ "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 [#implementation-details]

<Steps>
  <Step>
    ### Read and sniff the upload [#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`).
  </Step>

  <Step>
    ### Convert to Markdown [#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`.
  </Step>

  <Step>
    ### Extract fields with JSON mode [#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.
  </Step>

  <Step>
    ### Normalize and check [#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.
  </Step>
</Steps>

## Use Cases [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="receipt-parser"
  bindings="[&#x22;Workers AI (AI binding)&#x22;]"
  platform="[&#x22;Workers AI Markdown Conversion&#x22;, &#x22;Workers AI JSON mode&#x22;]"
  files="[
  &#x22;src/lib/upload.ts&#x22;,
  &#x22;src/lib/convert.ts&#x22;,
  &#x22;src/lib/extract.ts&#x22;,
  &#x22;src/lib/normalize.ts&#x22;,
  &#x22;src/lib/checks.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/constants/schema.ts&#x22;,
  &#x22;src/types/receipt.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/upload.ts&#x22;,&#x22;title&#x22;:&#x22;upload.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { MAX_FILE_BYTES, MIME_EXTENSIONS } from \&#x22;../constants/defaults\&#x22;;\nimport type { SupportedMimeType, UploadResult } from \&#x22;../types/receipt\&#x22;;\n\nconst MULTIPART_OVERHEAD_BYTES = 64 * 1024;\n\nfunction startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {\n  return signature.every((byte, i) => bytes[offset + i] === byte);\n}\n\n/** Detects the file type from magic bytes so a wrong or missing Content-Type doesn't matter. */\nexport function detectMimeType(data: ArrayBuffer): SupportedMimeType | null {\n  const bytes = new Uint8Array(data, 0, Math.min(data.byteLength, 12));\n  if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46])) return \&#x22;application/pdf\&#x22;;\n  if (startsWith(bytes, [0xff, 0xd8, 0xff])) return \&#x22;image/jpeg\&#x22;;\n  if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return \&#x22;image/png\&#x22;;\n  if (\n    startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) &&\n    startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8)\n  ) {\n    return \&#x22;image/webp\&#x22;;\n  }\n  return null;\n}\n\nfunction sanitizeFileName(name: string | null | undefined): string | null {\n  const cleaned = (name ?? \&#x22;\&#x22;)\n    .replace(/[^\\w.\\- ]+/g, \&#x22;\&#x22;)\n    .trim()\n    .slice(0, 100);\n  return cleaned || null;\n}\n\n/** Reads a receipt from a raw request body or a multipart `file` field. */\nexport async function readUpload(request: Request, nameParam?: string): Promise<UploadResult> {\n  const tooLarge = {\n    ok: false,\n    code: \&#x22;PAYLOAD_TOO_LARGE\&#x22;,\n    message: `File exceeds the ${MAX_FILE_BYTES / (1024 * 1024)}MB limit`,\n    status: 413,\n  } as const;\n  const missing = {\n    ok: false,\n    code: \&#x22;MISSING_FILE\&#x22;,\n    message: \&#x22;Send the receipt as the raw request body or as multipart field 'file'\&#x22;,\n    status: 400,\n  } as const;\n\n  const declaredLength = Number(request.headers.get(\&#x22;content-length\&#x22;) ?? 0);\n  if (declaredLength > MAX_FILE_BYTES + MULTIPART_OVERHEAD_BYTES) return tooLarge;\n\n  const contentType = (request.headers.get(\&#x22;content-type\&#x22;) ?? \&#x22;\&#x22;).toLowerCase();\n  let data: ArrayBuffer;\n  let name: string | null;\n\n  if (contentType.startsWith(\&#x22;multipart/form-data\&#x22;)) {\n    let form: FormData;\n    try {\n      form = await request.formData();\n    } catch {\n      return missing;\n    }\n    // workers-types declares FormData.get() as string | null, but file fields are File at runtime.\n    const field: unknown = form.get(\&#x22;file\&#x22;);\n    if (!(field instanceof File)) return missing;\n    data = await field.arrayBuffer();\n    name = sanitizeFileName(field.name);\n  } else {\n    data = await request.arrayBuffer();\n    name = sanitizeFileName(nameParam);\n  }\n\n  if (data.byteLength === 0) return missing;\n  if (data.byteLength > MAX_FILE_BYTES) return tooLarge;\n\n  const mimeType = detectMimeType(data);\n  if (!mimeType) {\n    return {\n      ok: false,\n      code: \&#x22;UNSUPPORTED_MEDIA_TYPE\&#x22;,\n      message: \&#x22;Unsupported file type. Use PDF, JPEG, PNG, or WebP\&#x22;,\n      status: 415,\n    };\n  }\n\n  return {\n    ok: true,\n    file: { name: name ?? `receipt.${MIME_EXTENSIONS[mimeType]}`, mimeType, data },\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/convert.ts&#x22;,&#x22;title&#x22;:&#x22;convert.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { UploadedFile } from \&#x22;../types/receipt\&#x22;;\n\n/** Converts a PDF or image to Markdown with Workers AI Markdown Conversion. */\nexport async function convertToMarkdown(ai: Ai, file: UploadedFile): Promise<string> {\n  const result = await ai.toMarkdown(\n    { name: file.name, blob: new Blob([file.data], { type: file.mimeType }) },\n    { conversionOptions: { pdf: { metadata: false } } }\n  );\n  if (result.format === \&#x22;error\&#x22;) {\n    throw new Error(result.error || \&#x22;Markdown conversion failed\&#x22;);\n  }\n  const markdown = result.data.trim();\n  if (!markdown) {\n    throw new Error(\&#x22;No text could be extracted from the file\&#x22;);\n  }\n  return markdown;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/extract.ts&#x22;,&#x22;title&#x22;:&#x22;extract.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { MAX_MARKDOWN_CHARS, MAX_OUTPUT_TOKENS, TEXT_MODEL } from \&#x22;../constants/defaults\&#x22;;\nimport { EXTRACTION_SYSTEM_PROMPT, RECEIPT_JSON_SCHEMA } from \&#x22;../constants/schema\&#x22;;\n\n/** JSON mode may return `response` as an already-parsed object or as a JSON string. */\nexport function parseModelJson(output: unknown): unknown {\n  const response =\n    output && typeof output === \&#x22;object\&#x22; ? (output as { response?: unknown }).response : output;\n\n  if (response && typeof response === \&#x22;object\&#x22;) return response;\n  if (typeof response === \&#x22;string\&#x22;) {\n    const cleaned = response\n      .trim()\n      .replace(/^```(?:json)?\\s*/i, \&#x22;\&#x22;)\n      .replace(/```$/, \&#x22;\&#x22;)\n      .trim();\n    return JSON.parse(cleaned);\n  }\n  throw new Error(\&#x22;Model returned no structured output\&#x22;);\n}\n\n/**\n * Extracts receipt fields from Markdown using a JSON-mode text model. The model occasionally\n * degenerates and runs into max_tokens with truncated JSON, so an unparseable result is retried once.\n */\nexport async function extractReceiptFields(ai: Ai, markdown: string): Promise<unknown> {\n  const run = () =>\n    ai.run(TEXT_MODEL, {\n      messages: [\n        { role: \&#x22;system\&#x22;, content: EXTRACTION_SYSTEM_PROMPT },\n        { role: \&#x22;user\&#x22;, content: markdown.slice(0, MAX_MARKDOWN_CHARS) },\n      ],\n      response_format: { type: \&#x22;json_schema\&#x22;, json_schema: RECEIPT_JSON_SCHEMA },\n      temperature: 0,\n      max_tokens: MAX_OUTPUT_TOKENS,\n    });\n\n  try {\n    return parseModelJson(await run());\n  } catch (err) {\n    if (!(err instanceof SyntaxError)) throw err;\n    return parseModelJson(await run());\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/normalize.ts&#x22;,&#x22;title&#x22;:&#x22;normalize.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  CURRENCY_SYMBOLS,\n  MAX_LINE_ITEMS,\n  MAX_TEXT_FIELD_LENGTH,\n  RECEIPT_CATEGORIES,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { LineItem, Receipt, ReceiptCategory } from \&#x22;../types/receipt\&#x22;;\n\nexport function roundTo(value: number, decimals: number): number {\n  const factor = 10 ** decimals;\n  return Math.round(value * factor) / factor;\n}\n\n/** Coerces numbers and money strings like \&#x22;$1,234.56\&#x22;, \&#x22;1.234,56 €\&#x22;, or \&#x22;(3.00)\&#x22; to numbers. */\nexport function toNumber(value: unknown, decimals = 2): number | null {\n  if (typeof value === \&#x22;number\&#x22;) return Number.isFinite(value) ? roundTo(value, decimals) : null;\n  if (typeof value !== \&#x22;string\&#x22;) return null;\n\n  const trimmed = value.trim();\n  const negative = /^\\(.*\\)$/.test(trimmed) || /^[^\\d]*-/.test(trimmed) || /-$/.test(trimmed);\n  let digits = trimmed.replace(/[^0-9.,]/g, \&#x22;\&#x22;);\n  if (!/\\d/.test(digits)) return null;\n\n  if (/^\\d{1,3}(\\.\\d{3})*,\\d{1,2}$/.test(digits) || /^\\d+,\\d{1,2}$/.test(digits)) {\n    digits = digits.replace(/\\./g, \&#x22;\&#x22;).replace(\&#x22;,\&#x22;, \&#x22;.\&#x22;);\n  } else {\n    digits = digits.replace(/,/g, \&#x22;\&#x22;);\n  }\n\n  const parsed = Number(digits);\n  if (!Number.isFinite(parsed)) return null;\n  return roundTo(negative ? -parsed : parsed, decimals);\n}\n\nfunction isValidYmd(year: number, month: number, day: number): boolean {\n  const date = new Date(Date.UTC(year, month - 1, day));\n  return (\n    date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n  );\n}\n\n/** Returns an ISO date (YYYY-MM-DD) or null. */\nexport function toIsoDate(value: unknown): string | null {\n  if (typeof value !== \&#x22;string\&#x22;) return null;\n  const trimmed = value.trim();\n  const iso = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(trimmed);\n  if (iso) {\n    const [, y, m, d] = iso;\n    return isValidYmd(Number(y), Number(m), Number(d)) ? `${y}-${m}-${d}` : null;\n  }\n  const parsed = Date.parse(trimmed);\n  if (!Number.isFinite(parsed)) return null;\n  return new Date(parsed).toISOString().slice(0, 10);\n}\n\nexport function toText(value: unknown): string | null {\n  if (typeof value !== \&#x22;string\&#x22;) return null;\n  const trimmed = value.replace(/\\s+/g, \&#x22; \&#x22;).trim();\n  return trimmed ? trimmed.slice(0, MAX_TEXT_FIELD_LENGTH) : null;\n}\n\nexport function toCurrency(value: unknown): string | null {\n  if (typeof value !== \&#x22;string\&#x22;) return null;\n  const trimmed = value.trim();\n  if (CURRENCY_SYMBOLS[trimmed]) return CURRENCY_SYMBOLS[trimmed];\n  const code = trimmed.toUpperCase();\n  return /^[A-Z]{3}$/.test(code) ? code : null;\n}\n\nexport function toCategory(value: unknown): ReceiptCategory {\n  const normalized = typeof value === \&#x22;string\&#x22; ? value.trim().toLowerCase() : \&#x22;\&#x22;;\n  return (RECEIPT_CATEGORIES as readonly string[]).includes(normalized)\n    ? (normalized as ReceiptCategory)\n    : \&#x22;other\&#x22;;\n}\n\nfunction toLineItem(value: unknown): LineItem | null {\n  if (!value || typeof value !== \&#x22;object\&#x22;) return null;\n  const item = value as Record<string, unknown>;\n  const description = toText(item.description);\n  if (!description) return null;\n\n  const quantity = toNumber(item.quantity, 3);\n  const unitPrice = toNumber(item.unitPrice);\n  let amount = toNumber(item.amount);\n  if (amount === null && quantity !== null && unitPrice !== null) {\n    amount = roundTo(quantity * unitPrice, 2);\n  }\n  return { description, quantity, unitPrice, amount };\n}\n\n/** Normalizes untrusted model output into a Receipt; anything unusable becomes null. */\nexport function normalizeReceipt(raw: unknown): Receipt {\n  const input = raw && typeof raw === \&#x22;object\&#x22; ? (raw as Record<string, unknown>) : {};\n  const lineItems = Array.isArray(input.lineItems)\n    ? input.lineItems\n        .map(toLineItem)\n        .filter((item): item is LineItem => item !== null)\n        .slice(0, MAX_LINE_ITEMS)\n    : [];\n\n  return {\n    vendor: toText(input.vendor),\n    date: toIsoDate(input.date),\n    currency: toCurrency(input.currency),\n    subtotal: toNumber(input.subtotal),\n    tax: toNumber(input.tax),\n    tip: toNumber(input.tip),\n    total: toNumber(input.total),\n    lineItems,\n    paymentMethod: toText(input.paymentMethod),\n    category: toCategory(input.category),\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/checks.ts&#x22;,&#x22;title&#x22;:&#x22;checks.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { SUM_TOLERANCE } from \&#x22;../constants/defaults\&#x22;;\nimport type { Receipt } from \&#x22;../types/receipt\&#x22;;\nimport { roundTo } from \&#x22;./normalize\&#x22;;\n\nfunction differs(a: number, b: number): boolean {\n  return Math.abs(a - b) > SUM_TOLERANCE;\n}\n\nfunction money(value: number): string {\n  return value.toFixed(2);\n}\n\n/** Sanity-checks a normalized receipt and returns human-readable warnings. */\nexport function checkReceipt(receipt: Receipt): string[] {\n  const warnings: string[] = [];\n  const { lineItems, subtotal, tax, tip, total } = receipt;\n\n  if (!receipt.vendor) warnings.push(\&#x22;Vendor not found\&#x22;);\n  if (!receipt.date) warnings.push(\&#x22;Date not found or invalid\&#x22;);\n  if (total === null) warnings.push(\&#x22;Total not found\&#x22;);\n\n  const amounts = lineItems.map((item) => item.amount);\n  if (amounts.some((amount) => amount === null)) {\n    warnings.push(\&#x22;Some line items have no amount\&#x22;);\n  }\n\n  const known = amounts.filter((amount): amount is number => amount !== null);\n  if (known.length > 0 && known.length === amounts.length) {\n    const itemsSum = roundTo(\n      known.reduce((sum, amount) => sum + amount, 0),\n      2\n    );\n    if (subtotal !== null && differs(itemsSum, subtotal)) {\n      warnings.push(`Line items don't sum to subtotal (${money(itemsSum)} vs ${money(subtotal)})`);\n    } else if (subtotal === null && total !== null) {\n      const expected = roundTo(itemsSum + (tax ?? 0) + (tip ?? 0), 2);\n      if (differs(expected, total)) {\n        warnings.push(\n          `Line items plus tax and tip don't sum to total (${money(expected)} vs ${money(total)})`\n        );\n      }\n    }\n  }\n\n  if (subtotal !== null && total !== null) {\n    const expected = roundTo(subtotal + (tax ?? 0) + (tip ?? 0), 2);\n    if (differs(expected, total)) {\n      warnings.push(\n        `Subtotal plus tax and tip doesn't match total (${money(expected)} vs ${money(total)})`\n      );\n    }\n  }\n\n  return warnings;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const TEXT_MODEL = \&#x22;@cf/meta/llama-3.3-70b-instruct-fp8-fast\&#x22;;\nexport const MAX_FILE_BYTES = 10 * 1024 * 1024;\nexport const MAX_MARKDOWN_CHARS = 12_000;\nexport const MAX_OUTPUT_TOKENS = 1_024;\nexport const MARKDOWN_PREVIEW_CHARS = 500;\nexport const MAX_TEXT_FIELD_LENGTH = 200;\nexport const MAX_LINE_ITEMS = 100;\nexport const SUM_TOLERANCE = 0.02;\n\nexport const SUPPORTED_MIME_TYPES = [\n  \&#x22;application/pdf\&#x22;,\n  \&#x22;image/jpeg\&#x22;,\n  \&#x22;image/png\&#x22;,\n  \&#x22;image/webp\&#x22;,\n] as const;\n\nexport const MIME_EXTENSIONS: Record<(typeof SUPPORTED_MIME_TYPES)[number], string> = {\n  \&#x22;application/pdf\&#x22;: \&#x22;pdf\&#x22;,\n  \&#x22;image/jpeg\&#x22;: \&#x22;jpg\&#x22;,\n  \&#x22;image/png\&#x22;: \&#x22;png\&#x22;,\n  \&#x22;image/webp\&#x22;: \&#x22;webp\&#x22;,\n};\n\nexport const RECEIPT_CATEGORIES = [\n  \&#x22;food\&#x22;,\n  \&#x22;travel\&#x22;,\n  \&#x22;lodging\&#x22;,\n  \&#x22;office\&#x22;,\n  \&#x22;software\&#x22;,\n  \&#x22;other\&#x22;,\n] as const;\n\nexport const CURRENCY_SYMBOLS: Record<string, string> = {\n  $: \&#x22;USD\&#x22;,\n  \&#x22;€\&#x22;: \&#x22;EUR\&#x22;,\n  \&#x22;£\&#x22;: \&#x22;GBP\&#x22;,\n  \&#x22;¥\&#x22;: \&#x22;JPY\&#x22;,\n  \&#x22;₹\&#x22;: \&#x22;INR\&#x22;,\n};&#x22;},{&#x22;path&#x22;:&#x22;src/constants/schema.ts&#x22;,&#x22;title&#x22;:&#x22;schema.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { RECEIPT_CATEGORIES } from \&#x22;./defaults\&#x22;;\n\nconst nullableNumber = { type: [\&#x22;number\&#x22;, \&#x22;null\&#x22;] };\nconst nullableString = { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;] };\n\nexport const RECEIPT_JSON_SCHEMA = {\n  type: \&#x22;object\&#x22;,\n  properties: {\n    vendor: nullableString,\n    date: { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;], description: \&#x22;Purchase date as YYYY-MM-DD\&#x22; },\n    currency: { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;], description: \&#x22;ISO 4217 code, e.g. USD\&#x22; },\n    subtotal: nullableNumber,\n    tax: nullableNumber,\n    tip: nullableNumber,\n    total: nullableNumber,\n    lineItems: {\n      type: \&#x22;array\&#x22;,\n      items: {\n        type: \&#x22;object\&#x22;,\n        properties: {\n          description: { type: \&#x22;string\&#x22; },\n          quantity: nullableNumber,\n          unitPrice: nullableNumber,\n          amount: nullableNumber,\n        },\n        required: [\&#x22;description\&#x22;, \&#x22;quantity\&#x22;, \&#x22;unitPrice\&#x22;, \&#x22;amount\&#x22;],\n      },\n    },\n    paymentMethod: { type: [\&#x22;string\&#x22;, \&#x22;null\&#x22;], description: \&#x22;e.g. Visa ****1234, cash\&#x22; },\n    category: { type: \&#x22;string\&#x22;, enum: [...RECEIPT_CATEGORIES] },\n  },\n  required: [\n    \&#x22;vendor\&#x22;,\n    \&#x22;date\&#x22;,\n    \&#x22;currency\&#x22;,\n    \&#x22;subtotal\&#x22;,\n    \&#x22;tax\&#x22;,\n    \&#x22;tip\&#x22;,\n    \&#x22;total\&#x22;,\n    \&#x22;lineItems\&#x22;,\n    \&#x22;paymentMethod\&#x22;,\n    \&#x22;category\&#x22;,\n  ],\n} as const;\n\nexport const EXTRACTION_SYSTEM_PROMPT = [\n  \&#x22;You extract structured data from receipts and invoices.\&#x22;,\n  \&#x22;The user message is the receipt converted to Markdown (images were described by a vision model).\&#x22;,\n  \&#x22;Return only values present in the receipt; use null when a field is missing. Never invent values.\&#x22;,\n  \&#x22;Amounts are plain numbers without currency symbols.\&#x22;,\n  \&#x22;Dates are YYYY-MM-DD; copy the year exactly as printed and never guess a different year.\&#x22;,\n  \&#x22;Each printed item line is one lineItems entry; do not repeat entries.\&#x22;,\n  \&#x22;category is one of: food, travel, lodging, office, software, other.\&#x22;,\n].join(\&#x22; \&#x22;);&#x22;},{&#x22;path&#x22;:&#x22;src/types/receipt.ts&#x22;,&#x22;title&#x22;:&#x22;receipt.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { RECEIPT_CATEGORIES, SUPPORTED_MIME_TYPES } from \&#x22;../constants/defaults\&#x22;;\n\nexport type SupportedMimeType = (typeof SUPPORTED_MIME_TYPES)[number];\nexport type ReceiptCategory = (typeof RECEIPT_CATEGORIES)[number];\n\nexport interface LineItem {\n  description: string;\n  quantity: number | null;\n  unitPrice: number | null;\n  amount: number | null;\n}\n\nexport interface Receipt {\n  vendor: string | null;\n  /** ISO date (YYYY-MM-DD) */\n  date: string | null;\n  /** ISO 4217 code */\n  currency: string | null;\n  subtotal: number | null;\n  tax: number | null;\n  tip: number | null;\n  total: number | null;\n  lineItems: LineItem[];\n  paymentMethod: string | null;\n  category: ReceiptCategory;\n}\n\nexport interface UploadedFile {\n  name: string;\n  mimeType: SupportedMimeType;\n  data: ArrayBuffer;\n}\n\nexport type UploadErrorCode = \&#x22;MISSING_FILE\&#x22; | \&#x22;PAYLOAD_TOO_LARGE\&#x22; | \&#x22;UNSUPPORTED_MEDIA_TYPE\&#x22;;\n\nexport type UploadResult =\n  | { ok: true; file: UploadedFile }\n  | { ok: false; code: UploadErrorCode; message: string; status: 400 | 413 | 415 };\n\nexport interface ParseResponse {\n  fileName: string;\n  mimeType: SupportedMimeType;\n  markdownPreview: string;\n  receipt: Receipt;\n  warnings: string[];\n}&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: POST /parse with a PDF or image body\n */\nimport { readUpload } from \&#x22;../src/lib/upload\&#x22;;\nimport { convertToMarkdown } from \&#x22;../src/lib/convert\&#x22;;\nimport { extractReceiptFields } from \&#x22;../src/lib/extract\&#x22;;\nimport { normalizeReceipt } from \&#x22;../src/lib/normalize\&#x22;;\nimport { checkReceipt } from \&#x22;../src/lib/checks\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { AI: Ai }): Promise<Response> {\n    const upload = await readUpload(request);\n    if (!upload.ok) {\n      return Response.json({ error: upload.message, code: upload.code }, { status: upload.status });\n    }\n\n    const markdown = await convertToMarkdown(env.AI, upload.file);\n    const receipt = normalizeReceipt(await extractReceiptFields(env.AI, markdown));\n    return Response.json({ receipt, warnings: checkReceipt(receipt) });\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/receipt-parser)
  </Step>

  <Step>
    ### Deploy [#deploy]

    The `AI` binding is configured in `wrangler.json`. Requires a Cloudflare account with Workers AI enabled.
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl -X POST "https://your-worker.workers.dev/parse" -F "file=@receipt.jpg"
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/receipt-parser
npm install
npm run dev
```

```bash
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 [#configuration]

`wrangler.json` declares a single Workers AI binding:

```json
{
  "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 [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime
* **[Markdown Conversion](https://developers.cloudflare.com/workers-ai/features/markdown-conversion/)** - `env.AI.toMarkdown` for PDFs and images
* **[JSON Mode](https://developers.cloudflare.com/workers-ai/features/json-mode/)** - Schema-constrained output from `@cf/meta/llama-3.3-70b-instruct-fp8-fast`

## Next Steps [#next-steps]

<Cards>
  <Card title="Markdown Conversion formats" href="https://developers.cloudflare.com/workers-ai/features/markdown-conversion/supported-formats/">
    All file types `toMarkdown` supports
  </Card>

  <Card title="Speech to Text Transcriber" href="/experiments/speech-to-text-transcriber">
    Another file-upload Workers AI experiment
  </Card>

  <Card title="GitHub Repository" href="https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/receipt-parser">
    View the complete source code
  </Card>
</Cards>
