A tiny, fast HTTP API that answers one question:
"Is this name on the U.S. Treasury's sanctions list, and how confident are you?"
Send a name, get back a ranked list of matches from the OFAC Specially Designated Nationals (SDN) list β plus optional parallel watchlists (Cuban PCC directory, ANPP deputies, FHRC Represores Cubanos, and any list you add) β with a 0β100 confidence score. Built for the kind of compliance checks fintech, crypto, and remittance products do thousands of times a day β but light enough to run on a single serverless function.
$ curl 'https://your-deployment.vercel.app/api?name=putin&minScore=85'{
"query": "putin",
"total": 3,
"results": [
{ "score": 100, "matchedName": { "full": "PUTIN, Vladimir Vladimirovich" }, "entity": { "type": "Individual", "programs": ["RUSSIA-EO14024"] } },
{ "score": 88, "matchedName": { "full": "PUTINA, Maria" } },
{ "score": 86, "matchedName": { "full": "PUTINA, Yekaterina" } }
],
"tookMs": 4
}OFAC publishes the SDN list as a ~100 MB XML file with ~19 000 entities and ~50 000 aliases (transliterations, A.K.A., F.K.A., name variants in non-Latin scripts, etc.). Doing a real-time fuzzy match against that β handling typos, word order, missing middle names, Spanish-vs-English spellings, Cyrillic transliterations β is non-trivial.
Most teams reach for an enterprise compliance vendor and pay per-call. This repo is the inverse: a self-hosted, single-file dataset, zero database, that turns the problem into a ~150-line search routine running entirely in memory on a serverless function.
- Sub-10 ms warm response on a single function instance
- Fuzzy scoring tuned for names: Jaro-Winkler + token-set ratio, so
"Maria del Carmen Lopez"matches"LOPEZ, Maria Carmen"at 100 - Diacritic & case-insensitive:
JosΓ©βjose,AL-QA'IDAβal qaida - All OFAC programs β Russia/Ukraine, Iran, Cuba, DPRK, SDGT, CAATSA, narcotics, etc. β surfaced in the response
- No database: dataset lives as a single JSON file in Cloudflare R2 (or any object store)
- No egress costs: Cloudflare R2 has zero egress fees
- Easy to update: re-run one script when OFAC updates the list
βββββββββββββββββββββββββββββββββββββββββββββββ
β Cold-start (once per warm Fluid instance) β
β β
OFAC XML βββ β βββββββββββββββββββ ββββββββββββββββ β
(~100 MB) β β β ofac-entities βββββΆβ trigram β β
β β β .json (~8 MB) β β inverted β β
import βββββ΄βββΆβ β in Cloudflare R2β β index in RAM β β
script β βββββββββββββββββββ ββββββββ¬ββββββββ β
β β β
ββββββββββββββββββββββββββββββββββββΌββββββββββββ
β
GET /api?name=putin βββΆ extract trigrams βββΆ top 400 candidates
β
βΌ
Jaro-Winkler + token-set scoring (per candidate)
β
βΌ
best score per entity β ranked JSON
A naive search would run a fuzzy scorer against all 40 000 names per request β workable, but wasteful. Instead:
- Candidate retrieval (microseconds): split the query into 3-char windows ("trigrams") and use an inverted index to find the ~400 names that share the most trigrams. This eliminates 99% of the dataset cheaply.
- Full scoring (milliseconds): run
max(Jaro-Winkler, token-set ratio)only on the candidates, collapse to the best score per entity, return the top N.
The trigram trick is the same idea Postgres' pg_trgm extension uses β except here it's ~40 lines of plain JavaScript and lives in the function's memory.
- Jaro-Winkler is the AML industry's default for name matching: it weighs shared prefixes heavily (people get their first letters right even when they typo the rest) and handles transpositions naturally β useful when OFAC has
Khaledand someone searchesKhalid. - Token-set ratio (the rapidfuzz recipe) ignores word order and extra tokens. So
"Maria del Carmen Lopez Hernandez"and"LOPEZ, Maria Carmen"still match at 100, even though one has 5 tokens and the other 3.
Taking the max of the two means a hit on either dimension is enough β biased toward false positives over false negatives, which is what you want for compliance screening (a missed sanction is much worse than a manual review).
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
name |
string | β | β | Name to screen. Required unless address is given. |
address |
string | β | β | Digital currency address to screen (exact, case-insensitive). Takes precedence over name. |
limit |
int | 10 | 1β50 | Max ranked matches to return (name search only). |
minScore |
int | 70 | 0β100 | Drop matches below this score (name search only). |
lists |
string | all | β | Comma-separated list ids to search (e.g. lists=ofac or lists=pcc,anpp). Default searches every loaded list. Unknown ids β 400. |
The SDN list flags ~960 digital currency addresses (BTC, ETH, TRX, USDT, XMR, β¦) as "Digital Currency Address" features. Screen one with:
$ curl 'https://your-deployment.vercel.app/api?address=0x098B716B8Aaf21512996dC57EB0615e2383E2f96'Address matches are exact (case-insensitive) β no fuzzy stage. Each result carries matchedAddress and currency instead of matchedName, always with score: 100.
| Status | Meaning |
|---|---|
400 |
Missing name/address param, or unknown id in lists. |
500 |
LISTS_MANIFEST_URL/OFAC_INDEX_URL not configured or R2 unreachable. |
The engine is list-agnostic: OFAC is just one list among N. Every list is a JSON file in R2 with the same { entities: [...] } shape, enumerated by a manifest.json:
{
"version": 1,
"lists": [
{ "id": "ofac", "label": "OFAC SDN", "key": "ofac-entities.json" },
{ "id": "pcc", "label": "PCC β Directorio de personas", "key": "lists/pcc.json" },
{ "id": "anpp", "label": "ANPP β Diputados", "key": "lists/anpp.json" }
]
}Set LISTS_MANIFEST_URL to the manifest's public URL and the runtime loads every list into one shared trigram index at cold-start, tagging each entity with its list id and source. (key resolves relative to the manifest URL, so everything lives in one public bucket. Without LISTS_MANIFEST_URL, the runtime falls back to OFAC_INDEX_URL and behaves exactly as the original OFAC-only API.)
Two scrapers ship in scripts/sources/:
pccβ the PCC people directory (~100 senior party officials, with cargo and entity)anppβ the ANPP deputies roster (~470 deputies, with cargo and org memberships)fhrcβ the FHRC Represores Cubanos database (~1,700 documented individuals, with institution; sourced from the site's public Supabase API since the frontend is a SPA)
npm run import:lists # scrape β data/{pcc,anpp,fhrc}-entities.json
npm run import:lists:upload # + push lists and manifest.json to R2The cuba-import.yml workflow re-scrapes weekly (Mondays 06:00 UTC) using the same R2_* secrets as the OFAC import.
- Drop a module in
scripts/sources/exporting{ id, label, source, fetchEntities }, wherefetchEntities()resolves to entities shaped like{ id, source, type, programs, names: [{ full }] }(extra fields pass through to API responses). - Register it in the
SOURCESarray ofscripts/import-lists.mjs. - Run
npm run import:lists:uploadβ the manifest is upserted automatically and the runtime picks the list up on next cold-start. No runtime code changes.
The repo does not ship OFAC data β that lives in R2.
# Downloads from OFAC if no local sdn_enhanced.xml exists.
npm run import
# Or point it at a specific file:
node scripts/import-ofac.mjs --xml=/path/to/sdn_enhanced.xmlOutput: data/ofac-entities.json (~9 MB, ~19 000 entities).
Create an R2 bucket with public access enabled (r2.dev domain or a custom domain).
cp .env.example .env
# Fill in R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET
npm run import:upload# In .env
OFAC_INDEX_URL=https://pub-<hash>.r2.dev/ofac-entities.jsonnpm install
npm run dev
curl 'http://localhost:3000/api?name=putin'Drop-in on Vercel β set OFAC_INDEX_URL in the project's environment and you're live. Should run on any platform that supports Next.js 16 + Node 20+.
OFAC updates the SDN list multiple times per week. To refresh manually:
npm run import:upload # re-download, re-parse, re-upload to R2This repo also ships a GitHub Actions workflow (.github/workflows/ofac-import.yml) that runs the import on weekdays at 23:00 UTC and pushes the result to R2 β set the R2_* repository secrets and it's fully automated. The API picks up the new index the next time a function instance cold-starts.
| Metric | Value |
|---|---|
| Dataset size | ~9 MB JSON (down from ~100 MB XML) |
| R2 storage cost | <$0.001/month |
| R2 egress cost | $0 (Cloudflare has no egress fees) |
| Function cold-start | ~1β3 s (download + index build, paid once) |
| Warm-request latency | 2β10 ms |
| Entities indexed | ~19 000 |
| Names (incl. aliases) | ~50 000 |
I considered all three. For a fixed-size 18 k-entity dataset that fits comfortably in memory, the cold-start + in-memory approach beats them on simplicity and warm latency. There's no database to provision, no migrations, no connection pool, no separate index to rebuild. If the dataset ever grew to a consolidated EU + UN + UK + OFAC + SECO scope (~200 k entries), Postgres with GIN/pg_trgm would become the right call.
Three reasons: zero egress fees, S3-compatible API (so the import script uses the standard AWS SDK), and public bucket URLs out of the box (no CDN to configure). Any S3-compatible store works β swap the endpoint in the import script.
Tempting β the JSON is ~8 MB and would fit in a serverless function bundle. But then the OFAC data is tied to deployments: every refresh requires a CI run. Keeping it in R2 means a single npm run import:upload updates every running instance on next cold-start.
- Substring matches aren't free: a query of
"khan"will match dozens of entities containing that token. Filter withminScore=85+for stricter results, or post-process the response. - No phonetic matching: Soundex/Metaphone aren't applied. For matches across radically different scripts (e.g. Cyrillic-only name vs. Latin query), you'll rely on OFAC's own transliterations being in the dataset (they usually are).
- No address/DOB filtering: this scores names only. Real compliance flows should layer additional checks (DOB, nationality, addresses) on the returned candidates.
- Not a substitute for legal review: hits are leads, not verdicts.
- Next.js 16 (App Router, route handler only β no React in the runtime path)
- Cloudflare R2 for dataset storage
fast-xml-parserfor the one-time XML β JSON conversion@aws-sdk/client-s3for R2 uploads (devDep β never bundled in the runtime)
MIT β see LICENSE.
{ "query": "vladimir putin", "normalizedQuery": "vladimir putin", "total": 1, "results": [ { "score": 100, "matchedName": { "full": "PUTIN, Vladimir Vladimirovich", "first": "Vladimir", "last": "PUTIN", "isPrimary": true, "aliasType": null, "script": "Latin" }, "entity": { "id": "21340", "identityId": "12824", "type": "Individual", "programs": ["RUSSIA-EO14024"], "sanctionsTypes": ["Block"], "names": [ /* every alias OFAC has on file */ ] } } ], "meta": { "datasetGeneratedAt": "2026-05-21T10:32:11.000Z", "entitiesIndexed": 17920, "namesIndexed": 41892 }, "tookMs": 4 }