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 watchlistExact 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 hitsThe 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 resultCache 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.