Tutorials / Structured data
Tutorial · Python or TypeScript · 15 minutesTurn emails into typed Pydantic objects
You already have a model for the data you want. Give it to a parser and every email arrives at your webhook as that model, nested lists included, checked against your schema before it's delivered. No regexes, no prompt to maintain, no guessing whether a field is there.
▶ Video walkthroughs on YouTube
- Your modelPydantic or Zod
- Schema parserits JSON Schema, versioned
- Email arrivesread by Claude in your shape
- Checkedevery rule in your schema
- Your objectvalid: true, typed
1. Describe what you want
Write the model as you would for any API. Constraints are part of the contract: they're checked on every email.
# models.py
from datetime import date
from pydantic import BaseModel, Field
class LineItem(BaseModel):
sku: str = Field(max_length=12, description="The product code")
qty: int = Field(ge=1)
unit_price: float = Field(ge=0)
class Invoice(BaseModel):
invoice_number: str = Field(pattern=r"^INV-\d+$")
supplier: str
total: float = Field(ge=0, description="The amount due, in pounds")
due_date: date | None = None # optional: not every invoice says
po_number: str | None = None
lines: list[LineItem]
// invoice.ts (Zod 4)
import { z } from "zod";
export const Invoice = z.object({
invoice_number: z.string().regex(/^INV-\d+$/),
supplier: z.string(),
total: z.number().min(0).describe("The amount due, in pounds"),
due_date: z.iso.date().nullable(), // optional: not every invoice says
po_number: z.string().nullable(),
lines: z.array(z.object({
sku: z.string().max(12).describe("The product code"),
qty: z.number().int().min(1),
unit_price: z.number().min(0),
})),
});
Say what can be missing. A required field that can't be null forces the AI to put
something there. Make a field optional (| None = None, .nullable()) if some emails
won't have it: you'll get null instead of a guess. We warn you about required fields when you save.
2. Make it a parser
The CLI reads your model and creates a schema parser named after it. Run it again after changing the model and you get a new version; the old ones are kept.
export EMAIL_PARSER_API_KEY=ek_live_… npx @wildye/email-parser-cli schema push models.py:Invoice ✓ Created Invoice prs_8f2k… · schema v1
import { EmailParser } from "@wildye/email-parser";
import { z } from "zod";
import { Invoice } from "./invoice.js";
const client = new EmailParser({ apiKey: process.env.EMAIL_PARSER_API_KEY! });
const parser = await client.parsers.create({ name: "Invoice", mode: "schema", schema: z.toJSONSchema(Invoice) });
Prefer clicking? Paste the JSON Schema on the dashboard's Parsers → New parser → Your JSON Schema.
3. Try it on a real email
Save any invoice email as a .eml file and run it through the parser. Nothing is delivered; it uses
one AI extraction.
npx @wildye/email-parser-cli schema test prs_8f2k… --eml invoice.eml
✓ valid: passed every check in the schema (v1)
{
"invoice_number": "INV-20977",
"supplier": "Lumen Office Supplies",
"total": 312,
"due_date": "2026-11-14",
"lines": [{ "sku": "PLT-L-TERRA", "qty": 2, "unit_price": 156 }]
}
If the answer breaks your schema, Claude gets one more try with the reasons. If it still doesn't pass, you see
exactly why, for example /lines/0/qty must be >= 1.
4. Use it for a domain
Choose the parser for one address, or for a whole domain (every address without its own parser):
curl -X PATCH https://ghostparse.com/v1/domains/dom_… -H "Authorization: Bearer $EMAIL_PARSER_API_KEY" \
-H "Content-Type: application/json" -d '{"parser_id":"prs_8f2k…"}'
Each email to the domain now carries data.extracted:
"extracted": {
"method": "schema", "schema_version": 1, "schema_sha256": "9f2c…",
"valid": true, "errors": [],
"data": { "invoice_number": "INV-20977", "supplier": "Lumen Office Supplies", "total": 312, … }
}
5. Receive it as your type
Verify the webhook, then turn data back into your model. It's checked again on your side, so a
parser pointed at a different schema can't slip through.
from fastapi import FastAPI, Request, Response
from email_parser import verify_webhook, WebhookVerificationError
from models import Invoice
app = FastAPI()
@app.post("/webhooks/email")
async def email_hook(request: Request):
try:
event = verify_webhook(await request.body(), request.headers, SECRET)
except WebhookVerificationError:
return Response(status_code=401)
email = event.email
if email.extracted_valid:
invoice = email.extracted_as(Invoice) # an Invoice, typed
accounts.add_bill(invoice.supplier, invoice.total, invoice.due_date, invoice.lines)
else:
review_queue.add(email.subject, email.raw["extracted"]["errors"])
return Response(status_code=200)
import express from "express";
import { extractedAs, verifyWebhook } from "@wildye/email-parser";
import { Invoice } from "./invoice.js";
const app = express();
app.post("/webhooks/email", express.raw({ type: "*/*" }), (req, res) => {
const event = verifyWebhook(req.body, req.headers, SECRET); // throws on a forged request
if (event.data.extracted?.valid) {
const invoice = extractedAs(event.data, Invoice); // z.infer<typeof Invoice>
accounts.addBill(invoice.supplier, invoice.total, invoice.due_date, invoice.lines);
} else {
reviewQueue.add(event.data.subject, event.data.extracted?.errors);
}
res.sendStatus(200);
});
Good to know
- Invalid answers still arrive, marked
valid: false. Route them elsewhere with a routing rule, or handle them in code as above. Mail is never held because of a schema. - Descriptions help.
Field(description=…)and.describe()reach the AI, as do the parser's instructions ("Amounts are in pounds"). - Not supported: recursive models,
dict[str, …]/z.record(use a list of objects with a key field), and references to other files. - Versions: every webhook says which
schema_versionit used, so you can deploy a model change before or after the parser change. - Cost: one AI extraction per email, including the retry.