Skip to content
AI Full-Stack Wiki

Tutorial: Build an Invoice Extractor with Structured Outputs and Zod

Turn messy invoice emails into typed data with messages.parse and Zod, check the arithmetic with business rules, retry once with feedback, and route failures to review.

Difficulty: Intermediate20 min readUpdated
On this page· What you will build

Why this matters

Extraction is one of the most valuable things models do in ordinary business software: emails, PDFs, and forms in, rows in a database out. Structured outputs with Zod shows how to get schema-valid JSON every time. This tutorial adds what a finance team actually needs on top: checks that the numbers add up, a retry when the model copied something wrong, and a clear path for the invoices that are wrong in the email itself, which no amount of retrying should "fix". The result is a pipeline you could point at an accounts-payable inbox.

What you will build#

A command that reads a folder of emails and, for each one, produces exactly one outcome:

  • Invoice: every rule passes; it becomes a row in invoices.csv.
  • Not an invoice: newsletters and the like are skipped.
  • Needs review: the numbers still do not add up after one retry; it goes to review.json with the problems listed for a person.
Invoice extractorEach email is parsed into a typed extraction with a Zod schema. Business rules check the numbers; on a failure the model gets one retry with the problems listed. Valid invoices go to a CSV file, non-invoices are skipped, and anything still failing goes to a review queue.typedEmailsplain textmessages.parseZod schemaBusiness rulesin centsinvoices.csvReview queuereview.json

Hover or tap a component to see what it does.

Invoice extractor: emails become typed data with messages.parse and Zod, business rules check the numbers with one retry, and results go to a CSV file or a review queue.
Show text description

The schema guarantees the shape; the rules check the meaning; a person sees whatever is still wrong.

  • Emails: Invoices, a newsletter, and one invoice whose own numbers do not add up.
  • messages.parse: Structured output: the response is valid JSON for the schema, parsed into a typed object.
  • Business rules: Line amounts, subtotal, total, required fields, and dates; checks a schema cannot express.
  • invoices.csv: Only invoices that passed every rule.
  • Review queue: Invoices that still fail after one retry, with the problems listed for a person.

You need Node.js 22.18 or later and an Anthropic API key. The four sample emails cost a few cents to process.

1. Set up the project#

mkdir invoice-extractor && cd invoice-extractor
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node
mkdir src test emails
npm pkg set scripts.extract="node --env-file=.env src/main.ts" scripts.test="node --test" scripts.typecheck="tsc"

Copy the tsconfig.json from Foundations Tutorial 1 and add your key to .env.

2. Sample emails#

Four emails, each testing something different. Two are clean invoices in different currencies:

emails/01-printworks.txt
From: Printworks GmbH
Subject: Your invoice INV-2026-0412
 
Hello,
 
please find your invoice below.
 
Invoice: INV-2026-0412
Date: 2026-10-01
Due: 2026-10-31
 
- 2 × Business cards (500) @ 45.00
- 1 × Flyer design @ 120.00
 
Subtotal: 210.00 EUR
Tax: 39.90
Total: 249.90 EUR
 
Kind regards,
Printworks accounts
emails/03-office-supplies.txt
From: Office Supplies Ltd
Subject: Invoice INV-OS-1207
 
Dear customer,
 
Invoice INV-OS-1207
Date: 2026-10-05
Due: 2026-11-04
 
- 10 × A4 paper ream @ 4.20
- 4 × Toner cartridge @ 28.50
 
Tax: 28.80
Total: 184.80 GBP
 
Thank you for your order.

One has a total that does not match its own lines (3 × 40 + 55 is 175, not 195), the kind of mistake that is cheap to catch and expensive to pay:

emails/02-cloudhost.txt
From: CloudHost Inc.
Subject: INV-CH-88231 for October
 
Hi there, thanks for hosting with us. Invoice INV-CH-88231 is attached and summarized here.
 
Date: 2026-10-02
Due: 2026-10-16
 
- 3 × Virtual server (monthly) @ 40.00
- 1 × Managed database @ 55.00
 
Tax: 0.00
Total: 195.00 USD
 
Questions? Reply to this email.

And one is not an invoice at all:

emails/04-newsletter.txt
From: Printworks GmbH
Subject: Autumn offers are here
 
Hello,
 
our autumn catalogue is out: 20% off all flyers until the end of the month. No action needed.
 
Printworks team

3. The schema#

src/schema.ts
import { z } from "zod";
 
export const LineItem = z.object({
  description: z.string(),
  quantity: z.number(),
  unitPrice: z.number().describe("Price of one unit, as written in the email"),
  amount: z.number().describe("quantity × unitPrice"),
});
 
// Every field is nullable: an email that is not an invoice, or that omits a field, has no value for it.
// The model must not invent values to fill the shape.
export const Extraction = z.object({
  isInvoice: z.boolean().describe("false for newsletters, receipts of payment, and anything that does not ask for payment"),
  vendor: z.string().nullable(),
  invoiceNumber: z.string().nullable(),
  issueDate: z.iso.date().nullable(),
  dueDate: z.iso.date().nullable(),
  currency: z.enum(["EUR", "USD", "GBP"]).nullable(),
  lineItems: z.array(LineItem),
  subtotal: z.number().nullable().describe("Sum of line amounts before tax; null if the email does not state it and it cannot be computed"),
  tax: z.number().nullable(),
  total: z.number().nullable().describe("The total exactly as written in the email, even if it looks wrong"),
});
 
export type Extraction = z.infer<typeof Extraction>;

Three schema decisions do most of the work:

  • isInvoice comes first. Classification and extraction in one call, so a newsletter produces false instead of an invoice full of guesses.
  • .nullable() instead of optional or required. Structured outputs require every property; null is how the model says "the email does not state this" without inventing a value.
  • .describe() carries instructions to the field ("exactly as written, even if it looks wrong"). zodOutputFormat turns these into the JSON Schema the model sees.

z.iso.date() becomes a JSON Schema string with format: "date", which structured outputs support. Constraints they do not support, such as numeric minimums, are removed from the schema sent to the API by the SDK and checked client-side instead. Keep the schema simple and put business logic in code.

4. Business rules#

src/rules.ts
import type { Extraction } from "./schema.ts";
 
// Compare money in integer cents, never as floating-point dollars.
const cents = (n: number) => Math.round(n * 100);
const money = (n: number) => (n / 100).toFixed(2);
 
/** Business rules a JSON schema cannot express. Returns human-readable problems; empty means valid. */
export function checkInvoice(x: Extraction): string[] {
  if (!x.isInvoice) return [];
  const problems: string[] = [];
  for (const field of ["vendor", "invoiceNumber", "issueDate", "currency", "total"] as const) {
    if (x[field] === null) problems.push(`${field}: missing`);
  }
  if (x.lineItems.length === 0) problems.push("lineItems: an invoice needs at least one line");
 
  x.lineItems.forEach((item, i) => {
    const expected = cents(item.quantity * item.unitPrice);
    if (cents(item.amount) !== expected) problems.push(`lineItems.${i}: ${item.quantity} × ${item.unitPrice} is ${money(expected)}, not ${item.amount}`);
  });
 
  const sum = x.lineItems.reduce((s, item) => s + cents(item.amount), 0);
  if (x.subtotal !== null && cents(x.subtotal) !== sum) problems.push(`subtotal: lines add up to ${money(sum)}, not ${x.subtotal}`);
  if (x.total !== null) {
    const expectedTotal = (x.subtotal !== null ? cents(x.subtotal) : sum) + cents(x.tax ?? 0);
    if (cents(x.total) !== expectedTotal) problems.push(`total: subtotal + tax is ${money(expectedTotal)}, not ${x.total}`);
  }
  if (x.issueDate && x.dueDate && x.dueDate < x.issueDate) problems.push(`dueDate: ${x.dueDate} is before issueDate ${x.issueDate}`);
  return problems;
}
 
/** One CSV field, quoted when it contains a separator, a quote, or a line break. */
export function csvField(value: string | number | null): string {
  const s = value === null ? "" : String(value);
  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

Every comparison happens in integer cents. In floating point, 0.1 + 0.2 is 0.30000000000000004, and a rule written in dollars would flag correct invoices. Dates in YYYY-MM-DD form compare correctly as strings, which is why the schema asks for ISO dates.

5. Extract, check, retry once#

src/extract.ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { checkInvoice } from "./rules.ts";
import { Extraction } from "./schema.ts";
 
const client = new Anthropic();
 
const SYSTEM =
  "You extract invoice data from emails for an accounts-payable team. " +
  "Copy every value exactly as written in the email; never correct, round, or invent numbers. " +
  "Use null for anything the email does not state.";
 
export type Result =
  | { status: "invoice"; invoice: Extraction; attempts: number }
  | { status: "not_invoice"; attempts: number }
  | { status: "review"; invoice: Extraction | null; problems: string[]; attempts: number };
 
export async function extract(email: string): Promise<Result> {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: `<email>\n${email}\n</email>` }];
  let last: Extraction | null = null;
  let problems: string[] = [];
 
  for (let attempt = 1; attempt <= 2; attempt++) {
    const message = await client.messages.parse({
      model: "claude-opus-5-5",
      max_tokens: 4096,
      output_config: { effort: "low", format: zodOutputFormat(Extraction) },
      system: SYSTEM,
      messages,
    });
    // parsed_output is null when there is no complete, schema-valid output (a refusal or max_tokens).
    if (message.stop_reason !== "end_turn" || !message.parsed_output) {
      return { status: "review", invoice: null, problems: [`no structured output (stop_reason: ${message.stop_reason})`], attempts: attempt };
    }
 
    last = message.parsed_output;
    if (!last.isInvoice) return { status: "not_invoice", attempts: attempt };
    problems = checkInvoice(last);
    if (problems.length === 0) return { status: "invoice", invoice: last, attempts: attempt };
 
    // One retry for extraction mistakes. If the email itself is inconsistent, the model should
    // keep the values as written, the problems remain, and a person reviews the invoice.
    messages.push(
      { role: "assistant", content: message.content },
      {
        role: "user",
        content:
          `Check your extraction against the email. These rules failed:\n- ${problems.join("\n- ")}\n` +
          "Fix any value you copied wrongly. If the email itself contains these numbers, keep them exactly as written.",
      },
    );
  }
  return { status: "review", invoice: last, problems, attempts: 2 };
}

The retry message is the subtle part. A model told only "the total is wrong, fix it" will happily change 195.00 to 175.00, producing a clean-looking row for an invoice whose vendor actually billed 195. The instruction distinguishes the two kinds of failure: copying mistakes get fixed, inconsistencies in the source are preserved and surface for review. In accounting, the second kind is often the one that matters.

The Result type is a discriminated union, so the caller handles each outcome explicitly and TypeScript knows that invoice exists only for "invoice" and "review".

6. Run the whole folder#

src/main.ts
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { extract, type Result } from "./extract.ts";
import { csvField } from "./rules.ts";
 
const dir = process.argv[2] ?? "emails";
const files = (await readdir(dir)).filter((f) => f.endsWith(".txt")).sort();
 
// Independent emails, so they run concurrently.
const results: [string, Result][] = await Promise.all(files.map(async (f) => [f, await extract(await readFile(join(dir, f), "utf8"))] as [string, Result]));
 
const rows = [["file", "vendor", "invoice_number", "issue_date", "due_date", "currency", "subtotal", "tax", "total"]];
const review: { file: string; problems: string[]; extracted: unknown }[] = [];
 
for (const [file, r] of results) {
  if (r.status === "invoice") {
    const x = r.invoice;
    rows.push([file, x.vendor, x.invoiceNumber, x.issueDate, x.dueDate, x.currency, x.subtotal, x.tax, x.total].map((v) => csvField(v ?? null)));
    console.log(`✓ ${file}: ${x.invoiceNumber}, ${x.total} ${x.currency}${r.attempts > 1 ? " (after a retry)" : ""}`);
  } else if (r.status === "not_invoice") {
    console.log(`- ${file}: not an invoice, skipped`);
  } else {
    review.push({ file, problems: r.problems, extracted: r.invoice });
    console.log(`? ${file}: needs review\n    ${r.problems.join("\n    ")}`);
  }
}
 
await writeFile("invoices.csv", rows.map((r) => r.join(",")).join("\n") + "\n");
await writeFile("review.json", JSON.stringify(review, null, 2));
console.log(`\n${rows.length - 1} invoice(s) in invoices.csv, ${review.length} for review in review.json`);
npm run extract
output against the local mock
✓ 01-printworks.txt: INV-2026-0412, 249.9 EUR
? 02-cloudhost.txt: needs review
    total: subtotal + tax is 175.00, not 195
✓ 03-office-supplies.txt: INV-OS-1207, 184.8 GBP
- 04-newsletter.txt: not an invoice, skipped
 
2 invoice(s) in invoices.csv, 1 for review in review.json
invoices.csv
file,vendor,invoice_number,issue_date,due_date,currency,subtotal,tax,total
01-printworks.txt,Printworks GmbH,INV-2026-0412,2026-10-01,2026-10-31,EUR,210,39.9,249.9
03-office-supplies.txt,Office Supplies Ltd,INV-OS-1207,2026-10-05,2026-11-04,GBP,156,28.8,184.8

Office Supplies states no subtotal, so the model computed one from the lines (156.00), as the schema's description allows; the rules then confirmed that 156.00 + 28.80 is the stated 184.80. CloudHost went through its retry and still failed, because the email really does say 195: exactly the invoice a person should look at before paying.

7. Test the rules#

test/rules.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { checkInvoice, csvField } from "../src/rules.ts";
import type { Extraction } from "../src/schema.ts";
 
const valid: Extraction = {
  isInvoice: true,
  vendor: "Printworks GmbH",
  invoiceNumber: "INV-2026-0412",
  issueDate: "2026-10-01",
  dueDate: "2026-10-31",
  currency: "EUR",
  lineItems: [
    { description: "Business cards", quantity: 2, unitPrice: 45, amount: 90 },
    { description: "Flyer design", quantity: 1, unitPrice: 120, amount: 120 },
  ],
  subtotal: 210,
  tax: 39.9,
  total: 249.9,
};
 
test("a consistent invoice passes", () => {
  assert.deepEqual(checkInvoice(valid), []);
});
 
test("money is compared in cents, so 0.1 + 0.2 does not fail", () => {
  const x = { ...valid, lineItems: [{ description: "a", quantity: 1, unitPrice: 0.1, amount: 0.1 }, { description: "b", quantity: 1, unitPrice: 0.2, amount: 0.2 }], subtotal: 0.3, tax: 0, total: 0.3 };
  assert.deepEqual(checkInvoice(x), []);
});
 
test("a total that does not add up is reported with the expected value", () => {
  assert.deepEqual(checkInvoice({ ...valid, total: 259.9 }), ["total: subtotal + tax is 249.90, not 259.9"]);
});
 
test("wrong line amounts, missing fields, and impossible dates are all reported", () => {
  const x = { ...valid, invoiceNumber: null, dueDate: "2026-09-01", lineItems: [{ ...valid.lineItems[0]!, amount: 80 }, valid.lineItems[1]!] };
  const problems = checkInvoice(x);
  assert.ok(problems.includes("invoiceNumber: missing"));
  assert.ok(problems.some((p) => p.startsWith("lineItems.0: 2 × 45 is 90.00")));
  assert.ok(problems.some((p) => p.startsWith("dueDate:")));
});
 
test("non-invoices are not checked", () => {
  assert.deepEqual(checkInvoice({ ...valid, isInvoice: false, total: null, lineItems: [] }), []);
});
 
test("CSV fields are quoted only when needed", () => {
  assert.equal(csvField("Printworks GmbH"), "Printworks GmbH");
  assert.equal(csvField('Acme, "The Best"'), '"Acme, ""The Best"""');
  assert.equal(csvField(null), "");
});
npm test   # 6 passing, no API calls

Common mistakes#

  • Trusting valid JSON as a correct answer. The schema guarantees shape, not truth. Check the numbers in code.
  • Retries that "fix" the source. Asking the model to make totals add up teaches it to falsify data. Ask it to copy faithfully and let inconsistencies reach a person.
  • Money in floating point. Compare cents as integers.
  • Required fields for data that may be absent. The model will invent a plausible value. Make them nullable and decide in code what is mandatory.
  • No path for "none of the above". Without isInvoice, a newsletter becomes an invoice with guessed fields.

Try it

Extend the extractor

  1. Add a fifth email with a due date before its issue date, and confirm it lands in review with the date problem.
  2. Add a vatNumber field that must match a simple pattern for EU vendors, validated in checkInvoice.
  3. Send PDFs instead of text: read a PDF as base64 and pass it as a document content block before the instructions.
  4. Process a folder of 200 emails with the Batches API at half price, matching results by custom_id (see the cache lab for the batch code).

Check your understanding

Why are most fields .nullable() rather than required strings and numbers?
An email's stated total is wrong. What should the pipeline produce?
Why compare amounts as integer cents?

Key takeaways

  • Use messages.parse with a Zod schema for the shape, and business rules in code for the meaning.
  • Make absent data nullable, put classification (isInvoice) in the same schema, and guide fields with .describe().
  • Retry once with the specific problems, but instruct the model to preserve values that are wrong in the source.
  • Give every input exactly one outcome: accepted, skipped, or sent to a person with reasons.

Next steps#

Finished this article? Track it to see your progress on the roadmap.