“Total: 1.234,50” is a normal line in a receipt. It is also a trap. Is that one thousand two
hundred thirty-four and a half, or one point two three four with trailing digits? Is the currency
euros, dollars, or something else? If your schema has one number field called total, the
extraction has to guess, and a wrong guess still looks like valid JSON.
This guide covers how to shape money fields for POST /v1/parse results: integer minor units,
an explicit currency code, signed amounts, and the original text kept alongside. A note on scope
up front. MailFrame today is the direct, synchronous POST /v1/parse call: raw email plus a
JSON Schema in, schema-validated JSON back in the same HTTP response. Currency conversion,
rounding policy, and deciding which total to trust are your application’s policy, written in
your code. MailFrame does not convert currencies or reconcile totals for you.
Why a plain number field is risky
- Floats drift.
0.1 + 0.2is not0.3in binary floating point. Summing line items as floats can disagree with the printed total by a cent. - Separators differ.
1,234.50and1.234,50mean the same amount in different locales. A bare number hides which reading was used. - The currency is separate information. A
$sign can mean USD, CAD, AUD, and more. The amount alone is not enough to store. - Minor units differ per currency. USD has 2 decimal places, JPY has 0, and some currencies have 3. “Multiply by 100” is wrong for all of those.
Integers in minor units, plus a currency code
Store the amount as an integer count of the smallest unit (cents for USD, yen for JPY) and store
the currency as an ISO 4217 code in its own field. Integers add exactly, and the pair
(amount_minor, currency) is unambiguous.
{
"type": "object",
"required": ["total_text", "total_minor", "currency", "currency_text"],
"properties": {
"total_text": {
"type": ["string", "null"],
"description": "The total exactly as printed in the email, including symbols and separators. Null if no total is present."
},
"total_minor": {
"type": ["integer", "null"],
"description": "The total in the currency's smallest unit (for USD, cents). Negative for refunds or credits. Null if the amount is missing or its format is unclear. Never guess."
},
"currency": {
"type": ["string", "null"],
"pattern": "^[A-Za-z]{3}$",
"description": "ISO 4217 code (case-insensitive; MailFrame typically returns lowercase, such as usd), only if the email states it by code or an unambiguous name. Null for a bare symbol such as $."
},
"currency_text": {
"type": ["string", "null"],
"description": "The currency symbol or word exactly as written, for example $ or EUR. Null if none appears."
}
},
"additionalProperties": false
}
A few choices here are deliberate:
- Every key is required and nullable. “The email did not say” needs somewhere to go. The reasoning matches optional vs. required fields.
currencyis null for a bare$. The symbol alone does not identify the currency. The symbol is kept incurrency_textso your code can apply a documented default, or send the record to review.- The
patterncarries the enforcement. It is ordinary schema validation. Adescriptionis guidance, not a guarantee, so test it against real emails from your sources. total_textstays. It is the one-glance check:"1.234,50 €"next to123450andEUR.
Calling /v1/parse
The request is the same shape as any inline-schema call: schema and raw_mime. This example
uses a synthetic email:
curl -X POST https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILFRAME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": {
"type": "object",
"required": ["total_text", "total_minor", "currency", "currency_text"],
"properties": {
"total_text": { "type": ["string", "null"] },
"total_minor": { "type": ["integer", "null"] },
"currency": { "type": ["string", "null"], "pattern": "^[A-Za-z]{3}$" },
"currency_text": { "type": ["string", "null"] }
},
"additionalProperties": false
},
"raw_mime": "From: billing@example.com\r\nTo: you@example.com\r\nSubject: Your receipt\r\n\r\nThanks for your order. Total: 1.234,50 EUR"
}'
The schema is shortened for the command line: it drops the description strings but keeps every
constraint. In your application, send the full document, descriptions included.
A response might look like this. The field names id, status, validation_errors, and data
follow the current API shape; the values are illustrative, since they depend on the email and the
extraction run:
{
"id": "parse_c93d11",
"status": "completed",
"validation_errors": [],
"data": {
"total_text": "1.234,50 EUR",
"total_minor": 123450,
"currency": "eur",
"currency_text": "EUR"
}
}
For an email that says only Total: $20.00, the honest result keeps the amount but leaves the
currency open. Again, illustrative:
{
"id": "parse_d05a72",
"status": "completed",
"validation_errors": [],
"data": {
"total_text": "$20.00",
"total_minor": 2000,
"currency": null,
"currency_text": "$"
}
}
This is a valid result. Nothing failed, and validation_errors is empty. It is also a record you
should not post to a ledger yet, because the currency is unknown.
Decide in your code, not in the schema
Everything past this point is application policy. Whether a bare $ from a US-only sender means
USD is a business rule you own.
- Store assumptions separately. If you default the currency, record it as your own field
(for example
assumed_currency: "USD"), not by overwriting the extractedcurrency. - Re-check the numbers yourself. Compare
total_minorwith the printedtotal_textand with the sum of any line items you also extract. Do not rely on the schema to catch a mismatch. - Route doubt to review, not to a default. A held record costs a person a few seconds. A wrong amount posted to an account costs more.
The excerpt below shows the decision. It is framing code, not a drop-in module. holdForReview
and saveReceipt stand in for your own queue and storage, and rawMime is the raw email you
already keep. Nothing in it touches your API key.
// holdForReview, saveReceipt: provided by your application.
type ParsedMoney = {
total_text: string | null;
total_minor: number | null;
currency: string | null;
currency_text: string | null;
};
// Decimal places per currency, keyed by uppercase code. Extend with the currencies your sources actually use.
const MINOR_DIGITS: Record<string, number> = { USD: 2, EUR: 2, GBP: 2, JPY: 0 };
function reviewReasons(data: ParsedMoney): string[] {
const reasons: string[] = [];
if (data.total_minor === null) {
reasons.push("no amount extracted");
} else if (!Number.isSafeInteger(data.total_minor)) {
reasons.push("amount is not a safe integer");
}
if (data.currency === null) {
reasons.push("currency not stated in the email");
} else if (!(data.currency.toUpperCase() in MINOR_DIGITS)) {
reasons.push(`no minor-unit rule for ${data.currency}`);
}
// Cross-check: the digits printed in total_text should equal total_minor. This assumes
// total_text holds exactly one amount (no order number, date, or VAT line) and prints every
// decimal place; a mismatch only sends the record to review.
if (data.total_text !== null && data.total_minor !== null) {
const printed = data.total_text.replace(/\D/g, "").replace(/^0+(?=\d)/, "");
if (printed !== String(Math.abs(data.total_minor))) {
reasons.push("total_text and total_minor disagree");
}
// Sign check. This recognizes only a leading minus ("-", or U+2212), optionally followed by
// whitespace, such as "-$20.00" or "- 20.00 EUR". It does not recognize a trailing minus,
// "CR", parentheses, or locale-specific forms: send those through a locale-aware parser or
// to manual review instead of assuming they are positive.
const printedNegative = /^\s*[-−]\s*/.test(data.total_text);
if (printedNegative !== (data.total_minor < 0)) {
reasons.push("sign of total_text and total_minor disagree");
}
}
return reasons;
}
// result is the parsed JSON body from POST /v1/parse; rawMime is your stored copy of the email.
const { data, status, validation_errors = [] } = result;
if (status !== "completed" || validation_errors.length > 0) {
await holdForReview({ rawMime, status, validation_errors });
} else {
const reasons = reviewReasons(data);
if (reasons.length > 0) {
await holdForReview({ rawMime, data, reasons });
} else {
await saveReceipt(data);
}
}
The sign check is deliberately narrow. It only recognizes a leading minus in total_text, so a
refund printed as -$20.00 but extracted as 2000 is held. A negative amount written as 20.00-,
20.00 CR, (20.00), or in another locale’s notation is not detected here and would be read as
positive. If your sources use those forms, parse the sign with a locale-aware parser or route the
record to manual review.
Applied to the two examples: the euro receipt passes and is saved. The bare-dollar receipt is
held with "currency not stated in the email", and the reviewer sees total_text and the
original message side by side. For failures of the request itself, such as a 400, a 422, or a
429, see
handling errors and timeouts from POST /v1/parse.
Those are a separate branch from the money logic here.
What this does not cover
- Currency conversion. If a record needs a reporting currency, your code must pick a rate and a date, and store both. The email rarely states either.
- Rounding and tax rules. Rounding per line versus per invoice differs by jurisdiction and by merchant. Extract what is printed and apply your own accounting rules.
- Multiple totals. Receipts often show subtotal, tax, shipping, discount, and total. Extract each as its own field and check that they agree, rather than asking for “the amount”.
- Currencies with three decimal places. A few currencies use three. The
MINOR_DIGITStable above is where you would add them, along with the matching cross-check logic. - Fully worked receipt schemas. See the Stripe receipt schema and invoice emails to JSON for complete shapes.
Next steps
- Store amounts as integers in minor units, never as floats.
- Put the currency in its own ISO 4217 field, and make it null when the email only shows a bare symbol.
- Make every money field required and nullable, and extract the printed text next to it.
- Use a negative integer for refunds and credits, and say so in the field
description. - Re-check totals in your own code against the printed text and any line items.
- Keep assumed currencies in a separate field from extracted ones.
- Hold uncertain records for a person instead of defaulting, and keep the original email with them.
- Run a sample of your own real emails through
/v1/parseand read the output by hand before trusting the schema.
The request and response shapes are in the API docs. For the broader schema workflow, start with building a JSON Schema email parser.