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 type —
transaction_typeis the single most useful field.purchase,withdrawal,deposit,transfer,payment,refund,fee,interest,chargeback,fraud_hold,authorization. This is what decides the lane: afraud_holdis high-signal and pages; afeeis a quiet ledger update; adepositgoes to an income queue. - The direction —
directionisdebit(money leaving the account) orcredit(money arriving). It is the broad bucket: everydebitis a spending event, everycreditis an income or refund event. Most teams split the data plane on this first. - The amount and currency —
amountas a decimal (42.17),currencyas an ISO 4217 code (USD,EUR,GBP,CAD,JPY). Sign does not live in the amount; the sign lives indirection. Adebitof $42.17 is-42.17in cashflow, not+42.17. - The account —
account_mask(the last 4 of the card or account,••••1234) plusaccount_type(checking,savings,credit_card,debit_card,line_of_credit,investment).account_maskis the dedup key for the cardholder’s accounts; combine it withposted_dateto keep one row per real transaction across anauthorization-then-purchasepair from the same merchant. - The merchant —
merchant_name(Blue Bottle Coffee),merchant_location(San Francisco, CA), andmerchant_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 dates —
transaction_date(when the customer or merchant initiated the transaction) andposted_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 balance —
balanceis the account balance immediately after the transaction posted, when the bank includes it. It is a strong reconciliation signal: the next transaction’sbalanceminus itsamount(with sign fromdirection) should equal the previous transaction’sbalance, modulo any transactions that arrived between them. - The plumbing —
payment_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:
| Field | Why you want it |
|---|---|
transaction_type | The lane-decider: fraud_hold vs deposit vs fee vs purchase |
direction | The broad bucket: debit (spending) vs credit (income) |
amount + currency | The money movement, sign from direction |
balance | Reconciliation signal; the chain is monotonic in posted_date order |
account_mask + account_type | The cardholder’s account, dedup key for a multi-account person |
merchant_name | Cross-bank join, the row key in the user’s transactions table |
merchant_category | MCC for budget categorization, when the bank includes it |
posted_date | When it actually settled; the partition key for daily aggregations |
transaction_date | When it was initiated; the user-facing timestamp |
payment_method + card_brand | Audit / reporting; only used on the exception path |
authorization_code + reference_id | Dispute / chargeback workflow |
alert_url | Deep 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, andcard_brandare enum-constrained in the schema, aswitchover them is exhaustive and safe — an unexpected value would have shown up invalidation_errorsrather than reaching this code. - Dedup on
account_mask+posted_date+amount. A bank often sends two emails for the same transaction: anauthorizationwhen the merchant pre-auths, then apurchasewhen the actual settlement posts. Pairing onaccount_mask+posted_date+amountkeeps one row per real movement, and lets you collapse theauthorizationinto the eventualpurchaserather than showing both. - Reconcile on
balance. When the bank includes the running balance, the chain is monotonic inposted_dateorder: the next transaction’sbalanceshould equal the previousbalanceminus the previousamount(with sign fromdirection), 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
Authorizationline, and the bank’s own message ID are signal the extractor leans on to tell Chase from BofA from Revolut, and afraud_holdfrom apurchase. Send the message as you received it. - Currency is a property of the alert, not the user. A travel-money cardholder gets
EURfrom Wise,USDfrom Chase, andGBPfrom Revolut; thecurrencyfield 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.