Skip to content

Repository files navigation

OFAC SDN Screening API

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
}

Why this exists

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.

What you get

  • 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

How it works

                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚  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

The two-stage trick

A naive search would run a fuzzy scorer against all 40 000 names per request β€” workable, but wasteful. Instead:

  1. 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.
  2. 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.

Why these scoring algorithms?

  • 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 Khaled and someone searches Khalid.
  • 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).

API reference

GET /api

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.

Response shape

{
  "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
}

Wallet screening

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.

Errors

Status Meaning
400 Missing name/address param, or unknown id in lists.
500 LISTS_MANIFEST_URL/OFAC_INDEX_URL not configured or R2 unreachable.

Multiple lists

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.)

Bundled Cuban lists

Two scrapers ship in scripts/sources/:

npm run import:lists          # scrape β†’ data/{pcc,anpp,fhrc}-entities.json
npm run import:lists:upload   # + push lists and manifest.json to R2

The cuba-import.yml workflow re-scrapes weekly (Mondays 06:00 UTC) using the same R2_* secrets as the OFAC import.

Adding your own list

  1. Drop a module in scripts/sources/ exporting { id, label, source, fetchEntities }, where fetchEntities() resolves to entities shaped like { id, source, type, programs, names: [{ full }] } (extra fields pass through to API responses).
  2. Register it in the SOURCES array of scripts/import-lists.mjs.
  3. 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.

Getting started

1. Generate the dataset

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.xml

Output: data/ofac-entities.json (~9 MB, ~19 000 entities).

2. Upload to Cloudflare R2

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

3. Configure & run

# In .env
OFAC_INDEX_URL=https://pub-<hash>.r2.dev/ofac-entities.json
npm install
npm run dev
curl 'http://localhost:3000/api?name=putin'

Deploy

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+.

Keeping the data fresh

OFAC updates the SDN list multiple times per week. To refresh manually:

npm run import:upload   # re-download, re-parse, re-upload to R2

This 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.

Cost & performance

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

Design notes

Why not Postgres / Neon / SQLite-FTS5?

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.

Why Cloudflare R2?

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.

Why not just bundle the JSON?

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.

Limitations & honest caveats

  • Substring matches aren't free: a query of "khan" will match dozens of entities containing that token. Filter with minScore=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.

Stack

License

MIT β€” see LICENSE.

Releases

Packages

Contributors

Languages