Defining schemas
A schema is the list of fields ParsedMail extracts from an email, each with a key, a type and an optional hint. You describe the fields in one plain-English sentence, adjust the generated typed fields, and every email parsed with the schema returns exactly those keys with values converted to their types.
Start from a plain-English description
Write the fields the way you would explain them to a colleague, separated by commas:
invoice number, vendor name, vendor email, invoice date, due date, line items with description, quantity and unit price, subtotal, tax, total, payment status (paid, unpaid, partially paid)ParsedMail turns it into fields without calling the model. "with" starts a table, a list in brackets becomes enum options, and words such as date, total, quantity or email set the type:
[
{"key": "invoice_number", "label": "Invoice number", "type": "string"},
{"key": "vendor_name", "label": "Vendor name", "type": "string"},
{"key": "vendor_email", "label": "Vendor email", "type": "email"},
{"key": "invoice_date", "label": "Invoice date", "type": "date"},
{"key": "due_date", "label": "Due date", "type": "date"},
{"key": "line_items", "label": "Line items", "type": "table", "columns": [
{"key": "description", "label": "Description", "type": "string"},
{"key": "quantity", "label": "Quantity", "type": "integer"},
{"key": "unit_price", "label": "Unit price", "type": "money"}
]},
{"key": "subtotal", "label": "Subtotal", "type": "money"},
{"key": "tax", "label": "Tax", "type": "money"},
{"key": "total", "label": "Total", "type": "money"},
{"key": "payment_status", "label": "Payment status", "type": "enum", "options": ["paid", "unpaid", "partially paid"]}
]Review the result in the schema editor: change a type, add a hint, mark a field required. Through the API you can send either a description or an explicit fields array to POST /v1/schemas.
Field types and their JSON output
Every value is converted to its declared type before it is stored or delivered. A value that is not in the email is null; lists and tables are an empty array instead.
| Type | JSON output | Notes |
|---|---|---|
string | "NW-48213" | Text as written, trimmed |
number | 12.5 | "1,234.50" and "1.234,50" both read as 1234.5 |
integer | 2 | Rounded to a whole number |
money | {"amount": 552.4, "currency": "USD"} | Amount rounded to 2 decimals, ISO 4217 currency or null when the email gives none |
date | "2026-10-21" | YYYY-MM-DD |
datetime | "2026-10-21T09:30:00+00:00" | ISO 8601 with offset |
boolean | true | Reads yes, no, paid, unpaid, confirmed |
email | "[email protected]" | Lowercased; an invalid address becomes null with a warning |
phone | "+1 415 555 0134" | Kept as written in the email |
url | "https://example.com/track/1Z99" | String |
enum | "unpaid" | One of the options, matched without regard to case |
list | ["PHP", "SQL"] | Array of strings |
table | [{"description": "...", "quantity": 2}] | Array of row objects with typed columns |
When a value cannot be converted to its type, for example "TBD" in a date field, the field becomes null, its confidence drops to 0.4 or lower and a warning keeps the original text. Only string, phone and url fields keep text as written. Your code never receives a value of the wrong type:
"data": {"due_date": null},
"warnings": [{"field": "due_date", "message": "Could not convert \"TBD\" to date."}]Tables, columns and enum options
A table field holds repeating rows such as line items, passengers or shipments. Each table has 1 to 20 columns. A column can be any type except table, list and enum; those are stored as string. Values inside a row are converted like top-level fields, and the table as a whole has one confidence score.
{
"key": "shipments",
"label": "Shipments",
"type": "table",
"description": "One row per tracking number",
"columns": [
{"key": "carrier", "label": "Carrier", "type": "string"},
{"key": "tracking_number", "label": "Tracking number", "type": "string"},
{"key": "shipped_on", "label": "Shipped on", "type": "date"}
]
}An enum field needs 2 to 30 options of up to 60 characters. A value outside the options becomes null with the warning "is not one of the allowed options", so the field never carries a surprise value into your code.
{"key": "priority", "label": "Priority", "type": "enum", "options": ["low", "normal", "urgent"], "required": true}Required fields and confidence
Mark a field "required": true when an email without it is not useful. If the value is missing, the field is null, its confidence is 0 and the result carries the warning "Required field not found in the email." On Growth and Scale this sends the email to the review queue, as described in inbox review thresholds.
Every top-level field gets a confidence from 0 to 1. confidence_avg averages the fields that have a value and the required fields, so an optional field that is honestly absent does not pull the average down.
Versions, replay and JSON Schema export
A schema starts at version 1. Every save that changes the fields raises the version by one; renaming the schema does not. New emails use the latest version right away, and each result records the schema_version it was parsed with. To apply a new version to older emails, replay them from the log or with POST /v1/emails/{id}/replay; a replay of an email that was already counted is not counted again.
Each schema exports as JSON Schema draft 2020-12, in the editor and as json_schema in GET /v1/schemas/{id}. Use it to validate webhook bodies or to generate types:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Invoices",
"type": "object",
"properties": {
"invoice_number": {"type": ["string", "null"], "description": "Invoice number"},
"due_date": {"type": ["string", "null"], "format": "date", "description": "Due date"},
"total": {"type": ["object", "null"], "properties": {"amount": {"type": "number"}, "currency": {"type": "string", "description": "ISO 4217 code"}}, "description": "Total"},
"payment_status": {"type": ["string", "null"], "enum": ["paid", "unpaid", "partially paid", null], "description": "Payment status"},
"line_items": {"type": "array", "items": {"type": "object", "properties": {"description": {"type": ["string", "null"], "description": "Description"}}}, "description": "Line items"}
},
"required": ["invoice_number", "total"]
}Through the API, PUT /v1/schemas/{id} replaces the fields only when you send a fields array, or a description together with "regenerate": true to build the fields again from it. Sending only name or description updates that text and leaves the fields and the version as they are. Details are in the API reference.
Test a schema against a sample email
In the schema editor paste a sample email, between 10 and 60,000 characters, and press Test. You see the JSON, the confidence per field and any warnings before a single real email arrives. Each test run counts as one email on your plan or sandbox. To test the full path including attachments, send an .eml file to the inbox from its page, or POST it as described in sending emails.
Limits and best practices for hints
- A schema has at most 60 fields. Keys are lowercase with underscores, up to 48 characters, and unique within the schema. Labels go up to 80 characters, hints up to 300.
- Use one schema per kind of email. When an inbox receives several kinds, use classification instead of one huge schema.
- Write hints for the model the way you would brief a new hire: "Total including tax, in the invoice currency", "Date the goods leave the warehouse, not the order date", "Customer reference, starts with PO-".
- Prefer
moneyovernumberfor amounts, so the currency travels with the value. - Use
enumfor values your code branches on, andtablefor anything that repeats. - Mark only the fields you truly need as required; every missing required field sends the email to review on Growth and Scale.
- Give the schema itself a one-sentence description. Classification reads it when choosing between schemas.
Frequently asked questions
Does ParsedMail use templates or regular expressions?
Can I nest a table inside a table?
What happens to old results when I change a schema?
schema_version they were parsed with. Replay the emails you want to parse again with the new version.