
Build a PEP & UBO Screening Pipeline in 20 Lines of Python
A clean AML check has three parts: (1) does the company or its owners appear on a sanctions list? (2) who actually controls the entity — the Ultimate Beneficial Owner? (3) what's the overall risk profile? With the VynCo Python SDK each is one call, so the whole pipeline is short enough to read in one sitting.

The full function
import vynco
from dataclasses import dataclass
from typing import Literal
client = vynco.Client()
@dataclass
class Verdict:
uid: str
name: str
decision: Literal["PASS", "MONITOR", "REVIEW"]
reasons: list[str]
def aml_check(uid: str) -> Verdict:
company = client.companies.get(uid).data
# 1. Sanctions on the entity
screening = client.screening.screen(name=company.name, uid=uid).data
reasons = [f"sanctions hit: {h.source}" for h in screening.hits]
# 2. UBO — walk the ownership chain, screen each natural person
ubo = client.companies.ubo(uid).data
for person in ubo.ubo_persons:
hit = client.screening.screen(name=person.name).data
if hit.hits:
reasons.append(f"UBO sanctions hit: {person.name}")
# 3. Algorithmic risk score (no LLM, ~300ms)
risk = client.ai.risk_score(uid=uid).data
if risk.overall_score >= 70:
reasons.append(f"risk score {risk.overall_score}/100 ({risk.risk_level})")
decision = "REVIEW" if any("sanctions" in r for r in reasons) else \
"MONITOR" if reasons else "PASS"
return Verdict(uid=uid, name=company.name, decision=decision, reasons=reasons)
Twenty lines, three API calls plus one per UBO. For a single counterparty it runs in about 800 ms.
What the three calls actually do
client.screening.screen() hits SECO, OpenSanctions, and FINMA in parallel, deduplicates overlapping entries, and returns a risk_level plus a list of matched entries with scores. The request is cached for an hour so batch workflows don't pay for the same name twice.
client.companies.ubo() walks upward through three sources:
- Zefix
head_office/foreign_parentrelationships - Acquisition records (acquirer is treated as parent of acquired)
- GLEIF-sourced
ultimate_parent_lei— populated by a weekly pipeline that pulls the global LEI graph
When the chain is empty (common for large Swiss corporates whose parent relationships aren't in Zefix), the response carries a data_coverage_note explaining why — so your code can branch on "we have no parent data" vs "the company genuinely has no parent".
client.ai.risk_score() is algorithmic, not LLM-based. Six weighted factors: sanctions exposure, company status, capital adequacy, audit category, change velocity over 180 days, regulatory status. The scorer returns each factor's contribution so downstream auditors can trace why a company scored where it did.
Scaling to a portfolio
Loop the function over your portfolio, but add back-pressure on the screening calls — 500 companies × 4 sanctions calls each is 2000 requests. Use the batch endpoints:
screenings = client.screening.batch(uids=portfolio).data.results
risks = client.ai.risk_score_batch(uids=portfolio).data.results
# Join + per-UBO screening (single calls — can't batch persons today)
for s, r in zip(screenings, risks):
ubo = client.companies.ubo(s.uid).data
for p in ubo.ubo_persons:
person_hit = client.screening.screen(name=p.name).data
# ... merge
Both batch endpoints process up to 100 companies in one request, which typically returns in 2-5 seconds.
The typed-error advantage
The SDK raises a concrete exception per HTTP status:
try:
result = aml_check(uid)
except vynco.NotFoundError:
log.info(f"UID {uid} not in Swiss register")
except vynco.InsufficientCreditsError as e:
log.warning(f"Monthly quota exceeded: {e.detail}")
except vynco.ServiceUnavailableError:
# LLM-backed endpoints surface 503 with a clear message — retry later
raise
Every error class wraps the RFC 7807 problem detail from the API, so e.detail has the actual human-readable message instead of a raw HTTP body.
Where to go next
ubo_resolution.py— standalone UBO walker with tree printingdue_diligence.py— a fuller version of this script with dossier generation
One thing to know about data coverage: Zefix doesn't publish corporate-group parent links for most large Swiss companies (UBS → UBS Group AG isn't in the register). Our weekly GLEIF pipeline backfills this for ~500 companies per run. Until that covers your target, ubo_persons may legitimately be empty. The data_coverage_note field in the response tells you when that's happening.
Links
- SDK:
github.com/VynCorp/vc-python - Example: ubo_resolution.py
- API docs: vynco.ch/docs/ubo
- Get an API key: vynco.ch/signup