Sanctions Screening Under Latency Constraints

Prerequisite: Identity and Compliance

Your payment API promises a 500ms end-to-end response time. The sanctions screening step takes 120ms — a quarter of your total budget — and that is before you add fuzzy matching against 15,000 OFAC entries, 8,000 EU consolidated list entries, and 3,200 UN Security Council entries. A customer sends a wire to “Mohammed Al-Rahman Trading LLC.” Your screener flags 47 potential matches. Each needs a similarity score, a confidence classification, and a disposition decision. The wire sits in a queue while your compliance team reviews the alerts. Three hours later, the customer calls asking why the wire has not arrived.

Sanctions screening is not optional, not deferrable, and not negotiable. OFAC’s strict liability standard means intent does not matter — if you process a payment to a sanctioned party, you are liable regardless of whether your screening system flagged the match. The fine schedule starts at $330,947 per violation and scales to the greater of $1,396,890 or twice the transaction value.

The Lists

Sanctions screening checks counterparties against government-maintained lists of prohibited individuals, entities, vessels, and aircraft. The major lists differ in scope, update frequency, and format.

List Maintained by Entries (~) Update frequency Format
SDN (Specially Designated Nationals) OFAC (US Treasury) ~15,000 Multiple times/week XML, CSV, PDF
Consolidated Sanctions List EU ~8,000 As needed, published daily XML
UN Security Council Consolidated List UN ~3,200 As needed XML
HMT Consolidated List UK Treasury ~4,500 Weekly CSV, XML
SECO Sanctions Switzerland ~5,000 As needed XML

OFAC’s SDN list includes primary entries and their aliases. A single entity can have 10–20 aliases across transliterations, abbreviations, and former names. The effective number of strings to match against is 3–5x the entry count.

OFAC updates the SDN list without advance notice. An entity added at 2:00 PM ET is sanctioned immediately. Your screening system must ingest list updates within minutes, not hours. A payment processed at 2:30 PM against a 2:00 PM addition is a violation.

Where Screening Sits in the Payment Path

Every payment flow has a screening decision point. The question is where.

Pre-execution screening checks the counterparty before the payment enters the rails. The screening latency adds directly to the customer-facing response time. A 200ms screening step in a 500ms API means 40% of your latency budget is compliance.

Post-execution screening checks after the payment is submitted but before settlement. This works for rails with settlement delays (ACH, SWIFT) but fails for real-time rails (FedNow, RTP) where execution and settlement are effectively simultaneous.

Rail Settlement delay Screening window Viable approach
ACH T+1 to T+2 Hours Post-execution batch
SWIFT Hours to days Hours Post-execution or pre-execution
Wire (Fedwire) Minutes Minutes Pre-execution required
FedNow / RTP Seconds None Pre-execution required
On-chain (stablecoin) Seconds to minutes Seconds Pre-execution required

For instant payment rails, screening must complete before execution. There is no window for async review.

Exact Matching and Its Limits

The simplest screening approach matches counterparty names exactly against list entries. Normalize both strings (uppercase, strip diacritics, collapse whitespace), then compare.

def normalize(name: str) -> str:
    name = unicodedata.normalize("NFKD", name)
    name = name.encode("ascii", "ignore").decode()
    name = re.sub(r"[^A-Z0-9 ]", "", name.upper())
    return re.sub(r"\s+", " ", name).strip()

def exact_match(name: str, watchlist: set[str]) -> bool:
    return normalize(name) in watchlist

Exact matching is fast — O(1) with a hash set. A lookup against 50,000 normalized entries takes under 1ms. The problem is coverage. “Muhammad” has over 30 common transliterations. “Al-Rahman” can appear as “Alrahman,” “Al Rahman,” “Ar-Rahman,” or “Abdul Rahman.” An exact match catches only the specific spelling on the list.

OFAC explicitly states that sanctions compliance programs must account for “reasonable” name variations. Exact matching alone does not meet this standard.

Fuzzy Matching

Fuzzy matching scores the similarity between the input name and each list entry. Every input is compared against every entry (or a filtered subset), and matches above a threshold are flagged for review.

Common algorithms and their tradeoffs:

Algorithm What it measures Speed Strength Weakness
Jaro-Winkler Character-level edit distance with prefix bonus Fast Good for typos and transpositions Poor for name reordering
Levenshtein Minimum edit operations Moderate Intuitive distance metric Penalizes length differences
Double Metaphone Phonetic encoding Fast Catches pronunciation-similar names Language-dependent
n-gram (Dice/Jaccard) Shared character sequences Fast Length-independent High false positive rate
Token set ratio Word-level overlap, order-independent Moderate Handles reordering (“LLC Trading Mohammed” vs “Mohammed Trading LLC”) Ignores semantic context

Production screening systems combine multiple algorithms. A typical pipeline: phonetic encoding as a first-pass filter (eliminates 95% of comparisons), followed by Jaro-Winkler on the remaining candidates, with token set ratio for entity names that may be reordered.

def screen_name(name: str, watchlist: list[Entry], threshold: float = 0.85) -> list[Hit]:
    candidates = phonetic_filter(name, watchlist)  # ~5% of entries survive
    hits = []
    for entry in candidates:
        jw = jaro_winkler(normalize(name), normalize(entry.name))
        tsr = token_set_ratio(name, entry.name)
        score = max(jw, tsr)
        if score >= threshold:
            hits.append(Hit(entry=entry, score=score, algorithm="jw" if jw >= tsr else "tsr"))
    return hits

The threshold is the critical parameter. Lower thresholds catch more true positives but generate more false positives. Higher thresholds reduce noise but risk missing sanctioned parties.

Threshold True positive rate False positive rate Alerts per 10K payments
0.95 ~70% < 0.01% 1–2
0.90 ~85% 0.05% 5–10
0.85 ~95% 0.2% 20–40
0.80 ~98% 0.8% 80–120

Most production systems operate at 0.85–0.90 for names and 0.95 for exact identifiers (passport numbers, entity registration IDs). The false positive rate at 0.85 means a compliance team reviewing 20–40 alerts per 10,000 payments. At 100,000 payments per day, that is 200–400 manual reviews daily.

The Latency Budget

Screening latency has three components: list lookup, similarity scoring, and disposition.

Component Time budget What happens
List lookup + phonetic filter 2–5ms Load candidates from indexed list
Similarity scoring 10–50ms Score candidates against input
Auto-disposition 1–2ms Apply rules to classify hits
Total (auto-cleared) 15–60ms Payment proceeds
Manual review queue Minutes to hours Payment holds

For payments that screen clean (no hits above threshold), the total latency is 15–60ms. For payments that generate hits but are auto-dispositioned (score below a secondary threshold, or the hit matches a known false positive), add 1–2ms. For payments that require manual review, latency is measured in minutes or hours.

The architecture goal: minimize the percentage of payments that enter the manual review queue while maintaining the true positive rate required by regulators.

Pre-computation and Caching

Repeated screening of the same counterparties wastes compute. Your top 1,000 customers appear in 80% of transactions. Screen them once, cache the result, and invalidate only when the list updates.

SCREEN_CACHE: dict[str, ScreenResult] = {}
CACHE_VERSION: int = 0  # incremented on list update

def cached_screen(name: str, version: int) -> ScreenResult:
    key = f"{normalize(name)}:{version}"
    if key in SCREEN_CACHE:
        return SCREEN_CACHE[key]
    result = full_screen(name)
    SCREEN_CACHE[key] = result
    return result

Cache invalidation is tied to list updates. When OFAC publishes a new SDN list, increment the version and let the cache rebuild organically. Do not batch-rescreen all cached entries synchronously — that creates a thundering herd. Rescreen lazily on next access, or prioritize rescreening based on recent transaction volume.

For high-volume platforms, pre-screening the entire customer database on each list update is viable. 100,000 customers against 50,000 list entries with phonetic filtering takes 15–30 minutes on a single node. Run it as a background job and flag any new matches for review before the next payment attempt.

See Identity and Compliance. KYC verification at onboarding reduces screening load at payment time. A customer verified against sanctions lists at onboarding only needs re-screening when the lists change, not on every payment.

Real-Time vs Batch Architecture

Real-time screening runs in the payment path. Every payment is screened before execution. Latency is the constraint. The screening service must return within the payment API’s SLA — typically 50–200ms for the screening component alone.

The real-time architecture: an in-memory index of all sanctions lists, loaded at startup and updated via change feed. The index uses phonetic encodings and n-gram tokens for fast candidate retrieval. Scoring runs against the candidate set, not the full list. Horizontal scaling by partitioning the index across nodes is possible but usually unnecessary — the full SDN list fits in under 100MB of memory.

Batch screening runs outside the payment path. Scheduled jobs screen customer databases, transaction histories, and counterparty records against updated lists. Batch screening catches entities added to the list after the original payment was processed.

Mode When to use Latency Coverage
Real-time Every payment on instant rails 15–60ms Current list at payment time
Batch (daily) Customer database rescreen N/A (offline) Catches retrospective additions
Event-driven On list update Minutes Rescreens recent transactions against new entries

Production systems run both. Real-time screening on the payment path catches current list matches. Daily batch rescreening catches retrospective matches — a person added to the SDN list today may have received a payment yesterday that was clean at the time but is now a compliance concern.

Alert Triage and Disposition

Not every alert requires human review. Auto-disposition rules clear known false positives and escalate genuine concerns.

Auto-clear rules: - Score below secondary threshold (e.g., 0.88 when primary is 0.85) AND no matching identifiers - Counterparty matches a previously reviewed and cleared entry (same name, same jurisdiction, disposition within 90 days) - Name match but country, date of birth, or identifier mismatch against all list entry attributes

Auto-escalate rules: - Score above 0.95 on any algorithm - Match on identifier (passport number, tax ID, vessel IMO number) - Counterparty in a sanctioned jurisdiction (DPRK, Iran, Syria, Cuba, Crimea region)

The disposition pipeline reduces manual review volume by 60–80%. A platform processing 100,000 payments per day with a 0.85 threshold generates 200–400 raw alerts. After auto-disposition, 40–80 require human review.

List Ingestion and Update Propagation

OFAC publishes list updates as XML deltas. The ingestion pipeline must parse the update, merge it into the active index, and propagate the change to all screening nodes — all before the next payment arrives.

List update published (OFAC XML)
  → Parser extracts additions, modifications, removals
  → Index updated atomically (swap, not mutate)
  → Cache version incremented
  → Event-driven rescreen triggered for recent transactions
  → Compliance dashboard updated with change summary

The target: list updates reflected in screening within 5 minutes of publication. The OFAC expectation is “as soon as technologically practicable.” Five minutes is defensible. Sixty minutes is not.

The atomic index swap is critical. If the screening service is mid-query when a list update arrives, the query must complete against either the old list or the new list — never a partially updated list. Load the new index into memory alongside the old one, swap the pointer atomically, then release the old index.

Screening Across Entity Types

Sanctions lists contain individuals, entities, vessels, and aircraft. Each type requires different matching strategies.

Entity type Matching approach Key identifiers
Individuals Name + DOB + nationality Passport, national ID
Entities Name + jurisdiction + registration Tax ID, registration number
Vessels Name + IMO number + flag state IMO (unique, persistent)
Aircraft Tail number + serial number MSN, registration

For individuals, name matching alone produces high false positive rates. Adding date of birth as a secondary filter reduces false positives by 70–80%. For entities, jurisdiction matching is the strongest secondary filter — “Mohammed Trading LLC” in Dubai has different risk implications than “Mohammed Trading LLC” in Des Moines.

Vessel and aircraft screening is simpler because IMO numbers and tail numbers are unique identifiers. A match on the identifier is definitive. Name matching for vessels is a backup for cases where the identifier is unavailable or the vessel has been re-flagged.

The screening service must handle all entity types in the same pipeline. A single wire transfer may require screening the sender (individual), the recipient (entity), the recipient’s beneficial owners (individuals), and any vessels or aircraft referenced in the payment details. A $50,000 wire to a shipping company can trigger four or five separate screening passes — each within the same latency budget.