Skip to content
banking transactions cards finance schema email-to-json tutorial

Parse Bank & Card Transaction Emails to JSON

Turn Chase, Bank of America, Wells Fargo, Capital One, Revolut, and Monzo transaction alerts into typed JSON: amount, currency, merchant, account mask, posted date, balance. Schema-first.

MailFrame Team

Every modern bank and card issuer sends a real-time alert when money moves on your account: a debit-card tap at a coffee shop, a wire out to a vendor, a payroll deposit, an ATM withdrawal, a recurring subscription charge, a fraud hold, a refund, a card-network authorization that pre-auths before the actual settlement. The emails come from Chase, Bank of America, Wells Fargo, Capital One, Citi, USAA, PNC, TD Bank, Discover, American Express, HSBC, Barclays, Santander, Revolut, Monzo, Starling, and dozens of regional banks and credit unions — each with its own template, its own way of printing the same handful of fields, and its own sender domain that drifts over time (no-reply@alert.chase.com, alerts@bofa.com, noreply@capitalone.com, transaction@wise.com, alerts@revolut.com).

If you are building a personal-finance tracker (YNAB, Monarch, Copilot Money, Lunch Money, Firefly III), an expense-management product (Brex, Ramp, Expensify, a custom reconciliation job), or a fraud-monitoring queue, the first thing you do with the email is extract the same fields every bank uses, only with different words: transaction type, direction, amount, currency, account mask, merchant, posted date, and the running balance. Parsing that by hand with per-bank regexes is the kind of code that works until a bank ships a new alert template — which is roughly every other quarter.

This post shows how to turn those emails into typed, validated JSON you can route to a finance dashboard, a fraud pager, or an AP reconciliation job — using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If you want the ready-made schema reference, it lives at Parse Bank & Card Transaction Emails; this post is the why-and-how around it.

Everything below uses the shipped, synchronous path: you send raw MIME, you get schema-validated JSON back in the same HTTP response. No inbox to configure, no callback to wait on. (For the reasoning behind the schema-first design, see Why We Built MailFrame Schema-First.)

What’s actually in a bank alert email

The signal you want from a transaction alert is not the bank’s marketing footer — it is a small, structured set of fields every bank prints somewhere in the message:

  • The transaction typetransaction_type is the single most useful field. purchase, withdrawal, deposit, transfer, payment, refund, fee, interest, chargeback, fraud_hold, authorization. This is what decides the lane: a fraud_hold is high-signal and pages; a fee is a quiet ledger update; a deposit goes to an income queue.
  • The directiondirection is debit (money leaving the account) or credit (money arriving). It is the broad bucket: every debit is a spending event, every credit is an income or refund event. Most teams split the data plane on this first.
  • The amount and currencyamount as a decimal (42.17), currency as an ISO 4217 code (USD, EUR, GBP, CAD, JPY). Sign does not live in the amount; the sign lives in direction. A debit of $42.17 is -42.17 in cashflow, not +42.17.
  • The accountaccount_mask (the last 4 of the card or account, ••••1234) plus account_type (checking, savings, credit_card, debit_card, line_of_credit, investment). account_mask is the dedup key for the cardholder’s accounts; combine it with posted_date to keep one row per real transaction across an authorization-then-purchase pair from the same merchant.
  • The merchantmerchant_name (Blue Bottle Coffee), merchant_location (San Francisco, CA), and merchant_category (the 4-digit MCC, 5814, when the bank includes it). The merchant is the cross-bank join: Chase prints “BLUE BOTTLE COFFEE”, Revolut prints “Blue Bottle Coffee #1234 SF”, and your tracker should land them on the same row.
  • The datestransaction_date (when the customer or merchant initiated the transaction) and posted_date (when the bank actually settled it). They are usually minutes apart for card purchases and a day or two apart for ACH transfers; keep both.
  • The running balancebalance is the account balance immediately after the transaction posted, when the bank includes it. It is a strong reconciliation signal: the next transaction’s balance minus its amount (with sign from direction) should equal the previous transaction’s balance, modulo any transactions that arrived between them.
  • The plumbingpayment_method (card, ach, wire, check, cash, transfer), card_brand (visa, mastercard, amex, discover, …), authorization_code, reference_id, alert_url. Most teams only need these on audit or exception paths, but the schema carries them so you can build the audit path without re-running the parser.

The fields worth extracting are roughly:

FieldWhy you want it
transaction_typeThe lane-decider: fraud_hold vs deposit vs fee vs purchase
directionThe broad bucket: debit (spending) vs credit (income)
amount + currencyThe money movement, sign from direction
balanceReconciliation signal; the chain is monotonic in posted_date order
account_mask + account_typeThe cardholder’s account, dedup key for a multi-account person
merchant_nameCross-bank join, the row key in the user’s transactions table
merchant_categoryMCC for budget categorization, when the bank includes it
posted_dateWhen it actually settled; the partition key for daily aggregations
transaction_dateWhen it was initiated; the user-facing timestamp
payment_method + card_brandAudit / reporting; only used on the exception path
authorization_code + reference_idDispute / chargeback workflow
alert_urlDeep link to the bank’s transaction detail page

A JSON Schema for the extraction

MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned.

Here is a compact schema for bank and card transaction alert emails. The enum constraints on transaction_type, direction, account_type, payment_method, and card_brand are not hints — they are hard constraints, so a value outside the list fails validation instead of silently landing in your ledger:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "bank_transaction",
  "type": "object",
  "required": [
    "transaction_type",
    "direction",
    "amount",
    "currency",
    "account_type"
  ],
  "properties": {
    "transaction_type": {
      "type": "string",
      "enum": [
        "purchase",
        "withdrawal",
        "deposit",
        "transfer",
        "payment",
        "refund",
        "fee",
        "interest",
        "chargeback",
        "fraud_hold",
        "authorization",
        "unknown"
      ]
    },
    "direction": {
      "type": "string",
      "enum": ["debit", "credit"]
    },
    "amount":      { "type": "number", "minimum": 0 },
    "currency":    { "type": "string", "minLength": 3, "maxLength": 3 },
    "balance":     { "type": "number" },
    "account_mask":{ "type": "string" },
    "account_type": {
      "type": "string",
      "enum": [
        "checking",
        "savings",
        "credit_card",
        "debit_card",
        "line_of_credit",
        "investment",
        "unknown"
      ]
    },
    "merchant_name":     { "type": "string" },
    "merchant_category": { "type": "string" },
    "merchant_location": { "type": "string" },
    "authorization_code":{ "type": "string" },
    "posted_date":       { "type": "string", "format": "date" },
    "transaction_date":  { "type": "string", "format": "date-time" },
    "payment_method": {
      "type": "string",
      "enum": ["card", "ach", "wire", "check", "cash", "transfer", "unknown"]
    },
    "card_brand": {
      "type": "string",
      "enum": [
        "visa",
        "mastercard",
        "amex",
        "discover",
        "diners",
        "jcb",
        "unionpay",
        "unknown"
      ]
    },
    "reference_id":  { "type": "string" },
    "notes":         { "type": "string" },
    "alert_url":     { "type": "string", "format": "uri" },
    "date":          { "type": "string", "format": "date" }
  }
}

This is the same schema MailFrame ships pre-built under the id bank_transaction, so you do not have to register it yourself — but copying it here is a good starting point if you want to tighten or extend it for your own routing logic (for example, require merchant_category to be a 4-digit string when you reconcile to MCC data, or restrict account_type to {"checking", "savings"} if you only care about cash flow on a personal bank account).

Send the raw email to /v1/parse

MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822 MIME text of the email — exactly the bytes you received — along with a schema, and you get typed JSON back in the same HTTP response.

You can reference the pre-built schema by id (schema_id) or pass an inline JSON Schema. Here it is with curl, using the built-in bank_transaction schema against a Chase credit-card purchase alert:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer ${MAILFRAME_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "bank_transaction",
    "raw_mime": "From: Chase Alerts <no-reply@alert.chase.com>\r\nTo: jordan.rivera@example.com\r\nSubject: A charge of $42.17 was made on your credit card\r\nDate: Thu, 03 Jul 2026 09:12:00 -0400\r\n\r\nA charge of $42.17 was made on your Chase credit card ending in 1234\r\non July 3, 2026 at Blue Bottle Coffee in San Francisco, CA.\r\n\r\nYour current available balance is $4,312.55.\r\nAuthorization code: 048221\r\n\r\nManage alerts: https://chase.com/online-banking/alert/1234"
  }'

The response wraps the extracted, schema-valid data in a data object alongside the parse status and any validation errors:

{
  "id": "parse_8a3f02",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "transaction_type": "purchase",
    "direction": "debit",
    "amount": 42.17,
    "currency": "USD",
    "balance": 4312.55,
    "account_mask": "••••1234",
    "account_type": "credit_card",
    "merchant_name": "Blue Bottle Coffee",
    "merchant_category": "",
    "merchant_location": "San Francisco, CA",
    "authorization_code": "048221",
    "posted_date": "2026-07-03",
    "transaction_date": "2026-07-03T09:12:00-04:00",
    "payment_method": "card",
    "card_brand": "visa",
    "alert_url": "https://chase.com/online-banking/alert/1234",
    "date": "2026-07-03"
  }
}

Validate, then route

Because the result is validated against your schema before it reaches you, your application code can stay small. Check validation_errors first, then branch on direction (broad split) and transaction_type (intent split):

interface ParseResult {
  id: string;
  status: "completed" | "failed";
  validation_errors: unknown[];
  data: BankTransaction;
}

interface BankTransaction {
  transaction_type:
    | "purchase"
    | "withdrawal"
    | "deposit"
    | "transfer"
    | "payment"
    | "refund"
    | "fee"
    | "interest"
    | "chargeback"
    | "fraud_hold"
    | "authorization"
    | "unknown";
  direction: "debit" | "credit";
  amount: number;
  currency: string;
  balance?: number;
  account_mask?: string;
  account_type:
    | "checking"
    | "savings"
    | "credit_card"
    | "debit_card"
    | "line_of_credit"
    | "investment"
    | "unknown";
  merchant_name?: string;
  merchant_category?: string;
  merchant_location?: string;
  authorization_code?: string;
  posted_date?: string;
  transaction_date?: string;
  payment_method?:
    | "card"
    | "ach"
    | "wire"
    | "check"
    | "cash"
    | "transfer"
    | "unknown";
  card_brand?:
    | "visa"
    | "mastercard"
    | "amex"
    | "discover"
    | "diners"
    | "jcb"
    | "unionpay"
    | "unknown";
  reference_id?: string;
  notes?: string;
  alert_url?: string;
  date?: string;
}

async function processBankTransactionEmail(rawMime: string): Promise<void> {
  const res = await fetch("https://api.mailframe.ai/v1/parse", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      schema_id: "bank_transaction",
      raw_mime: rawMime,
    }),
  });

  const result: ParseResult = await res.json();

  // Validation ran server-side against your schema — surface failures, don't guess.
  if (result.status !== "completed" || result.validation_errors.length > 0) {
    await flagForReview(result);
    return;
  }

  const r = result.data;

  // High-signal events first — these page or escalate.
  if (r.transaction_type === "fraud_hold" || r.transaction_type === "chargeback") {
    await pageOnFraud(r);
    return;
  }

  // Then broad direction split — debit is spending, credit is income / refund.
  if (r.direction === "credit") {
    if (r.transaction_type === "deposit") {
      await recordIncome(r);
    } else if (r.transaction_type === "refund" || r.transaction_type === "interest") {
      await recordIncome(r);
    }
    return;
  }

  // Debit — spending lane. Sub-route by type.
  switch (r.transaction_type) {
    case "fee":
      await recordFee(r);
      return;
    case "withdrawal":
      await recordCashWithdrawal(r);
      return;
    case "payment":
      await recordBillPayment(r);
      return;
    case "purchase":
    case "authorization":
    case "transfer":
    default:
      // Routine spending — append to the ledger; collapse authorization→purchase later.
      await recordSpend(r);
  }
}

A few practical notes:

  • Trust the types, not the strings. Because transaction_type, direction, account_type, payment_method, and card_brand are enum-constrained in the schema, a switch over them is exhaustive and safe — an unexpected value would have shown up in validation_errors rather than reaching this code.
  • Dedup on account_mask + posted_date + amount. A bank often sends two emails for the same transaction: an authorization when the merchant pre-auths, then a purchase when the actual settlement posts. Pairing on account_mask + posted_date + amount keeps one row per real movement, and lets you collapse the authorization into the eventual purchase rather than showing both.
  • Reconcile on balance. When the bank includes the running balance, the chain is monotonic in posted_date order: the next transaction’s balance should equal the previous balance minus the previous amount (with sign from direction), modulo any transactions that arrived between them. Use the chain as a self-audit before you write to the ledger.
  • Keep the raw MIME intact. Don’t pre-strip headers before sending — the sender domain, the Authorization line, and the bank’s own message ID are signal the extractor leans on to tell Chase from BofA from Revolut, and a fraud_hold from a purchase. Send the message as you received it.
  • Currency is a property of the alert, not the user. A travel-money cardholder gets EUR from Wise, USD from Chase, and GBP from Revolut; the currency field is per-transaction and should not be normalized away at the schema layer.

Where this fits in your pipeline

The example above POSTs from wherever you already hold the message — an IMAP poller on a customer’s inbox, an inbound-email webhook on a shared finance alias, a Lambda reading from S3, a no-code pipeline forwarding alerts to a single mailbox. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the trade-offs are laid out in How to Choose an Email Parsing API and the broader email-to-JSON API overview.

Bank and card transaction alerts are usually one lane of a broader finance-mail pipeline, so most teams wire this schema up alongside the other pre-built parsers — Parse Subscription Renewals flags recurring charges (which show up here as a purchase with the same merchant_name and amount every month), Parse Invoices handles B2B payment-due emails with PO numbers, and the Parse Stripe Receipts schema catches the merchant-side receipt. Browse the rest of the schema library for the full list of pre-built parsers.

For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with retries and delivery history) is available in early access — see Designing Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding a finance inbox to a unique MailFrame inbox address, instead of POSTing it yourself, is on the roadmap; until it ships, the direct POST shown here is the supported path.

The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the bank-transaction schema against your own transaction alerts, request early access and we’ll help you wire it up.

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