Skip to content
VynCo is in public beta — we'd love your feedback.
← Back to blogBuild a PEP & UBO Screening Pipeline in 20 Lines of Python

Build a PEP & UBO Screening Pipeline in 20 Lines of Python

VynCo Engineering4 min read4/13/2026

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.

Risk score breakdown

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:

  1. Zefix head_office / foreign_parent relationships
  2. Acquisition records (acquirer is treated as parent of acquired)
  3. 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

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

Build a PEP & UBO Screening Pipeline in 20 Lines of Python | VynCo Blog | VynCo