Parsing order confirmation emails
Marketplaces, shop platforms and resellers all send an order confirmation, and almost none of them will send you the same order as JSON. This page turns that mail into an order row plus its line items — and shows the reconciliation that tells you when a row went missing.
Measured on 30 August 2026 against the live service at
https://mailmint.app.mintapis.com, from a free account created the same morning.
The message, the schema and the response are all printed below, including a run that produced a false
alarm and why.
Order mail into order rows #
You need the order to exist in your own system: an order number to key on, who it is for, what it came to, and the products with quantities so stock and margin are right. The mail contains all of it, laid out for a human, in a template the sender may change next Tuesday.
The header fields — number, name, total, currency — are the easy half. They are short, labelled, and there is exactly one of each.
The line items are the hard part #
Three reasons, and they compound:
- There is often no table. Outlook compatibility pushes every large sender towards
nested single-cell tables, so an HTML
<table>extractor structurally cannot see the line items. MailMint runs row extraction from more than one independent source and reconciles the candidates; when two sources disagree the field is flaggedarray_source_disagreementrather than silently resolved in favour of one. - A short row set looks exactly like a correct one. "Forty rows in the mail, one row in
the output" is the standing complaint in this category, and it goes unnoticed because nothing in the
output says how many there should have been. Deterministic tables therefore carry
row_countandtruncatedalongside the rows. - Summary rows double-count. A Subtotal line sitting inside the same visual table as the products will break every sum downstream if it is treated as a product. It is detected and dropped.
The schema #
Line items are an array of object, so you get typed rows rather than strings:
[
{ "name": "order_number", "type": "string", "description": "the order reference" },
{ "name": "customer_name", "type": "string", "description": "name of the person who ordered" },
{ "name": "subtotal", "type": "number", "description": "sum of the line items before shipping" },
{ "name": "shipping_cost", "type": "number", "description": "the shipping charge" },
{ "name": "order_total", "type": "number", "description": "grand total charged" },
{ "name": "currency", "type": "string", "description": "ISO currency code" },
{ "name": "shipping_postcode", "type": "string", "description": "postcode of the delivery address" },
{ "name": "shipping_country", "type": "string", "description": "delivery country" },
{ "name": "line_items", "type": "array", "description": "one row per ordered product",
"items": { "type": "object", "fields": [
{ "name": "sku", "type": "string" },
{ "name": "description", "type": "string" },
{ "name": "qty", "type": "integer" },
{ "name": "unit_price", "type": "number" },
{ "name": "amount", "type": "number" }
] } }
]
Asking for subtotal and shipping_cost is not decoration. They are what let the
arithmetic check know the relationship it is supposed to verify — see
below.
A real order mail, really parsed #
A three-product order confirmation with SKUs, a shipping charge and a UK delivery address, sent as
plain text. Back in 5,132 ms with flags: [] and
needs_review: false:
| Field | Value | Conf. | Source |
|---|---|---|---|
order_number | A7-449021 | 0.97 | rule+llm |
customer_name | Priya Raman | 0.85 | llm |
subtotal | 122.5 | 0.98 | rule+llm |
shipping_cost | 4.9 | 0.85 | llm |
order_total | 127.4 | 0.96 | llm |
currency | GBP | 0.97 | rule+llm |
shipping_postcode | BS1 4TR | 0.85 | llm |
shipping_country | United Kingdom | 0.85 | llm |
And the rows, typed, straight out of fields.line_items.value:
[
{ "sku": "CPD-02", "description": "Ceramic Pour-Over Dripper", "qty": 2, "unit_price": 27, "amount": 54 },
{ "sku": "FP-100", "description": "Filter Papers, 100 pack", "qty": 1, "unit_price": 6.5, "amount": 6.5 },
{ "sku": "GK-09", "description": "Gooseneck Kettle 0.9L", "qty": 1, "unit_price": 62, "amount": 62 }
]
54 + 6.50 + 62 = 122.50, which is the subtotal; 122.50 + 4.90 = 127.40, which is the
order_total. Both equations were checked, both held, and the message came back clean.
The one naming mistake that floods your queue #
Worth knowing because it is invisible: the arithmetic check has to be able to find the money
column inside each row. It reads amount, total, line_total,
price, preis, betrag, sum, value,
charge, montant and importe, in any casing, with underscores or
spaces. It deliberately does not read unit_price, einzelpreis or
rate — a per-unit rate is never mistaken for a line total.
An earlier build of this service accepted only amount and total. The same
order mail, the same schema, with the row field renamed, on 30 August 2026:
| Row money field | Result then | Result now |
|---|---|---|
amount | clean | clean |
price | arithmetic_mismatch, needs_review | clean |
betrag | arithmetic_mismatch, needs_review | clean |
line_total | confidence penalty on the total | clean |
And a run that shows why the check is worth having. Eight further runs of the same
message on 30 August 2026 came back clean seven times. The eighth did not, and it was right not to: that
run extracted four rows instead of three — one of them entirely null —
and priced the £62 kettle at £6.50. The rows summed to 73.50 against a stated subtotal of
122.50, so arithmetic_mismatch fired and the message went to review instead of into an order
system. Nothing else in that response looked wrong: the order number, the total and the address were all
correct, and a parser without the reconciliation would have handed you a clean-looking order with a
missing product and a mispriced one. That is the failure this check exists for, and it is also why the
false version of it was worth fixing — an alarm that fires on every message is an alarm nobody reads
on the one that matters.
The rows were identical and correct in every case; only the key name changed. The sum came to zero, zero never reconciles, and the message was flagged — every message, forever. It is fixed, the recognised names are listed above and in the reference, and the case is in the test suite. It is on this page because it is the kind of thing a vendor normally quietly patches, and because the general rule it illustrates is worth more than the bug: a check that cannot read your data must decline to run, not report your data as wrong. That is now the behaviour — a row set with no recognisable money column is left unverified rather than blamed.
Into your system #
Point webhook_url at your order intake and the signed message object arrives as the order
is confirmed — HMAC-SHA256 over timestamp and body, with the verification worked through
in the reference. Or poll GET /v1/events with a cursor. Or use
n8n-nodes-mailmint on n8n Cloud or self-hosted n8n; if community nodes are disabled, an
HTTP Request node against POST /v1/parse works too.
Two order mails for the same order — the shop's and the marketplace's — are a normal thing to receive. Give each its own mailbox with its own schema and webhook; several endpoints on one mailbox are also supported, and one failing endpoint never disables another.
When not to use this #
- The platform has an API or a webhook. Shopify, WooCommerce, Amazon SP-API and most marketplaces will give you the order as data. Take it. Structured beats parsed every time, and parsing their confirmation email instead is a worse version of the same integration.
- You need it in under a second. These parses took 2.9 to 5.1 s end to end. That is fine for order intake and wrong for anything a customer is waiting on.
- The order is a PDF attachment. Attachment content extraction is not wired into the parse pipeline yet.
- One sender, one template, forever. If exactly one shop mails you and its layout is stable, a regular expression is free and will not surprise you.
What we cannot claim #
MailMint has paying customers, but no customer logo, testimonial or case study is quoted without permission. There is no published uptime history, SLA or SOC 2 report. One region, one instance. PDF attachment contents are not read. The confidence numbers come from a hold-out set of 163 labelled field slots on 36 messages, run three times and pooled — enough to show that 0.9+ means something (189/189 correct in the latest pooled run), not enough to call it calibrated, and below 0.7 the buckets are too thin to order. The table is published with the cases it gets wrong. The parse above is one order mail. Run your own before you believe anything here.
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