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:

  1. Receive the mail at an address you control, and keep the original bytes.
  2. Decide it is an invoice and not a reminder, a statement or a receipt.
  3. Pull out the header fields — number, dates, supplier, currency.
  4. Pull out the money — net, tax, gross, and the line items if you post them separately.
  5. Decide whether you believe the result.
  6. 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 problemWhy it bites
Decimal commas1.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 dates08/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 totalAn 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 driftSuppliers 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 wrongThe 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:

FieldValueConf.SourceEvidence in the mail
invoice_numberINV-2026-07310.97rule+llmInvoice INV-2026-0731 from Nordwind Logistik GmbH
invoice_date2026-08-280.97rule+llmRechnungsdatum: 28.08.2026
due_date2026-09-270.93llm27.09.2026
supplier_nameNordwind Logistik GmbH0.25llmnone returned
currencyEUR0.97rule+llm45,00 EUR
subtotal10090.97rule+llmZwischensumme    1.009,00 EUR
tax_amount191.710.93llm191,71 EUR
total1200.710.97rule+llmGesamtbetrag    1.200,71 EUR
ibanDE44 5001 0517 5407 3249 310.85llmIBAN 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 sets needs_review on 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:

  1. Webhook. Point webhook_url at your accounting system's inbound endpoint. The signed body is the same message object the API returns. Nothing to poll.
  2. n8n. n8n-nodes-mailmint installs on n8n Cloud and self-hosted n8n; the trigger runs on the polling feed when a webhook cannot reach you. An HTTP Request node against POST /v1/parse remains available when community nodes are disabled.
  3. 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.

Get an API key   Read the quickstart   API reference

The other three #

Order confirmationsOrder number, totals and every line item, reconciled against the total.
Shipping notificationsTracking number, carrier and the promised date, out of any carrier's template.
Lead & contact-form emailsName, company, phone and budget into the CRM, with the doubtful ones held back.

Comparisons instead: what an email parsing API has to get right · vs Mailparser · vs Parseur · vs Docparser · vs Zapier Parser