Skip to content

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

  1. INN, if you have it. GET /v1/sanctions/check?inn=7708503727 matches the INN exactly against entries that carry an INN. Any inn_hits row means a listed entry has this identifier.
  2. Registry card. GET /v1/companies/7708503727 returns the registry fields, bands, structure, address and the API's own sanctions screening (INN and name) in one object. A 404 not_found means the INN is not in the registry copy; it does not say anything about the lists.
  3. 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.
  4. No INN, only a name. GET /v1/companies/search?query=... lists registry candidates with sanctioned_inn and sanction_name_hits flags. Pick the right company, take its INN, and run the flow again from step 1.
  5. Record. Every response has meta.request_id (also the X-Request-Id header) and meta.sources with the as_of of 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.

counterparty_check.py
#!/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 in get().
  • Only name hits near 1.0. Pass --min-similarity 0.9.
  • For an AI client. The same checks are the tools check_sanctions, search_companies and get_company.