Counterparty screening¶
This guide screens a Russian counterparty against sanctions lists, pulls its registry card, and keeps an audit record of every call. The order matters: INN first, name second. An INN is an exact identifier; a name is a fuzzy match that produces candidates.
Screening aid, not a determination
The API finds candidates on the EU, OFAC SDN, UK, UN and US CSL lists and flags near-identical names. A candidate is not a confirmed hit, and an empty result is not a clearance: it means no match on the lists loaded, as of the dates in meta.sources. Compliance decisions need a human review of the original list entries and your own policy.
The flow¶
- INN, if you have it.
GET /v1/sanctions/check?inn=7708503727matches the INN exactly against entries that carry an INN. Anyinn_hitsrow means a listed entry has this identifier. - Registry card.
GET /v1/companies/7708503727returns the registry fields, bands, structure, address and the API's own sanctions screening (INN and name) in one object. A404 not_foundmeans the INN is not in the registry copy; it does not say anything about the lists. - Name.
GET /v1/sanctions/check?name=...with the registry name (or the name you were given). Names are normalised (Cyrillic to Latin, legal forms like ООО, LLC, A.Ş. removed) and compared by trigram similarity; the response shows the normalised form so you can see what was compared. - No INN, only a name.
GET /v1/companies/search?query=...lists registry candidates withsanctioned_innandsanction_name_hitsflags. Pick the right company, take its INN, and run the flow again from step 1. - Record. Every response has
meta.request_id(also theX-Request-Idheader) andmeta.sourceswith theas_ofof each list. The script writes them to a JSON Lines file so you can later show exactly what was asked, when, and against which list dates.
Endpoint details: sanctions and restrictions, companies.
Reading a hit¶
| What you see | What it means | What to do |
|---|---|---|
inn_hits has a row |
A listed entry carries the same INN | Treat as a match until a person has reviewed the entry (program, countries, listing date, active) |
| Name hit, similarity 1.0, listed INN differs or is empty | The normalised names are equal. Many companies share names, and many list entries have no INN | Compare country, address, program and any identifiers in the original list entry |
| Name hit 0.8 to 0.95 | Close name: abbreviations, transliteration variants, or a related entity | Review only if the business context fits (same sector, group, region) |
| Name hit below 0.8 | Weak similarity, usually noise. The script counts these separately | Skim; raise --min-similarity to hide them |
active: false |
Delisted entry. Hidden unless you pass only_active=false |
Not a current listing, but may matter for history |
| No candidates | Nothing matched on the lists loaded | Note the list dates; as_of of a list is null when nothing matched, so read freshness from /v1/sources/freshness if you need it |
sanctioned_inn: true in a company search row |
An active list entry has this company's INN | Same as an INN hit |
Lists differ in what they publish: OFAC and EU entries often have no INN, so a name match may be the only signal. A name match whose listed INN differs from yours usually points to a different entity; one with no listed INN needs the original entry checked.
Script¶
Needs Python 3 and requests. Put your key in TRRU_KEY.
#!/usr/bin/env python3
"""Counterparty screening with an audit trail.
Usage:
TRRU_KEY=... python counterparty_check.py --inn 7708503727
TRRU_KEY=... python counterparty_check.py --name "Gazprombank" --audit audit.jsonl
TRRU_KEY=... python counterparty_check.py --inn 7707083893 --name "Sberbank"
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
import time
import requests
BASE = "https://trru.atapinar.com"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['TRRU_KEY']}"
AUDIT_PATH = None
class ApiError(RuntimeError):
def __init__(self, status, code, message, request_id):
super().__init__(f"HTTP {status} {code}: {message} (request id {request_id})")
self.status, self.code, self.request_id = status, code, request_id
def audit(path, params, status, request_id, meta=None, note=""):
"""Append one JSON line per API call. The key is never written."""
if not AUDIT_PATH:
return
row = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "path": path, "params": params,
"status": status, "request_id": request_id, "note": note,
"sources": [{"source_id": s["source_id"], "as_of": s["as_of"], "fetched_at": s["fetched_at"]}
for s in (meta or {}).get("sources", [])]}
with open(AUDIT_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def get(path, **params):
"""GET with retry on 429/502/503/504. Returns (data, meta). Raises ApiError; every attempt that ends is audited."""
for attempt in range(4):
r = SESSION.get(BASE + path, params=params, timeout=60)
if r.status_code in (429, 502, 503, 504) and attempt < 3:
time.sleep(min(int(r.headers.get("Retry-After", 2 ** attempt)), 30))
continue
break
rid = r.headers.get("X-Request-Id")
body = r.json()
if r.status_code != 200:
err = body.get("error", {})
audit(path, params, r.status_code, rid, note=f"{err.get('code')}: {err.get('message')}")
raise ApiError(r.status_code, err.get("code"), err.get("message"), rid)
audit(path, params, 200, body["meta"]["request_id"], body["meta"])
return body["data"], body["meta"]
def classify(hit, query_inn):
"""Plain-language reading of one sanctions candidate."""
sim = hit.get("similarity")
if query_inn and hit.get("inn") == query_inn:
return "INN match: the listed entry carries the same INN"
if sim is None:
return "INN match"
if sim >= 0.95:
return "name matches after normalization; confirm country, INN and address before concluding"
if sim >= 0.8:
return "close name; likely a different or related entity unless identifiers agree"
return "weak name similarity; usually noise"
def hit_rows(hits, query_inn):
rows = []
for h in hits:
rows.append(f"| {h['list']} | {h['name']} | {h['entity_type']} | {h['program']} | {', '.join(h['countries'] or [])} | "
f"{h['inn'] or ''} | {h['listed_on'] or ''} | {h['as_of']} | {h.get('similarity', '')} | {classify(h, query_inn)} |")
return rows
HIT_HEADER = ["| List | Listed name | Type | Program | Countries | Listed INN | Listed on | List as_of | Similarity | Reading |",
"|---|---|---|---|---|---|---|---|---|---|"]
SEEN = set() # (list, entry_id) already reported in this run, so the INN step and the name step do not double count
def screen(name="", inn="", min_similarity=0.6, known_inn=""):
"""One /v1/sanctions/check call; returns (lines, strong, weak). Strong = INN match or name similarity >= 0.8."""
params = {"min_similarity": min_similarity, "limit": 15}
if inn:
params["inn"] = inn
if name:
params["name"] = name
data, meta = get("/v1/sanctions/check", **params)
lines = [f"Query: name={name!r}, inn={inn!r}; normalized name: {data['query']['normalized']!r}; request id {meta['request_id']}", ""]
inn_keys = {(h["list"], h["entry_id"]) for h in data["inn_hits"]}
name_hits = [h for h in data["name_hits"] if (h["list"], h["entry_id"]) not in inn_keys] # already shown as INN hits
strong = weak = 0
for h in data["inn_hits"] + name_hits:
key = (h["list"], h["entry_id"])
if key not in SEEN:
SEEN.add(key)
if h.get("similarity") is None or h["similarity"] >= 0.8:
strong += 1
else:
weak += 1
if data["inn_hits"] or name_hits:
lines += HIT_HEADER
lines += hit_rows(data["inn_hits"], known_inn or inn) + hit_rows(name_hits, known_inn or inn)
else:
lines.append("No candidates on the screened lists.")
lines += ["", "Lists screened: " + ", ".join(f"{s['source_id']} (as_of {s['as_of'] or 'n/a'}, fetched {s['fetched_at'][:16]})"
for s in meta["sources"])]
return lines, strong, weak
def card(inn):
"""Registry card for an INN. Returns (lines, company dict or None)."""
try:
data, meta = get(f"/v1/companies/{inn}")
except ApiError as e:
if e.status == 404:
return [f"INN {inn} is not in the registry copy (request id {e.request_id})."], None
raise
c = data["company"]
geo = data["geo"][0]["address"] if data["geo"] else "n/a"
reg = next((s for s in meta["sources"] if s["source_id"] == c["source_id"]), {})
lines = [f"| Field | Value |", "|---|---|",
f"| Name | {c['name_ru']} |", f"| INN / OGRN | {c['inn']} / {c['ogrn']} |", f"| OKVED | {c['okved']} |",
f"| Region | {c['region']} |", f"| Address | {geo} |", f"| Status | {c['status']} |",
f"| Size / revenue band | {c['size_band']} / {c['revenue_band']} |",
f"| Parent/child links | {len(data['structure'])} |",
f"| Registry as_of | {reg.get('as_of') or c['as_of']} |", f"| Request id | {meta['request_id']} |"]
return lines, c
def main():
global AUDIT_PATH
ap = argparse.ArgumentParser()
ap.add_argument("--inn", default="")
ap.add_argument("--name", default="")
ap.add_argument("--min-similarity", type=float, default=0.6)
ap.add_argument("--audit", default="", help="append a JSON line per API call to this file")
a = ap.parse_args()
if not (a.inn or a.name):
ap.error("give --inn and/or --name")
if a.inn and not re.fullmatch(r"\d{10}|\d{12}", a.inn):
ap.error("an INN has 10 digits (legal entity) or 12 digits (individual)")
AUDIT_PATH = a.audit or None
out = [f"# Counterparty screening, {dt.datetime.now(dt.timezone.utc):%Y-%m-%d %H:%M} UTC", ""]
strong = weak = 0
company = None
if a.inn: # 1) identifier first: exact and cheap
out += [f"## 1. Sanctions screening by INN {a.inn}", ""]
lines, s1, w1 = screen(inn=a.inn, min_similarity=a.min_similarity)
out += lines + [""]
strong, weak = strong + s1, weak + w1
out += [f"## 2. Registry card", ""]
lines, company = card(a.inn)
out += lines + [""]
name = a.name or (company or {}).get("name_ru") or ""
if name: # 2) then the name (the one given, or the registry name of the INN)
out += [f"## {3 if a.inn else 1}. Sanctions screening by name {name!r}", ""]
lines, s1, w1 = screen(name=name, min_similarity=a.min_similarity, known_inn=a.inn)
out += lines + [""]
strong, weak = strong + s1, weak + w1
if name and not a.inn: # name only: show registry candidates so the user can pick an INN and re-run by INN
found, meta = get("/v1/companies/search", query=name, only_active="true", limit=5)
out += ["## 2. Registry candidates (re-run with --inn to screen one of them by identifier)", ""]
if found["rows"]:
out += ["| INN | Name | Region | OKVED | Name score | INN on a list | Near-identical listed names |", "|---|---|---|---|---|---|---|"]
for c in found["rows"]:
out.append(f"| {c['inn'] or 'n/a'} | {c['name_ru'] or c['name_en']} | {c['region'] or 'n/a'} | {c['okved'] or 'n/a'} | {c['name_score']} | "
f"{'yes' if c['sanctioned_inn'] else 'no'} | {', '.join(h['list'] + ': ' + h['name'] for h in c['sanction_name_hits']) or '-'} |")
else:
out.append("No registry rows matched this name.")
out += ["", f"Request id {meta['request_id']}", ""]
if strong or weak:
out.append(f"**Result: {strong} strong candidate(s) (INN match or name similarity >= 0.8) and {weak} weak one(s) to review.**")
else:
out.append("**Result: no sanctions candidates found.**")
if AUDIT_PATH:
out.append(f"Audit trail appended to `{AUDIT_PATH}`.")
print("\n".join(out))
return 3 if strong else 0 # exit 3 = candidates to review (1 = error), so a calling script can branch
if __name__ == "__main__":
try:
sys.exit(main())
except (ApiError, requests.RequestException) as e:
sys.exit(f"error: {e}")
export TRRU_KEY=trru_YOUR_KEY
python counterparty_check.py --inn 7708503727 --audit audit.jsonl
python counterparty_check.py --name "Ромашка" --audit audit.jsonl
python counterparty_check.py --inn 7707083893 --name "Sberbank"
Exit code: 0 no strong candidate, 3 at least one strong candidate (INN match or name similarity of 0.8 or more), 1 API or network error, 2 bad arguments. A calling script can branch on 3 to open a review.
Output: screening by INN¶
# Counterparty screening, 2026-11-13 19:47 UTC
## 1. Sanctions screening by INN 7708503727
Query: name='', inn='7708503727'; normalized name: ''; request id 49615e502407484ebfd10874ed9687d3
| List | Listed name | Type | Program | Countries | Listed INN | Listed on | List as_of | Similarity | Reading |
|---|---|---|---|---|---|---|---|---|---|
| CSL | OPEN JOINT STOCK COMPANY RUSSIAN RAILWAYS | entity | Non: RUSSIA-EO14024 | RU | 7708503727 | | 2026-11-13 | | INN match: the listed entry carries the same INN |
Lists screened: us_csl (as_of 2026-11-13, fetched 2026-11-13T06:17)
## 2. Registry card
| Field | Value |
|---|---|
| Name | ОАО "РЖД" |
| INN / OGRN | 7708503727 / 1037739877295 |
| OKVED | 49.20.9 |
| Region | 77 |
| Address | г Москва, ул Новая Басманная, д 2/1 стр 1 |
| Status | active |
| Size / revenue band | large / gt_10b |
| Parent/child links | 0 |
| Registry as_of | 2026-10-22 |
| Request id | ab9651e945974eedbd823d64bd8ec337 |
## 3. Sanctions screening by name 'ОАО "РЖД"'
Query: name='ОАО "РЖД"', inn=''; normalized name: 'rzhd'; request id 3306ba3b1eba4df79b869fc491825acb
| List | Listed name | Type | Program | Countries | Listed INN | Listed on | List as_of | Similarity | Reading |
|---|---|---|---|---|---|---|---|---|---|
| CSL | OPEN JOINT STOCK COMPANY RUSSIAN RAILWAYS | entity | Non: RUSSIA-EO14024 | RU | 7708503727 | | 2026-11-13 | 1.0 | INN match: the listed entry carries the same INN |
Lists screened: us_csl (as_of 2026-11-13, fetched 2026-11-13T06:17)
**Result: 1 strong candidate(s) (INN match or name similarity >= 0.8) and 0 weak one(s) to review.**
Audit trail appended to `out/audit_demo.jsonl`.
The INN 7708503727 has one INN hit (a US CSL entry). The same entry shows up again in the name step with similarity 1.0; the script counts it once.
Output: name only¶
# Counterparty screening, 2026-11-13 19:47 UTC
## 1. Sanctions screening by name 'Ромашка'
Query: name='Ромашка', inn=''; normalized name: 'romashka'; request id bfcf3ee999124813a02205826973b438
No candidates on the screened lists.
Lists screened: eu_fsf (as_of n/a, fetched 2026-11-13T06:15), ofac_sdn (as_of n/a, fetched 2026-11-13T06:16), uk_fcdo (as_of n/a, fetched 2026-11-13T06:16), un_sc (as_of n/a, fetched 2026-11-13T06:16), us_csl (as_of n/a, fetched 2026-11-13T06:17)
## 2. Registry candidates (re-run with --inn to screen one of them by identifier)
| INN | Name | Region | OKVED | Name score | INN on a list | Near-identical listed names |
|---|---|---|---|---|---|---|
| 0231009032 | ООО "РОМАШКА" | 02 Башкортостан респ | 47.11 | 1.0 | no | - |
| 0249995121 | ООО"РОМАШКА" | 02 Башкортостан респ | 47.11 | 1.0 | no | - |
| 0260005593 | ООО "РОМАШКА" | 02 Башкортостан респ | 47.25.1 | 1.0 | no | - |
| 0269048087 | ООО "РОМАШКА+" | 02 Башкортостан респ | 47.11 | 1.0 | no | - |
| 0900004350 | ООО "РОМАШКА" | 09 Карачаево-Черкесская респ | 55.10 | 1.0 | no | - |
Request id 3c2c01df33914ba79fe2b05278f92c29
**Result: no sanctions candidates found.**
Audit trail appended to `out/audit_demo.jsonl`.
A common name returns many registry companies with the same score. Pick the right one (region, OKVED, address in the company card) and re-run with --inn.
Audit file¶
With --audit, each API call appends one line. The key is never written.
{"ts": "2026-11-13T19:47:09+00:00", "path": "/v1/sanctions/check", "params": {"min_similarity": 0.6, "limit": 15, "inn": "7708503727"}, "status": 200, "request_id": "49615e502407484ebfd10874ed9687d3", "note": "", "sources": [{"source_id": "us_csl", "as_of": "2026-11-13", "fetched_at": "2026-11-13T06:17:04.741878+00:00"}]}
request_id ties the line to the server's own request log, sources records as_of and fetched_at of every list used, and status and note record failures such as a 404. Only the final answer of each call is recorded; attempts that were retried after a 429 or 504 are not listed. Keep the file with the case notes.
Variations¶
- Many counterparties. Loop over INNs from a CSV and call
screen(inn=...)for each; stay under the rate limit (default 120 requests per minute shared by all your calls) by sleeping about 1 second between companies, or rely on the retry inget(). - Only name hits near 1.0. Pass
--min-similarity 0.9. - For an AI client. The same checks are the tools
check_sanctions,search_companiesandget_company.