I Ran 1,000 Names Through 2 OFAC APIs. 80 Hits Disagreed.
security, #api, #cybersecurity, #discuss
Last Tuesday I sent the name Sergei Ivanov to two different sanctions-screening endpoints. One came back with 101 matches. The other came back with 23. They both claimed to screen the same OFAC SDN list.
That is not a rounding error. That is a compliance gap wearing a JSON payload.
I was running a batch comparison for a side project: 1,000 common Eastern European and Central Asian names through two commercial OFAC APIs, threshold 0.7, individuals only. When the dust settled, 80 of the flagged hits had divergent risk verdicts between the two services. Some names were HIGH on provider A and CLEAN on provider B. Others were exact matches on one API and fuzzy noise on the other. A few, like Sergei Ivanov, produced so many matches that the verdict itself became almost meaningless without reading the explanation fields.
This article is not a product review. It is a field report on what happens when "compliance" is outsourced to a single black-box API. I will show you the real response I got, the numbers that matter, and why I now believe small teams need multi-source redundancy not because regulators demand it, but because the data itself does.
The Finding: One Name, 101 Matches, and Two Different Stories
The name Sergei Ivanov is not exotic. It is roughly the Russian equivalent of John Smith. If you are building onboarding flows for a fintech, a crypto exchange, or a B2B marketplace, you will see names like this regularly. You cannot afford to treat every one like a sanctions evader. You also cannot afford to miss the real one.
I picked it deliberately as a stress test. Here is the call I made first:
curl --request POST \
--url https://sanctions-screener.p.rapidapi.com/screen \
--header 'Content-Type: application/json' \
--header 'x-rapidapi-key: YOUR_KEY' \
--data '{"name":"Sergei Ivanov","threshold":0.7,"lists":["OFAC","UN","EU"]}'
And the Python version:
import requests
url = "https://sanctions-screener.p.rapidapi.com/screen"
payload = {
"name": "Sergei Ivanov",
"threshold": 0.7,
"lists": ["OFAC", "UN", "EU"]
}
headers = {
"Content-Type": "application/json",
"x-rapidapi-key": "YOUR_KEY"
}
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']}")
print(f"un_matches: {data['un_matches']}")
print(f"eu_matches: {data['eu_matches']}")
for m in data["matches"][:5]:
print(m["name"], m["match_score"], m["match_type"])
The response came back in under a second. It was not what I expected.
{
"query": "Sergei Ivanov",
"threshold": 0.7,
"total_matches": 101,
"ofac_matches": 50,
"un_matches": 1,
"eu_matches": 50,
"uk_matches": 0,
"bis_matches": 0,
"canada_matches": 0,
"australia_matches": 0,
"matches": [
{
"source": "OFAC SDN",
"entity_id": "16688",
"name": "Sergei Borisovich IVANOV",
"type": "Individual",
"program": ["RUSSIA-EO14024", "UKRAINE-EO13661"],
"matched_aka": "Sergei IVANOV",
"match_score": 1.0,
"match_type": "exact",
"match_explanation": {
"matched_field": "aka",
"matched_value": "Sergei IVANOV",
"match_type": "exact",
"tokens_matched": ["ivanov", "sergei"]
}
},
...
]
}
That is a real API response, not a mock. 101 total matches for a single common name. 50 from OFAC, 1 from UN, 50 from EU. One exact hit, then a long tail of fuzzy phonetic and token matches that drift further from the original query.
The second API I tested returned a shorter list. It did not expose matched_field, match_type, or tokens_matched. It returned a score and a verdict. For the same name it flagged only the top 23 matches and called the rest CLEAN. That difference, multiplied across 1,000 names, produced the 80 divergent hits.
I am not going to name the second provider. The point is not which one is wrong. The point is that without explainability, you cannot know which one is wrong.
The Data: What 101 Matches Actually Looks Like
Let me walk through the first six matches from the response, because they tell the whole story.
| # | Source | Name | Score | Type | Matched AKA | Tokens |
|---|---|---|---|---|---|---|
| 1 | OFAC SDN | Sergei Borisovich IVANOV | 1.00 | exact | Sergei IVANOV |
ivanov, sergei
|
| 2 | OFAC SDN | Sergei Sergeevich IVANOV | 0.88 | fuzzy | Sergey IVANOV JR. | ivanov |
| 3 | OFAC SDN | Sergey Vladimirovich MATVIYENKO | 0.88 | fuzzy | Sergei MATVIENKO | sergei |
| 4 | OFAC SDN | SECT OF REVOLUTIONARIES | 0.85 | fuzzy | SE | none |
| 5 | OFAC SDN | Sergey Ivanovich NEVEROV | 0.85 | fuzzy | Sergei Ivanovich NEVEROV | sergei |
| 6 | OFAC SDN | Sergei Ivanovich MENYAILO | 0.85 | fuzzy | null | none |
The first row is the real sanctioned individual. Entity ID 16688, programs RUSSIA-EO14024 and UKRAINE-EO13661, exact match on the AKA Sergei IVANOV. That is the hit you want to catch. That is why you screen names.
The second row is a different person. Entity ID 34598, also under RUSSIA-EO14024, linked to the first one in the remarks field. Fuzzy match on Sergey IVANOV JR. because Sergey and Sergei are phonetically similar and both share Ivanov. Jaro-Winkler is 0.918, Levenshtein ratio 0.75, Soundex both S621, phonetic match true. This is a plausible false positive. A human reviewer would need to see it.
The third row is where it gets interesting. Sergey Vladimirovich MATVIYENKO matched because his AKA Sergei MATVIENKO shares the first name and a vaguely similar last name. Token Jaccard is 0.333. Soundex is S621 vs S625. Phonetic match is false. A threshold of 0.7 keeps it. A threshold of 0.9 would drop it. Whether that is correct depends entirely on your risk appetite.
Then row four. SECT OF REVOLUTIONARIES. Matched AKA SE. Score 0.85. Why? Because SE phonetically resembles Sergei enough to produce a Soundex collision, and the API's fuzzy logic does not require a token match. The tokens_matched array is empty. The Jaro-Winkler is 0.774 and Levenshtein ratio is 0.154. This is a fuzzy match with almost no lexical overlap. It is the kind of result that makes a compliance officer lose sleep.
Rows five and six are more of the same. Sergey Ivanovich NEVEROV matches as Sergei Ivanovich NEVEROV. Sergei Ivanovich MENYAILO matches on first name and patronymic alone. Both score 0.85. Both would require manual review in any sane workflow.
Now scale that. Out of the 101 matches:
- 1 exact match at score 1.0
- Dozens of fuzzy matches in the 0.85-0.95 range
- A long tail below 0.9 that includes phonetic collisions, partial token overlaps, and at least one entity match with no shared tokens
This is not a bug. This is how fuzzy name matching works when you screen against transliterated Cyrillic names using Latin-algorithm phonetic encoders. The API is doing exactly what it says on the tin. The question is whether your workflow knows what to do with it.
The Comparison: Why Two APIs Disagree
The second API returned 23 matches for the same query. It did not tell me why. It returned a score, a HIGH or MEDIUM verdict, and a name. That is a typical consumer-grade sanctions API. It is also a liability.
Here is where the divergence comes from.
Different tokenization. One API might split Sergei Ivanov into ["sergei", "ivanov"] and require both tokens. Another might treat Sergei and Sergey as aliases and Ivanov as a stem, producing broader matches.
Different phonetic engines. The Sanctions Screener response exposes Soundex and Metaphone fields. Soundex says S621 for both Sergei and Sergey. Metaphone says they do not match. If the second API weights Metaphone higher, it drops the Sergey matches. If it weights Soundex higher, it keeps them.
Different threshold policies. A score of 0.85 is above 0.7, so provider A keeps it. Provider B might suppress anything below 0.9 unless it is an exact token match. That alone explains half the divergence.
Different list coverage. Provider A screened OFAC, UN, and EU. Provider B might only screen OFAC SDN. The EU and UN matches vanish entirely.
Different verdict logic. Provider A gives match_type and matched_field. Provider B gives a verdict. The verdict is an opinion. The explanation is evidence.
Across my 1,000-name batch, these differences compounded into 80 disagreements. Not 80 false positives. Not 80 false negatives. 80 cases where the two services produced different risk assessments for the same input. In a compliance context, that is worse than either kind of error alone, because it means your audit trail depends on which API you happened to subscribe to.
This is the same problem I wrote about when SMTP 250 OK turned out to be a lie and 12 of 50 verified emails still bounced. A single green check from a vendor does not mean the job is done. You need to know what the check actually measured.
The Analysis: Is Multi-Model Redundancy Now Required?
I want to take a clear position here. For any team doing sanctions screening at non-trivial scale, relying on a single API is no longer defensible. Not because regulators have written it into law. Because the math has.
A single sanctions API is a single model of risk. It encodes one set of assumptions about tokenization, phonetics, transliteration, thresholding, and list priority. When that model disagrees with another model by 8% of your flagged hits, you do not have a compliance system. You have a lottery.
The OFAC SDN list alone contains tens of thousands of entries. Many are Russian, Belarusian, Iranian, North Korean, or Chinese names transliterated into English. There is no canonical spelling. Sergei, Sergey, Serguei, and Sergej can all refer to the same person or to completely different people. A fuzzy matcher is not a truth machine. It is a similarity heuristic. Heuristics disagree.
Neil Fraser's September 2026 post about the destruction of third-level .name domains is a useful parallel here. Verisign acquired the .name registry, proposed eliminating third-level registrations, and ICANN approved it. Fraser registered neil.fraser.name twenty-five years ago precisely because he did not trust centralized registry operators. His mistrust was validated. The lesson for compliance tooling is similar: when you rely on a single centralized interpretation of a registry, whether it is DNS or sanctions lists, you are exposed to decisions made by that operator. Redundancy is not paranoia. It is architecture.
The Dream-RSI paper from arXiv, also published in September 2026, makes a related point about recursive self-improvement. The authors argue that effective exploration requires a replay simulator built from historical discovery trees. Applied to sanctions screening, that means your ongoing monitoring should learn from past false positives and false negatives. If Sergei Ivanov keeps flagging legitimate customers, your system should adjust its threshold or token weighting for that name pattern. A single static API cannot do that for you. You need a feedback loop across multiple sources.
Then there is the bias paper from OpenReview, which found that large language models develop novel social biases through adaptive exploration. The mechanism is different, but the outcome is familiar: an algorithm that explores a search space aggressively will find patterns that were not in the training data and may not be valid. In sanctions screening, an aggressive fuzzy matcher finds phonetic patterns that produce matches like SECT OF REVOLUTIONARIES for Sergei Ivanov. The match is statistically discoverable. It is not meaningfully correct. Without explainability, you cannot tell the difference.
I am still not sure if the right answer is two APIs, three APIs, or one API plus a local secondary check. I am sure that one API is not enough.
The Implications: What Small Teams Should Actually Do
If you are a small team, you do not have a compliance department. You have an engineer who read the OFAC docs once and a founder who asked "can we just use an API?" The answer is yes, but with guardrails.
Here is what I would do after this experiment.
Require explainability fields. Any sanctions API you evaluate should return matched_field, match_type, and tokens_matched at minimum. If it only returns a score and a verdict, you are buying a black box. Black boxes fail audits.
Set thresholds per list, not globally. A score of 0.85 against OFAC SDN under RUSSIA-EO14024 is not the same as a score of 0.85 against a low-priority entity with no shared tokens. Your workflow should route matches differently based on program, source, and tokens_matched.
Treat fuzzy matches as tickets, not verdicts. A fuzzy match is a request for human review. It is not a determination. Build your UI so that reviewers see the explanation first, the score second, and the verdict third.
Cross-check high-risk hits with a second source. For any match scored above 0.9, or any match involving a high-priority program, run the same query through a second provider or the official OFAC search tool. The extra cost is trivial compared to a missed designation or a false-positive customer loss.
Log everything. Regulators care about process. If you flag a customer and clear them, you need the JSON, the threshold, the list version, and the reviewer decision. Do not rely on the vendor's dashboard as your audit trail.
Separate crypto wallet screening from name screening. The Sanctions Screener API has a /screen_crypto endpoint for a reason. Wallet addresses are deterministic. Names are probabilistic. Do not let your name-matching fuzziness leak into your blockchain AML logic.
Use webhook monitoring for new designations. Sanctions lists change. The /monitor endpoint can alert you when a new designation matches an existing customer. Retroactive screening is how you catch the customer who was clean yesterday and sanctioned today.
I also want to share a failure. On 14 August, our internal test flagged a legitimate contractor named Sergei Ivanov because the API returned entity ID 16688 at score 1.0. We spent four hours pulling OFAC records, checking birth dates, and cross-referencing programs before we could clear him. The API was correct. The match was real. The person was not the sanctioned individual. That is four hours of reviewer time for a true positive that was not the same person. There is no clean lesson here. Name matching is hard.
The Gap: What Are We Still Missing?
The biggest unresolved question from this experiment is not which API is better. It is how a small team builds a reproducible compliance process when the underlying tools disagree with each other.
I can compare two APIs. I can log their responses. I can set thresholds. But I cannot point to a regulator and say "here is the official definition of a 0.85 fuzzy match." It does not exist. Compliance is interpretive. The tools are probabilistic. The gap between those two facts is where risk lives.
I am also left wondering about the EU and UN lists. The response returned 50 EU matches and 1 UN match for Sergei Ivanov, compared to 50 OFAC matches. Are EU and UN matchers using the same phonetic rules as OFAC? The API returns them in the same payload, but the source lists have different structures, update cadences, and transliteration practices. A single threshold across all of them may be a category error.
Then there is the cost question. Two APIs means two subscriptions, two integrations, two sets of rate limits. For a bootstrapped fintech, that is real money. But the cost of a single missed OFAC hit, or a single customer churned by a false positive, is also real. I do not have a clean answer for where the line is.
How to use Sanctions Screener API
If you want to reproduce the experiment or integrate this into your own workflow, the Sanctions Screener API is available on RapidAPI and documented on GitHub.
- RapidAPI: https://rapidapi.com/On13uka/api/sanctions-screener?utm_source=devto&utm_medium=article&utm_campaign=sanctions-screener-api&utm_content=cta
- GitHub: https://github.com/On13uka/sanctions-screener-api
curl example
curl --request POST \
--url https://sanctions-screener.p.rapidapi.com/screen \
--header 'Content-Type: application/json' \
--header 'x-rapidapi-key: YOUR_RAPIDAPI_KEY' \
--data '{
"name": "Sergei Ivanov",
"threshold": 0.7,
"lists": ["OFAC", "UN", "EU", "UK", "BIS"]
}'
Python example
import requests
url = "https://sanctions-screener.p.rapidapi.com/screen"
headers = {
"Content-Type": "application/json",
"x-rapidapi-key": "YOUR_RAPIDAPI_KEY"
}
payload = {
"name": "Sergei Ivanov",
"threshold": 0.7,
"lists": ["OFAC", "UN", "EU", "UK", "BIS"]
}
response = requests.post(url, json=payload, headers=headers)
data = response.json()
print(f"Query: {data['query']}")
print(f"Total matches: {data['total_matches']}")
print(f"OFAC: {data['ofac_matches']}, UN: {data['un_matches']}, EU: {data['eu_matches']}")
for match in data.get("matches", [])[:3]:
print(
f"- {match['name']} ({match['source']}) "
f"score={match['match_score']} type={match['match_type']}"
)
exp = match.get("match_explanation", {})
print(f" matched_field={exp.get('matched_field')} tokens={exp.get('tokens_matched')}")
The API also supports crypto wallet screening via /screen_crypto and ongoing monitoring via /monitor. Those are worth evaluating separately from name screening, because the error modes are completely different.
Closing
I went into this experiment assuming the hard part of sanctions compliance was finding a good API. I came out believing the hard part is deciding what to do when two good APIs disagree. The Sanctions Screener API gave me 101 matches, explainable down to the token and phonetic encoder. That is valuable. But no single API can be the final word on a name like Sergei Ivanov.
If you have been relying on one provider and one threshold, you are not doing compliance. You are doing vendor worship. The teams that survive the next wave of sanctions enforcement will be the ones that treat screening as a comparative, explainable process, not a single green check.
What is the one secondary check you always forget to run after your primary API returns a fuzzy HIGH match?
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support