Parsing invoice emails
A supplier emails an invoice. Somebody reads the number, the date, the net, the VAT and the gross off the screen and types them into the ledger. This page is about deleting that step — and about the part everybody skips, which is knowing which invoices the machine got wrong.
Every number on this page was measured, on 30 August 2026, against the live service at
https://mailmint.app.mintapis.com, from a free account created the same morning.
The parses below are copied out of the responses, including the one that went wrong. Rerun them
yourself; the schema and the message are both printed in full.
What actually has to happen #
The job is not "read a PDF". Most of the invoices that arrive by email in small and mid-sized businesses are either in the body of the mail or in an attachment whose numbers also appear in the body, and the six steps are always the same:
- Receive the mail at an address you control, and keep the original bytes.
- Decide it is an invoice and not a reminder, a statement or a receipt.
- Pull out the header fields — number, dates, supplier, currency.
- Pull out the money — net, tax, gross, and the line items if you post them separately.
- Decide whether you believe the result.
- Hand it to whatever books it, and hold back the ones you do not believe.
Steps 1 to 4 are the ones every product in this category sells. Step 5 is the one that decides whether you can leave the thing running unattended, and it is the reason this page spends more space on a field that came back wrong than on the eight that came back right.
The five parts that are hard #
| The problem | Why it bites |
|---|---|
| Decimal commas | 1.009,00 is one thousand and nine euros in Germany
and is nonsense read as an English number. Getting this wrong is silent: you book €1.01 and the
totals still look like money. |
| Ambiguous dates | 08/09/2026 is two different months. A due date
off by thirty days is a late payment fee, and nothing in the output looks unusual. |
| Which total | An invoice mail commonly prints four amounts — net, VAT, gross, and an unrelated figure in the footer or the signature. "The biggest number" is wrong often enough to matter. |
| Layout drift | Suppliers change invoice templates without telling anybody. A positional rule — "the value two lines under the word Total" — keeps returning a value after the layout moves. It just returns the wrong one. |
| Knowing you were wrong | The four above are all silent. A parser that returns a plausible number and no signal about it is worse than one that fails loudly, because the error reaches the ledger. |
The schema #
There are no templates to draw and no positional rules to maintain. You name the fields you want, give each a type and one line of description, and that is the whole configuration:
[
{ "name": "invoice_number", "type": "string", "description": "the supplier's invoice number" },
{ "name": "invoice_date", "type": "date", "description": "date the invoice was issued" },
{ "name": "due_date", "type": "date", "description": "date payment is due" },
{ "name": "supplier_name", "type": "string", "description": "legal name of the company sending the invoice" },
{ "name": "currency", "type": "string", "description": "ISO currency code of the amounts" },
{ "name": "subtotal", "type": "number", "description": "net amount before tax" },
{ "name": "tax_amount", "type": "number", "description": "the VAT amount" },
{ "name": "total", "type": "number", "description": "the gross amount due" },
{ "name": "iban", "type": "string", "description": "bank account to pay to" }
]
Attach it to a mailbox and every message that arrives is parsed against it, or send it inline with
POST /v1/parse to try it without an address:
curl -X POST "$MAILMINT_URL/v1/parse" \
-H "Authorization: Bearer $MAILMINT_API_KEY" \
-H "Content-Type: application/json" \
-d @invoice-request.json
A real invoice, really parsed #
A German logistics invoice, decimal commas and all, with three line items and a footer. The full body
was sent as text. It came back in 4,471 ms. Nine fields, verbatim from
the response — value, confidence, and the evidence span the
value was read from:
| Field | Value | Conf. | Source | Evidence in the mail |
|---|---|---|---|---|
invoice_number | INV-2026-0731 | 0.97 | rule+llm | Invoice INV-2026-0731 from Nordwind Logistik GmbH |
invoice_date | 2026-08-28 | 0.97 | rule+llm | Rechnungsdatum: 28.08.2026 |
due_date | 2026-09-27 | 0.93 | llm | 27.09.2026 |
supplier_name | Nordwind Logistik GmbH | 0.25 | llm | none returned |
currency | EUR | 0.97 | rule+llm | 45,00 EUR |
subtotal | 1009 | 0.97 | rule+llm | Zwischensumme 1.009,00 EUR |
tax_amount | 191.71 | 0.93 | llm | 191,71 EUR |
total | 1200.71 | 0.97 | rule+llm | Gesamtbetrag 1.200,71 EUR |
iban | DE44 5001 0517 5407 3249 31 | 0.85 | llm | IBAN DE44 5001 0517 5407 3249 31 |
The decimal commas came through: 1.009,00 EUR is 1009, not 1.009.
Both dates were read day-first because the document is German and said so. And
Gesamtbetrag won over the three other amounts in the mail.
Now the interesting row. supplier_name is
correct — the company really is Nordwind Logistik GmbH — and it came back at
0.25 with the flag hallucinated_evidence:supplier_name. The model produced
the right answer but could not point at the substring it read it from, and a value whose evidence does
not survive a literal search of the message is not trusted, however plausible it looks. The whole
message was marked needs_review: true for that one field.
That is the trade being made, stated plainly: you will review some fields that were right. The alternative is a parser that hands you a confident wrong company name, and that is the failure that ends up in a ledger.
The arithmetic check #
An invoice is one of the few documents that can be checked against itself: the line items sum to the net, and net plus tax minus discount equals the gross. When your schema names those fields, the sums are verified and the result moves the confidence:
- reconciles → the confidence ceiling rises by 0.03;
- does not reconcile → every confidence is multiplied by 0.8 and the message is flagged
arithmetic_mismatch, which setsneeds_reviewon the whole message rather than on one field.
This is what catches the classic failure in this category — forty line items in the mail, one line item in the output — because a short row set does not add up and a short row set is otherwise indistinguishable from a correct one.
Two things worth knowing before you rely on it. It only runs when it knows the relationship.
Line items are compared to a subtotal if you asked for one; against a tax-inclusive
total alone they are only checked against a plausibility band, because the gap between the
two is a tax rate nobody told us. And the name of the money column inside each row matters:
amount, total, line_total, price, preis,
betrag, sum, value, charge, montant and
importe are all read as the line total; unit_price and einzelpreis
deliberately are not. If a row set contains no column we recognise as money, the check declines to run
rather than summing to zero — a message we could not read is not a message that is wrong.
What to do with needs_review #
needs_review is a stored column, not a client-side rule, so the API, the dashboard queue
and the webhook body can never disagree about it. It is also indexed, which means the operational
question — "what do I have to look at this morning" — is one request:
# everything a human still has to see, newest first
curl "$MAILMINT_URL/v1/messages?needs_review=true&view=review" \
-H "Authorization: Bearer $MAILMINT_API_KEY"
# or just the ones whose sums did not add up
curl "$MAILMINT_URL/v1/messages?flag=arithmetic_mismatch" \
-H "Authorization: Bearer $MAILMINT_API_KEY"
view=review adds an issues array to each row: which flag fired, on which
field, what the value and the confidence were, and the evidence. That is enough to build a review screen
without fetching the messages one at a time.
And when you improve the schema — because that supplier name kept coming back thin — you do
not have to wait for the next invoice. POST /v1/mailboxes/{id}/reparse replays the stored
original bytes against the new schema, with dry_run: true to see what would change before
anything does.
Without writing code #
Three routes, in order of how much you have to build:
- Webhook. Point
webhook_urlat your accounting system's inbound endpoint. The signed body is the same message object the API returns. Nothing to poll. - n8n.
n8n-nodes-mailmintinstalls on n8n Cloud and self-hosted n8n; the trigger runs on the polling feed when a webhook cannot reach you. An HTTP Request node againstPOST /v1/parseremains available when community nodes are disabled. - Polling.
GET /v1/events?cursor=…with an opaque cursor, for anything that cannot be reached from outside.
When not to use this #
- The numbers are only inside a PDF attachment. Attachment content extraction is written but not wired into the parse pipeline. If the mail body says "please find attached" and nothing else, MailMint reads the mail and not the invoice. This is the single most likely reason this page does not apply to you — check three real invoices before you go further.
- You are already inside an accounting suite that does this. If DATEV, Xero or QuickBooks already ingest your supplier mail, a second pipeline buys you nothing but reconciliation work.
- Nothing may leave your network. There is one region and one hosted instance. If your invoices cannot cross that line, run a parser yourself; that is a legitimate answer to this problem.
- Under about a hundred invoices a month, arriving in two layouts. A person opening the mail is genuinely competitive at that size, and has better judgement than any confidence score.
What we cannot claim #
MailMint has paying customers, but no customer logo, testimonial or case study is quoted without permission, and there is no uptime history to quote. There is no SLA and no SOC 2. One region, one instance. PDF attachment contents are not read. The confidence calibration is measured over 163 labelled field slots on 36 hold-out messages, run three times — enough to show that values reported at 0.9+ were right 189 times out of 189, nowhere near enough to call any of it a calibrated probability, and below 0.7 the buckets hold so few values that they come out in the wrong order. The whole table is published, including the five high-confidence errors from the preceding run. The parse on this page is one invoice, not a benchmark, and it is shown with its failure rather than without it.
Try it on your own worst email #
Not a demo message — the one your current parser gets wrong. Paste its subject and body into
POST /v1/parse and read the confidence and the evidence span on each field.
/v1/parse stores nothing, needs no inbound address and no DNS record, and the free plan is
300 parsed emails a month with no card, which is enough to run a real low-volume
workflow rather than only to look at one.
The other three #
Comparisons instead: what an email parsing API has to get right · vs Mailparser · vs Parseur · vs Docparser · vs Zapier Parser