Skip to main content

Build a subscription tracker from raw card transactions in Python

Updated 6 min readMarkdown

Most people pay for more subscriptions than they remember. Finding them is a classic fintech feature, and it sounds easy until you look at the data. A bank export gives you lines like PAYPAL *NETFLIX 4029357733 and APPLE.COM/BILL, not "Netflix, 45 AED a month".

In this tutorial we will build a small Python script that:

  1. Reads a CSV of card transactions.
  2. Enriches each statement line into a clean merchant (name, category, website, subscription flag).
  3. Groups charges by merchant and detects which ones recur monthly.
  4. Prints a tidy subscription report.

We will use DescriptorAPI for step 2 (disclosure: I built it). The free tier covers 500 enrichments a month, which is plenty for a personal statement. The grouping and recurrence logic in steps 3 and 4 works with any enrichment source.

The input

Export a CSV from your bank or use this sample, transactions.csv:

date,description,amount
2026-08-03,PAYPAL *NETFLIX 4029357733,-45.00
2026-08-05,POS 4521 CAREEM*RIDE DXB AE,-32.50
2026-08-11,SPOTIFY P2B3C4D5E6 STOCKHOLM SE,-21.99
2026-09-03,PAYPAL *NETFLIX 4029357733,-45.00
2026-09-07,POS 7781 CAREEM*RIDE DXB AE,-27.00
2026-09-11,SPOTIFY P8K2M1N0Q9 STOCKHOLM SE,-21.99
2026-10-03,PAYPAL *NETFLIX 4029357733,-45.00
2026-10-11,SPOTIFY P5T7W3X1Z8 STOCKHOLM SE,-21.99
2026-10-14,شراء كارفور الرياض,-310.40

Notice two things that make this realistic. The Careem lines have different terminal numbers, and the Spotify lines have a different reference code every month. Grouping by the raw string would treat every Spotify charge as a different merchant.

Setup

pip install requests
export DESCRIPTORAPI_KEY="dk_live_..."

Step 1: deduplicate before you call

Statements repeat the same merchants over and over. Send each distinct line once and fan the answers back out. This saves quota and time.

import csv, os, requests
from collections import defaultdict
from datetime import date

API = "https://api.descriptorapi.com/v1/enrich/batch"
HEADERS = {
    "Authorization": f"Bearer {os.environ['DESCRIPTORAPI_KEY']}",
    "Content-Type": "application/json",
}

def load(path):
    with open(path, newline="", encoding="utf-8") as f:
        return [
            {"date": date.fromisoformat(r["date"]),
             "descriptor": r["description"].strip(),
             "amount": float(r["amount"])}
            for r in csv.DictReader(f)
        ]

Step 2: enrich in batches of 100

The batch endpoint takes up to 100 items and returns results in the same order, so we can zip them back to our inputs. An invalid item gets an error object in its place without failing the batch.

def enrich_all(descriptors, country="AE"):
    unique = list(dict.fromkeys(descriptors))  # dedupe, keep order
    results = {}
    for i in range(0, len(unique), 100):
        chunk = unique[i:i + 100]
        body = {"items": [{"descriptor": d, "country": country} for d in chunk]}
        resp = requests.post(API, json=body, headers=HEADERS, timeout=30)
        resp.raise_for_status()
        for d, r in zip(chunk, resp.json()["results"]):
            results[d] = r
    return results

Check the batch docs for the exact response envelope before you ship this; adjust resp.json()["results"] if your version differs.

Step 3: decide what to trust

Every result has a confidence between 0 and 1 and a source (cache, rules, fuzzy or ai). For a report a user will read, a simple rule works well: trust the merchant above 0.6, otherwise fall back to the normalized descriptor, which has the noise (terminal IDs, reference codes, phone numbers) already stripped.

We also skip anything whose kind is not a purchase, so refunds, transfers and bank fees do not pollute the report.

def merchant_key(r):
    if r.get("error"):
        return None, None
    if r.get("kind") != "purchase":
        return None, None
    m = r.get("merchant")
    if m and r.get("confidence", 0) >= 0.6:
        return m["name"], m.get("category")
    return r.get("normalized") or r["descriptor"], None

This is where enrichment pays off: SPOTIFY P2B3C4D5E6 STOCKHOLM SE and SPOTIFY P8K2M1N0Q9 STOCKHOLM SE both come back as Spotify, and PAYPAL *NETFLIX comes back as Netflix with PayPal as the processor.

Step 4: detect recurrence

The API returns an is_subscription flag for merchants known to bill on a subscription basis. That is a great signal, but a user's own history is the stronger one. We combine both: a merchant counts as a subscription if it is flagged, or if it was charged at least three times at a roughly monthly interval with a stable amount.

def is_monthly(charges):
    if len(charges) < 3:
        return False
    charges = sorted(charges, key=lambda c: c["date"])
    gaps = [(b["date"] - a["date"]).days for a, b in zip(charges, charges[1:])]
    amounts = [abs(c["amount"]) for c in charges]
    stable = max(amounts) - min(amounts) <= 0.1 * max(amounts)
    return all(25 <= g <= 35 for g in gaps) and stable

def report(rows):
    enriched = enrich_all([r["descriptor"] for r in rows])
    groups = defaultdict(list)
    flagged = set()
    meta = {}
    for row in rows:
        r = enriched[row["descriptor"]]
        name, category = merchant_key(r)
        if not name:
            continue
        groups[name].append(row)
        meta[name] = category
        if r.get("is_subscription"):
            flagged.add(name)

    print(f"{'Merchant':<20}{'Category':<26}{'Monthly':>10}")
    total = 0.0
    for name, charges in groups.items():
        if name in flagged or is_monthly(charges):
            monthly = abs(sorted(charges, key=lambda c: c["date"])[-1]["amount"])
            total += monthly
            print(f"{name:<20}{(meta[name] or '-'):<26}{monthly:>10.2f}")
    print(f"{'Total':<46}{total:>10.2f}")

if __name__ == "__main__":
    report(load("transactions.csv"))

Run it:

python subscriptions.py

You get one line per subscription (Netflix and Spotify in the sample) with its category and current monthly cost. Careem and Carrefour are left out because they are regular purchases, even though Careem appears more than once.

Things to improve before production

  • Cache results on your side keyed by the raw descriptor. Most of your users share the same merchants, so your hit rate climbs quickly.
  • Show AI answers differently. Results with source: "ai" and verified: false are model suggestions awaiting review. Let users correct them.
  • Annual plans. Extend is_monthly to also accept gaps of 360 to 370 days.
  • Price changes. The 10% stability check tolerates small changes; a jump bigger than that is worth surfacing as "your subscription got more expensive".
  • Mixed scripts. If you serve users in the Gulf or North Africa, expect Arabic descriptors. Render merchant names with dir="auto" in HTML so mixed-direction text displays correctly.

Wrap up

The hard part of a subscription tracker is not the date arithmetic, it is turning noisy statement lines into stable merchant identities. Once you have that, recurrence detection is a few lines of Python.

The full script is above. You can try any statement line in the DescriptorAPI playground without an API key, and if you find a descriptor it gets wrong, I would genuinely like to see it.

No comments yet. Be the first to comment.