Written up in October 2026. Dates are when I actually built it.
Airline rewards activity isn’t a log, it’s a table that quietly edits itself. Points post late, get adjusted, sometimes vanish. I wanted a ping the moment my own account’s history changed, so I built a watcher: about 900 lines of Python with 60 unit tests. It only ever reads my own account.
Don’t hash the columns, hash the meaning
Each activity row gets a SHA-256 over (date, description, amount). Not over the raw columns, and that distinction cost me a design pass. The data can arrive two ways (the CSV download or the fallback scrape of the page table) and those name and order columns differently. Hash the raw row and the same transaction hashes differently depending on which path fired, which means fake “changes” and archive churn the moment the fallback kicks in. Hash the semantic identity and both paths agree.
The diff is a multiset diff
Two identical transactions on the same day are legitimate, so I diff with Counters of hashes, not sets. Added rows are in new but not old, removed are the reverse. Then the interesting bit: if a removed row and an added row share an identity key, that isn’t “one deleted, one added”, it’s one row that was modified, and I report it that way.
The site only shows roughly four months of history, so rows that merely scroll out of view would look like deletions. I treat a removal as “aged out” only if it’s older than a 110-day cutoff and older than the oldest row still visible. Anything else removed is real and fires an alert.
State lives in SQLite
A single-row snapshot table (CHECK (id = 1)), a permanent archive keyed by row hash with first_seen and last_seen, a run log, and a flags table. The archive means that even after a row disappears from the site, I still know what it said.
Alerts are at-least-once
The order of operations is the clever-ish part: fetch, diff, send the alert, then persist state. If sending fails, I deliberately don’t save, so the next run re-diffs against the old state and tries again. Better a duplicate alert than a missed one.
Failure alerts are rate-limited with flags: one “session expired” alert until it recovers, one failure alert after 3 consecutive failures, and a “recovered” message when things work again.
The hard part: MFA on a headless box
The server can’t click through an MFA login. So I log in on my laptop in a real browser, export Playwright’s storage_state (cookies plus localStorage), copy it to the server, and let frequent runs keep the session alive. Data comes from the site’s own JSON API, captured by listening to the page’s network responses rather than parsing a virtualized DOM. Before deep links, I “warm” the session through the homepage, because the edge bot protection wants a normal-looking first request.