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:
- Reads a CSV of card transactions.
- Enriches each statement line into a clean merchant (name, category, website, subscription flag).
- Groups charges by merchant and detects which ones recur monthly.
- 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"andverified: falseare model suggestions awaiting review. Let users correct them. - Annual plans. Extend
is_monthlyto 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.
Comments
Join the discussionNo comments yet. Be the first to comment.