Skip to content
json-schema email-parsing currency data-quality api

Money Amounts and Currencies in Email Extraction

Model prices from emails without rounding errors or guessed currencies: integer minor units, explicit currency codes, signed refunds, and source text for review.

MailFrame Team

“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.2 is not 0.3 in binary floating point. Summing line items as floats can disagree with the printed total by a cent.
  • Separators differ. 1,234.50 and 1.234,50 mean 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.
  • currency is null for a bare $. The symbol alone does not identify the currency. The symbol is kept in currency_text so your code can apply a documented default, or send the record to review.
  • The pattern carries the enforcement. It is ordinary schema validation. A description is guidance, not a guarantee, so test it against real emails from your sources.
  • total_text stays. It is the one-glance check: "1.234,50 €" next to 123450 and EUR.

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.

  1. Store assumptions separately. If you default the currency, record it as your own field (for example assumed_currency: "USD"), not by overwriting the extracted currency.
  2. Re-check the numbers yourself. Compare total_minor with the printed total_text and with the sum of any line items you also extract. Do not rely on the schema to catch a mismatch.
  3. 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_DIGITS table 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/parse and 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.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access