DEV Community

VASP Tracker
VASP Tracker

Posted on

Parsing ESMA's MiCA CASP Register in Python: Six Data Gotchas (and a Deadline Calendar to Go With It)

Since the last MiCA transitional periods ended on 1 July 2026, "is this firm authorised in the EU?" has a single first place to look: ESMA's interim MiCA register. It isn't a database with an API. It's a set of CSV files that ESMA re-publishes on its MiCA page, weekly according to ESMA. That makes it easy to download and surprisingly easy to misread.

This post walks through a small, dependency-free Python script that:

  1. pulls a machine-readable calendar of upcoming crypto licensing deadlines, and
  2. loads ESMA's authorised-CASP file and cleans up the quirks I hit doing it.

Everything below was run on 22 October 2026 against the file ESMA lists as last updated on 19 October 2026. Your counts will differ when ESMA refreshes the file. That's the point of scripting it.

Step 1: A Deadline Calendar as Data

At VASP Tracker we publish our jurisdiction and deadline data as open data (CC BY 4.0). The JSON file has two arrays: jurisdictions (one row per jurisdiction, with its regulator, status and official register URL) and events (one row per dated event).

The fields you'll care about in events:

field meaning
date ISO date of the event
event_type e.g. application_deadline, transition_end, ban, regime_start, in_force
key_date true for the dates that change what firms may do
our_calculation true when the date isn't printed in the source but derived from it (e.g. "one year from commencement")
source_urls pipe-separated links to the law, gazette or regulator notice

The our_calculation flag matters. If you build alerts on these dates, show calculated dates differently from dates the regulator actually printed.

Step 2: The Script

Standard library only, Python 3.9+.

import csv, io, json, urllib.request
from collections import Counter
from datetime import date, datetime

def get(url: str) -> bytes:
    req = urllib.request.Request(url, headers={"User-Agent": "licence-deadline-check/0.1"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return r.read()

# --- 1. The deadline calendar -------------------------------------------
cal = json.loads(get("https://vasptracker.com/data/global/deadlines.json"))
today = date.today().isoformat()

upcoming = sorted(
    (e for e in cal["events"] if e["key_date"] and e["date"] >= today),
    key=lambda e: e["date"],
)
for e in upcoming:
    calc = "  (calculated)" if e["our_calculation"] else ""
    print(f'{e["date"]}  {e["jurisdiction"]:<15} {e["event_type"]}{calc}')
    print(f'            source: {e["source_urls"].split(" | ")[0]}')

print(Counter(j["register_format"] for j in cal["jurisdictions"]))

# --- 2. ESMA's interim MiCA register (authorised CASPs) ------------------
raw = get("https://www.esma.europa.eu/sites/default/files/2024-12/CASPS.csv")
rows = list(csv.DictReader(io.StringIO(raw.decode("utf-8-sig"))))  # strip the BOM

def d(s: str):
    """ESMA dates are dd/mm/yyyy. A few cells hold values like '06-May'
    (no year), so return None instead of guessing."""
    s = (s or "").strip()
    try:
        return datetime.strptime(s, "%d/%m/%Y").date()
    except ValueError:
        return None

casps = []
for r in rows:
    r = {k: (v or "").strip() for k, v in r.items()}           # 'NL ' -> 'NL'
    r["services"] = [x.strip() for x in r["ac_serviceCode"].split("|") if x.strip()]
    r["passports"] = [x.strip() for x in r["ac_serviceCode_cou"].split("|") if x.strip()]
    r["authorised_on"] = d(r["ac_authorisationNotificationDate"])
    r["ended_on"] = d(r["ac_authorisationEndDate"])
    casps.append(r)

active = [r for r in casps if not r["ended_on"] or r["ended_on"] > date.today()]
print(len(rows), "rows,", len({r["ae_lei"] for r in casps}), "distinct LEIs,", len(active), "without a past end date")
print(Counter(r["ae_homeMemberState"] for r in active).most_common(5))
print("latest record update:", max(d(r["ac_lastupdate"]) for r in casps if r["ac_lastupdate"]))
print("unparseable authorisation dates:",
      sum(1 for r in casps if r["ac_authorisationNotificationDate"] and not r["authorised_on"]))
print(Counter(r["ac_legal_basis"] for r in casps))
Enter fullscreen mode Exit fullscreen mode

Output (22 Oct 2026)

The calendar part prints the next key dates with their primary source:

2026-10-30  Brazil          application_deadline
2026-11-04  Kenya           transition_end  (calculated)
2026-11-06  Brazil          ban
2027-01-18  United States   effective_date
2027-02-28  United Kingdom  application_deadline
2027-04-09  Australia       in_force
2027-07-01  Russia          transition_end
2027-07-22  Japan           effective_date  (calculated)
2027-10-25  United Kingdom  regime_start
Enter fullscreen mode Exit fullscreen mode

(Source lines omitted here for space.) The register_format counter shows why "just scrape the registers" is harder than it sounds. Across the 76 jurisdictions in the file, 31 official registers are CSV, 26 are HTML pages, 7 are PDFs, one is CSV plus an API (Brazil's Central Bank), one is HTML plus an API (the UK FCA), and 10 have no register we could find.

The ESMA part printed:

379 rows, 376 distinct LEIs, 377 without a past end date
[('DE', 109), ('FR', 36), ('NL', 28), ('CY', 24), ('MT', 23)]
latest record update: 2026-10-15
unparseable authorisation dates: 10
Counter({'': 180, 'Article 60 - Notification': 117, 'Article 63 - CASP authorisation': 66, 'Article 63': 10, 'Article 60': 6})
Enter fullscreen mode Exit fullscreen mode

The Six Gotchas

1. There's a UTF-8 BOM. The file starts with EF BB BF. Decode with utf-8-sig, or your first column will be called \ufeffae_competentAuthority and every row["ae_competentAuthority"] lookup will raise KeyError.

2. Values have trailing whitespace. All 29 Dutch rows have ae_homeMemberState set to "NL ", not "NL". Group by country without stripping and the Netherlands quietly splits in two. Strip every cell.

3. Dates are dd/mm/yyyy strings, and a few aren't dates at all. Calling max() on the raw ac_lastupdate strings returned 31/08/2026. The real latest update after parsing was 15 October 2026. Ten ac_authorisationNotificationDate cells hold values like 06-May, with no year (it looks like a spreadsheet auto-format). Parse explicitly and treat failures as unknown, not as today.

4. One row is not one firm. 379 rows, 376 distinct LEIs. The duplicates I found are firms with more than one entry (separate notifications with different dates and services). If you want "number of firms", count distinct ae_lei. If you want "number of authorisation events", count rows. Decide which one you mean.

5. Multi-value fields use inconsistent separators. ac_serviceCode lists the MiCA services (a. custody … j. transfer services). 205 rows separate them with " | " and 64 with a bare "|". Split on | and strip, never on " | ". ac_serviceCode_cou (the passporting countries) uses | too.

6. The legal-basis column is half empty and not normalised. 180 rows have no ac_legal_basis. The rest mix Article 63 - CASP authorisation with Article 63, and Article 60 - Notification with Article 60. The difference is real: Article 63 is the standard CASP authorisation, while Article 60 lets certain already-regulated financial entities (credit institutions and investment firms, for example) provide crypto-asset services after notifying their regulator. Normalise on the article number, and keep "unknown" as its own bucket rather than guessing.

Bonus: ESMA says withdrawn authorisations stay in the file, with the date the withdrawal took effect. Only 2 rows had an ac_authorisationEndDate when I ran this. Filter on it anyway, or you'll count a withdrawn firm as live.

What This Doesn't Tell You

  • The ESMA file is interim and second-hand. ESMA compiles it from what national competent authorities send. ESMA itself warns that information already in a national register may not show up immediately. For a decision that matters, check the national register of the home member state too.
  • Absence isn't proof of anything. A missing firm may be authorised under another legal entity name (always search by LEI and by legal name, not by brand), or the record may simply not have arrived yet.
  • Calculated dates are calculations. Treat our_calculation: true events as "derived from the source", and link users to the source.

If you'd rather not maintain the cleaning code yourself, our EU open-data page has a dated snapshot of ESMA's CASP file (selected columns) plus a per-firm table with ISO dates, LEIs, service codes and passporting states, and per-member-state transition data, under the same CC BY 4.0 licence. Corrections are welcome at press@vasptracker.com.


VASP Tracker is an independent project and is not affiliated with ESMA or any national regulator. Nothing here is legal advice. Always confirm a firm's status with the competent authority.

Sources

Top comments (0)