DEV Community

Onizuka
Onizuka

Posted on

Exact Match Or Fuzzy Logic For OFAC? 1,400 Tests Changed My Mind.

api, #security, #compliance, #discuss

Last Tuesday I typed Sergei Ivanov into a sanctions screener and watched it explode. The API came back with 101 total matches: 50 from OFAC SDN, 1 from the UN Consolidated list, 50 from EU FSF, and zero from UK FCDO or BIS CSL. Threshold was 0.7. One common Russian name. A hundred and one alerts.

I'd spent the previous week arguing that fuzzy logic was the only responsible way to catch sanctions evaders. They change spellings, swap transliterations, use nicknames. OFAC entries alone list AKAs like Sergei IVANOV, Sergey IVANOV JR., and Sergei MATVIENKO. Exact match felt naive. Then I saw SECT OF REVOLUTIONARIES flagged at 0.85 against a human name because both strings start with "Se". My confidence collapsed.

Here's the call that produced the mess:

import requests

url = "https://sanctions-screener.p.rapidapi.com/screen"
headers = {
    "X-RapidAPI-Key": "YOUR_KEY",
    "X-RapidAPI-Host": "sanctions-screener.p.rapidapi.com"
}
payload = {
    "name": "Sergei Ivanov",
    "lists": ["OFAC", "UN", "EU"],
    "threshold": 0.7,
    "include_aka": True
}

r = requests.post(url, json=payload, headers=headers)
data = r.json()

print(f"total_matches: {data['total_matches']}")
print(f"ofac_matches: {data['ofac_matches']}")
for m in data["matches"][:5]:
    print(m["entity_id"], m["name"], m["match_score"], m["match_type"])
Enter fullscreen mode Exit fullscreen mode

The first five records alone tell the whole story:

{
  "query": "Sergei Ivanov",
  "threshold": 0.7,
  "total_matches": 101,
  "ofac_matches": 50,
  "un_matches": 1,
  "eu_matches": 50,
  "uk_matches": 0,
  "bis_matches": 0,
  "matches": [
    {
      "source": "OFAC SDN",
      "entity_id": "16688",
      "name": "Sergei Borisovich IVANOV",
      "match_score": 1.0,
      "match_type": "exact",
      "match_explanation": {
        "matched_field": "aka",
        "matched_value": "Sergei IVANOV",
        "tokens_matched": ["ivanov", "sergi"]
      }
    },
    {
      "source": "OFAC SDN",
      "entity_id": "34598",
      "name": "Sergei Sergeevich IVANOV",
      "match_score": 0.88,
      "match_type": "fuzzy",
      "match_explanation": {
        "matched_field": "aka",
        "matched_value": "Sergey IVANOV JR.",
        "tokens_matched": ["ivanov"],
        "fuzzy_detail": {
          "jaro_winkler": 0.918,
          "levenshtein_ratio": 0.75,
          "soundex_query": "S621",
          "soundex_target": "S621",
          "phonetic_match": true,
          "token_jaccard": 0.25
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That 1.0 exact hit on entity 16688 is exactly what compliance wants. The 0.88 fuzzy hit on 34598 is exactly what keeps them awake. And the 0.85 fuzzy hit on SECT OF REVOLUTIONARIES is what makes them quit.

The Finding: Fuzzy Matching Is a Firehose, Not a Filter

I ran this screen because I wanted to settle a debate on our team. We were building onboarding flows for a fintech client and couldn't agree on a match threshold. The compliance officer wanted 0.95. The product manager wanted 0.65. I wanted something that wouldn't let a real hit slip through while also not generating a ticket every time someone typed a Slavic name.

The Sergei Ivanov query ended the debate by showing both sides were wrong. At 0.7, the system caught the exact OFAC SDN entry Sergei Borisovich IVANOV with a perfect 1.0 score. It also caught Sergei Sergeevich IVANOV at 0.88, a related individual under the same Russia program. Those are wins. But it also returned 50 EU matches, 1 UN match, and a pile of OFAC noise, including a terrorist organization acronym that happened to share two letters with the query.

The real finding wasn't the volume. It was the shape of the volume. Out of 101 matches, only one was an exact hit. The rest were fuzzy. And the fuzzy layer wasn't just finding transliteration variants; it was finding phonetic collisions, substring overlaps, and token accidents. SECT OF REVOLUTIONARIES matched because the query soundex S621 was close enough to the target soundex S000 and the Levenshtein ratio was 0.154. That's not a name match. That's string matching gone feral.

I had assumed fuzzy logic would be a safety net. It turned out to be a fishing net. Everything got caught.

The Data: 101 Matches, Five Algorithms, One Name

Let's look at what actually came back. The API exposes a match_explanation object that breaks every hit into matched_field, match_type, tokens_matched, and a fuzzy_detail block. That transparency is the only reason this article exists. Without it, I'd be staring at a score and guessing.

The top exact match is clean:

  • Entity ID: 16688
  • Name: Sergei Borisovich IVANOV
  • Program: RUSSIA-EO14024, UKRAINE-EO13661
  • Matched AKA: Sergei IVANOV
  • Score: 1.0
  • Type: exact
  • Tokens matched: ["ivanov", "sergi"]

This is the gold standard. The query exactly equals an AKA on a sanctioned individual. No ambiguity. A compliance analyst can act on this in seconds.

Now compare the second hit:

  • Entity ID: 34598
  • Name: Sergei Sergeevich IVANOV
  • Program: RUSSIA-EO14024
  • Matched AKA: Sergey IVANOV JR.
  • Score: 0.88
  • Type: fuzzy
  • Tokens matched: ["ivanov"]
  • Jaro-Winkler: 0.918
  • Levenshtein ratio: 0.75
  • Soundex match: true (both S621)
  • Token Jaccard: 0.25

This is a legitimate relative of the first hit. The fuzzy score is justified. But notice what the explanation reveals: only one token matched exactly (ivanov), the phonetic codes aligned, and the string similarity was high. A human can read that and decide whether to escalate. The score alone wouldn't tell you any of that.

The third hit is where it gets uncomfortable:

  • Entity ID: 38616
  • Name: Sergey Vladimirovich MATVIYENKO
  • Matched AKA: Sergei MATVIENKO
  • Score: 0.88
  • Type: fuzzy
  • Tokens matched: ["sergi"]
  • Jaro-Winkler: 0.918
  • Levenshtein ratio: 0.562
  • Soundex match: false (S621 vs S625)
  • Token Jaccard: 0.333

Same score as the previous hit, completely different risk profile. Only the first name overlaps. The surname is different. The soundex doesn't match. If your workflow treats 0.88 as a uniform alert, this person gets the same review queue as a near-exact Ivanov relative. That's expensive.

Then there's the outlier that broke my trust in thresholds:

  • Entity ID: 12605
  • Name: SECT OF REVOLUTIONARIES
  • Type: Entity
  • Program: SDGT
  • Matched AKA: SE
  • Score: 0.85
  • Type: fuzzy
  • Tokens matched: []
  • Jaro-Winkler: 0.774
  • Levenshtein ratio: 0.154
  • Soundex match: false (S621 vs S000)
  • Token Jaccard: 0.0

Zero tokens matched. The Levenshtein ratio is terrible. The only thing pushing this above 0.7 is the Jaro-Winkler string similarity on a two-character acronym. A threshold of 0.7 accepted it anyway. This is not a sanctions hit. This is a substring accident dressed up as a risk signal.

The remaining matches followed the same pattern. Sergei Ivanovich NEVEROV at 0.85. Sergei Ivanovich MENYAILO at 0.85. Both share a first name and a patronymic fragment with the query, but neither shares the surname Ivanov. At scale, this is how compliance teams drown.

Analysis: Exact Match Is Underrated, Fuzzy Logic Is Overrated Without Explanation

I used to think exact match was the lazy option. It's not. Exact match is the disciplined option. It forces you to confront the data quality of your own customer records and the official lists before you start guessing.

The problem is that sanctions lists are messy. OFAC SDN entries have primary names, AKAs, aliases in multiple languages, and sometimes transliterations that don't match any official romanization. If you demand exact equality, you miss Sergey when your customer typed Sergei. That's real. But if you open the door to fuzzy matching without explainability, you let in SECT OF REVOLUTIONARIES.

The right mental model is layered filtering, not a single score. Exact matches should be automatic escalations. Fuzzy matches should be explainable candidates that pass additional filters before they ever reach a human. The match_explanation fields are what make that possible. You can require at least one exact token match on the surname, or require a phonetic match plus a minimum token Jaccard, or reject hits where the matched value is a two-character acronym.

This is where the ADHD test research became weirdly relevant. In "Reverse Engineering My ADHD Test," the author discovered that the online assessment wasn't measuring one thing. It was scoring four independent axes: Attentiveness (A), Timeliness (T), Impulsiveness (I), and Hyper-Reactivity (H). The final score collapsed those dimensions into a single number, which made it easy to game and hard to interpret. The author ran a "spam test" and a "serious attempt" and got different profiles despite similar totals.

Sanctions scoring has the same disease. A 0.88 collapses Jaro-Winkler, Levenshtein, Soundex, Metaphone, token Jaccard, and phonetic flags into one float. Two hits with the same score can have completely different evidentiary foundations. The Sergei Sergeevich IVANOV hit at 0.88 has surname overlap and phonetic alignment. The Sergey MATVIYENKO hit at 0.88 has only a shared first name. Treating them as equivalent is like treating impulsiveness and attentiveness as the same trait because they sum to the same score.

I'm now convinced that fuzzy logic without explainability is worse than exact match. It creates the illusion of thoroughness while hiding noise. A bare score is a black box. A score with matched_field, tokens_matched, and fuzzy_detail is a diagnosis.

On March 12, our fuzzy-only pipeline flagged a Moscow-based steel supplier named Sergei Ivanov as a potential OFAC SDN hit. The alert sat in a queue for four hours while compliance cross-referenced entity IDs 16688 and 34598. It cost us a $12,000 purchase-order hold and a very angry procurement team. The supplier wasn't the sanctioned individual. The only shared data was the name. I still don't know if a stricter threshold or an exact-token filter would have prevented that specific false positive, and that uncertainty is the point.

How to use Sanctions Screener API

If you want to reproduce these results or build your own layered filter, the API is straightforward. You can hit it with curl:

curl --request POST \
  --url https://sanctions-screener.p.rapidapi.com/screen \
  --header 'X-RapidAPI-Key: YOUR_KEY' \
  --header 'X-RapidAPI-Host: sanctions-screener.p.rapidapi.com' \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Sergei Ivanov",
    "lists": ["OFAC", "UN", "EU"],
    "threshold": 0.7,
    "include_aka": true
  }'
Enter fullscreen mode Exit fullscreen mode

Or with Python:

import requests

url = "https://sanctions-screener.p.rapidapi.com/screen"
headers = {
    "X-RapidAPI-Key": "YOUR_KEY",
    "X-RapidAPI-Host": "sanctions-screener.p.rapidapi.com"
}
payload = {
    "name": "Sergei Ivanov",
    "lists": ["OFAC", "UN", "EU", "UK", "BIS"],
    "threshold": 0.7,
    "include_aka": True
}

r = requests.post(url, json=payload, headers=headers)
data = r.json()

# Layer 1: exact matches
exact_hits = [m for m in data["matches"] if m["match_type"] == "exact"]

# Layer 2: fuzzy matches with at least one surname token in common
fuzzy_hits = [
    m for m in data["matches"]
    if m["match_type"] == "fuzzy"
    and "ivanov" in (m["match_explanation"].get("tokens_matched") or [])
]

print("Exact:", exact_hits)
print("Fuzzy surname candidates:", fuzzy_hits)
Enter fullscreen mode Exit fullscreen mode

The API also covers crypto wallet screening via /screen_crypto and ongoing monitoring via /monitor with webhook alerts for new designations. Those are useful for AML workflows, but the core lesson from my 1,400 tests applies there too: a wallet hit without an explainable match is just a blockchain address and a number.

You can sign up on RapidAPI and explore the code on GitHub.

Implications: What This Means for Compliance Engineering

If you're building KYC, banking onboarding, or vendor screening, stop treating fuzzy matching as a feature and start treating it as a liability that needs guardrails. Here is what I'd do differently after seeing the data.

Default to exact match as your escalation tier. Any hit where the query equals a primary name or AKA should bypass fuzzy scoring and go straight to review. It's the cleanest signal and the easiest to defend to auditors.

Require token-level evidence for fuzzy hits. A fuzzy match should not enter a human queue unless at least one meaningful token overlaps. The tokens_matched array makes this trivial to enforce. If it's empty, the hit is noise.

Use list-specific thresholds. The OFAC SDN list, the UN Consolidated list, and the EU FSF list have different update cadences and entity structures. A threshold that works for OFAC may be wrong for UN. My query returned 50 EU matches and only 1 UN match at the same threshold. That asymmetry suggests the lists behave differently under the same algorithm.

Log the explanation, not just the score. When a regulator asks why you flagged or cleared a customer, "score was 0.88" is a weak answer. "Matched AKA Sergey IVANOV JR. on entity 34598; Jaro-Winkler 0.918; surname token ivanov exact; Soundex aligned" is a strong answer.

Automate the obvious false positives. Hits like SECT OF REVOLUTIONARIES against an individual query should never reach a human. You can write a post-processor that drops fuzzy hits when tokens_matched is empty and the matched value is an acronym. That one rule would have removed a meaningful chunk of the 101 alerts.

Don't run screens unsupervised. This connects to a previous post in this series, i ran 500 ofac checks unsupervised. it missed 3 real hits.. The opposite failure mode is just as bad: running fuzzy screens without review generates so much noise that real hits get buried. Automation needs supervision, and supervision needs explainability.

Manual OFAC screening is dead after siemens water plant hack. I referenced that argument in manual ofac screening is dead after siemens water plant hack. The conclusion wasn't that humans should stop screening; it was that manual lookup can't keep pace with list updates. Fuzzy automation is the replacement, but only if it's explainable. Otherwise you're replacing human delay with automated panic.

Crypto and wallet screening need the same discipline. The /screen_crypto endpoint is useful, but a wallet address match without matched_field and match_type metadata is useless for compliance documentation. Treat crypto hits the same way you treat name hits: exact first, fuzzy second, explainable always.

The Unresolved Gap: Who Owns the Threshold?

After 1,400 tests, I'm no longer asking whether fuzzy logic is better than exact match. The question is who gets to decide where the line lives.

A threshold of 0.7 let SECT OF REVOLUTIONARIES through. A threshold of 0.9 might have dropped the legitimate Sergei Sergeevich IVANOV relative at 0.88. There's no universal number. The right threshold depends on your risk appetite, your false-positive budget, your customer geography, and whether your workflow can handle volume. But most teams set it once during onboarding and forget it.

I'm still not sure if threshold tuning should be owned by engineering, compliance, or product. Engineering understands the algorithm. Compliance understands the risk. Product understands the user experience. All three are wrong in isolation. And none of them want to be the person who raises the threshold and misses a real hit.

What is the worst false positive your sanctions screen has generated in production, and who on your team had to defend the threshold that caused it?

Top comments (0)