If you work with counterparties, suppliers or KYC data, you've probably got a spreadsheet column full of LEIs (Legal Entity Identifiers, the 20-character IDs every regulated company needs to trade). Some of them are typos, some belong to companies that closed, and some lapsed because nobody paid the yearly renewal. GLEIF, the foundation that runs the global LEI system, publishes all of this through a free JSON API with no key, so checking a whole list takes one short script.
1. Catch typos before calling any API
An LEI is 18 letters or digits followed by 2 check digits (ISO 17442). The check uses the same MOD 97-10 trick as IBANs: turn each letter into a number (A=10 up to Z=35), read the result as one big integer, and it must leave a remainder of 1 when divided by 97.
def checksum_ok(lei: str) -> bool:
digits = "".join(str(int(c, 36)) for c in lei)
return int(digits) % 97 == 1
int(c, 36) does the letter conversion for free, because base 36 maps 0-9 to themselves and A-Z to 10-35. This alone catches most copy-paste mistakes, like a swapped or changed last character.
2. Look up 200 LEIs per request
The lei-records endpoint accepts a comma-separated filter[lei], so you don't need one request per code:
GET https://api.gleif.org/api/v1/lei-records?filter[lei]=LEI1,LEI2,...&page[size]=200
Anything valid that doesn't come back simply doesn't exist in the register.
3. The full script
Put one LEI per line in leis.txt and run python3 lei_check.py leis.txt > result.csv. It only needs requests.
import csv, re, sys, time
import requests
API = "https://api.gleif.org/api/v1/lei-records"
LEI_RE = re.compile(r"^[A-Z0-9]{18}[0-9]{2}$")
def checksum_ok(lei: str) -> bool:
# ISO 17442 uses ISO 7064 MOD 97-10: letters become numbers (A=10 ... Z=35)
digits = "".join(str(int(c, 36)) for c in lei)
return int(digits) % 97 == 1
def lookup(leis):
"""Fetch up to 200 LEI records in one request."""
r = requests.get(API, params={"filter[lei]": ",".join(leis), "page[size]": 200}, timeout=30)
r.raise_for_status()
return {rec["id"]: rec["attributes"] for rec in r.json()["data"]}
def main(path):
raw = [l.strip().upper() for l in open(path) if l.strip()]
valid = [l for l in raw if LEI_RE.match(l) and checksum_ok(l)]
found = {}
for i in range(0, len(valid), 200):
found.update(lookup(valid[i:i + 200]))
time.sleep(1) # be polite to the free API
w = csv.writer(sys.stdout)
w.writerow(["lei", "status", "legal_name", "country", "entity_status", "registration_status", "next_renewal"])
for lei in raw:
if lei not in valid:
w.writerow([lei, "invalid_format_or_checksum", "", "", "", "", ""])
elif lei not in found:
w.writerow([lei, "not_found", "", "", "", "", ""])
else:
a = found[lei]
w.writerow([lei, "ok", a["entity"]["legalName"]["name"],
a["entity"]["legalAddress"]["country"], a["entity"]["status"],
a["registration"]["status"], (a["registration"]["nextRenewalDate"] or "")[:10]])
if __name__ == "__main__":
main(sys.argv[1])
Output for a small test file:
lei,status,legal_name,country,entity_status,registration_status,next_renewal
5493001KJTIIGC8Y1R12,ok,Bloomberg Finance L.P.,US,ACTIVE,ISSUED,2027-01-25
7LTWFZYICNSX8D621K86,ok,DEUTSCHE BANK AKTIENGESELLSCHAFT,DE,ACTIVE,ISSUED,2027-06-02
HWUPKR0MPOU8FGXBT394,ok,Apple Inc.,US,ACTIVE,ISSUED,2027-03-08
5493001KJTIIGC8Y1R13,invalid_format_or_checksum,,,,,
NOTANLEI,invalid_format_or_checksum,,,,,
4. Read the two status fields correctly
They mean different things, and mixing them up is the most common mistake:
-
entity.statusis about the company:ACTIVEorINACTIVE(merged, dissolved). -
registration.statusis about the LEI itself:ISSUEDis fine,LAPSEDmeans the renewal wasn't paid,RETIREDorANNULLEDmeans it shouldn't be used anymore.
A company can be perfectly active with a lapsed LEI, which is exactly the case your compliance team wants flagged. The next_renewal column tells you who is about to lapse.
5. Bonus: find an LEI from a company name
If you only have names, the same endpoint filters by legal name:
r = requests.get(API, params={"filter[entity.legalName]": "Siemens Aktiengesellschaft", "page[size]": 3}, timeout=30)
for d in r.json()["data"]:
print(d["id"], d["attributes"]["entity"]["legalName"]["name"], d["attributes"]["entity"]["legalAddress"]["country"])
This returns both the German parent and the Austrian subsidiary, so always check the country before you trust a name match.
Limits worth knowing
- The API is free but rate limited (around 60 requests per minute), so keep the small sleep between batches.
- GLEIF data is published under CC0, so you can store and reuse it.
- Parent and child relationships live under separate
relationshipslinks on each record, if you need group structures.
If you'd rather not run it yourself
Disclosure: I built and maintain a hosted version of this on Apify, GLEIF LEI Lookup. It does the same checks plus name search and exports to CSV or Google Sheets, and invalid or not-found codes are free. For a one-off list, the script above is all you need.
Top comments (0)