Nicolás Duque

Reading bank notification emails to track spending in Colombia

Aug 5, 2026 · 3 min read

Nora is a personal-finance app I'm building for Colombia. The first way to get data into it is uploading a bank statement, which I described in Rules first, LLM second. But statements arrive once a month, and people want to see their spending as it happens.

In other countries you'd connect to the bank through an API. Colombian banks don't expose APIs that a regular developer can use. They do, however, send an email for almost every purchase and transfer. So Nora reads those.

How it works for the user

Each user gets a unique inbound email address from Nora. The idea is that bank alerts get forwarded to that address, so transactions show up in the app without uploading anything. Users can turn capture on or off per address and see the most recent emails Nora received.

The address is a short random hash, so it can't be guessed from the user's name or ID.

Step 1: accept the email fast

Inbound emails arrive through a webhook from the email provider. The webhook handler does very little:

  1. Finds the destination address, from the email envelope or the "to" field.
  2. Looks up which user owns it. Unknown addresses are discarded silently, so the endpoint doesn't reveal which addresses exist. Inactive addresses are discarded too.
  3. Saves the raw email with status RECEIVED.
  4. Emits an email.received event and returns.

The endpoint is rate-limited, and it always answers "OK", even when something fails inside. Otherwise the provider would keep retrying an email that will never parse, and a bug in a parser would turn into a flood of repeated requests. Errors are logged instead.

Step 2: parse in the background

A listener picks up the email.received event and does the real work, so parsing time never slows down the webhook.

The email moves through clear states: RECEIVED, then PROCESSING, then PARSED or FAILED. A failed email stores the reason, like "no parser for this sender" or "couldn't identify the transaction type". That makes problems easy to find: you look at the failed emails and their messages.

One parser per bank

Each bank gets its own parser, currently Bancolombia, Nequi and Nu Colombia. They all implement the same small interface:

interface BankEmailParser {
  canParse(fromAddress: string, subject: string, body: string): boolean;
  parse(fromAddress: string, subject: string, body: string): EmailParseResult;
}

A factory walks an ordered list of parsers and uses the first one that says it can handle the email, with the most specific ones first. Adding a bank means adding a parser to that list.

Two rules make the parsers easy to live with:

  • They never throw. A parser returns success: false with an error message instead. One strange email can't break the pipeline.
  • They return plain data: date, amount in pesos, description, income or expense, and the merchant when it's available.

The Bancolombia parser, for example, recognizes the common alert shapes (purchases, transfers received, transfers sent and ATM withdrawals) and tries them in order. The details are very Colombian: amounts like $1.234.567 use dots as thousands separators and pesos have no decimals, and merchant names usually appear in capital letters, which helps pull them out of the sentence.

Step 3: save the transactions

When parsing works, Nora finds the user's account for that bank, or creates one (a digital wallet for Nequi and Nu, a checking account otherwise), and saves each transaction linked to the email it came from. A unique constraint skips duplicates. Each new transaction emits an event for the AI layer, and the email is marked PARSED with the number of transactions it created.

Why no LLM here

Bank alerts are short, repetitive and predictable. Regular expressions per bank are cheap, fast, easy to test and easy to explain when something fails. An LLM would add cost and uncertainty to a problem that doesn't need it. I'd rather use AI where rules run out, like categorizing transactions nobody has seen before.

What this design gets right

  • The webhook is fast and doesn't depend on parsing.
  • Every email has a visible state and a reason when it fails.
  • Banks are isolated from each other behind one interface.
  • A bad email fails alone, without taking anything else down.

It's not glamorous, but for a country without open banking APIs, the inbox is the most reliable real-time data source a personal-finance app can get.