This is the 5th stream in a series. Previous ones: HS codes, document parsing, finance validators, watch/monitoring. This time β compliance screening against OFAC, EU, UK, UN sanctions lists, plus PEP screening and entity verification.
Same playbook each time: one Cloudflare Worker + one JSON list + one generator script β 100 SEO pages + landing + Suby product.
Live: https://jsonexlab.com/compliance
What it does
Screen any person or company against:
- OFAC SDN (US Treasury, 19,416 entries)
- OFAC Consolidated (non-SDN)
- UK OFSI (UK sanctions)
- EU Consolidated (EU sanctions)
- UN Security Council (UN sanctions)
- PEP (Politically Exposed Persons)
- Entity verification (company registries: US-DE, UK, DE, UA, etc.)
Live demo:
curl "https://compliance-engine.boring-saas-infra.workers.dev/check/sanctions?q=Rosneft"
β
{
"check_type": "SANCTIONS",
"query": "Rosneft",
"status": "HIT",
"matches": true,
"results": [
{
"list": "OFAC_SDN",
"name": "OPEN JOINT-STOCK COMPANY ROSNEFT OIL COMPANY",
"type": "entity",
"country": "Russia",
"programs": ["UKRAINE-EO13662", "RUSSIA-EO14024"],
"ids": [
{"type": "Registration ID", "number": "1027700043502"},
{"type": "Tax ID No.", "number": "7706107510"},
{"type": "Website", "number": "www.rosneft.com"}
],
"addresses": [{"street": "26/1 Sofiyskaya Embankment", "city": "Moscow", "country": "Russia"}],
"score": 1
}
]
}
Why this one was harder
Three things made this stream different from previous ones:
1. Cloudflare Workers cannot fetch OFAC directly
OFAC lives on sanctionslistservice.ofac.treas.gov. Cloudflare Workers fails with HTTP 525 (SSL handshake error) or 403 (missing User-Agent). This is a TLS incompatibility on the origin side β nothing you can fix from the Worker.
Solution: move ingestion to GitHub Actions (6 GB RAM, normal TLS stack, works with any URL).
2. 30 MB XML doesn't fit in 128 MB Worker RAM
Naive approach β fetch XML β parse β insert β dies with error 1102 (Worker exceeded resource limits).
Solution: GitHub Actions downloads the XML, parses it in Node.js, and inserts directly into Cloudflare D1 via the D1 HTTP API.
3. D1 free tier has a daily row-write limit
100,000 row writes per day. A single OFAC ingestion = 19,416 rows Γ 2 (UPSERT counts as insert + update) = ~39,000 writes. Two ingestions in one day β quota hit.
Solution: cron runs once per day at 01:00 UTC. Quota resets at midnight UTC, so the ingestion always fits.
The architecture
GitHub Actions (cron 0 1 * * *)
β
βββ Download OFAC SDN XML (30 MB) via curl
βββ Download UK OFSI CSV (50 MB) via curl
βββ Parse in Node.js (6 GB RAM available)
βββ POST to Cloudflare D1 HTTP API
β
βΌ
Cloudflare D1 (compliance-db)
βββ sanctions (19,416+ entries)
βββ pep
βββ entity
βββ license
βββ runs (ingestion log)
βββ search_log
β
βΌ
Cloudflare Worker (compliance-engine)
βββ GET /check/sanctions?q=...
βββ GET /check/pep?q=...
βββ GET /check/entity?q=...
βββ GET /stats
βββ Cron triggers (legacy, not used)
β
βΌ
Cloudflare Pages (jsonexlab.com)
βββ /compliance (landing)
βββ /compliance/{slug} (100 SEO pages)
βββ /sitemap.xml (729 URLs)
The GitHub Actions workflow
.github/workflows/ingest-ofac.yml:
name: Ingest OFAC Sanctions
on:
schedule:
- cron: '0 1 * * *'
workflow_dispatch:
jobs:
ingest:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run OFAC ingestion
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: node scripts/ingest-ofac.js
Simple. GitHub Actions does the heavy lifting, Worker stays light.
The parser
Core is the OFAC SDN XML parser β regex-based, no dependencies:
function parseOneSdnEntry(entry, listCode = "OFAC_SDN") {
const uid = extractTag(entry, "uid");
if (!uid) return null;
const lastName = decodeXmlEntities(extractTag(entry, "lastName") || "");
const firstName = decodeXmlEntities(extractTag(entry, "firstName") || "");
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || lastName;
if (!fullName) return null;
const type = mapSdnType(extractTag(entry, "sdnType") || "Entity");
const programs = extractAllTags(entry, "program");
// aliases, ids, addresses, nationality, dob...
return {
list_code: listCode,
entity_id: uid,
name: fullName,
name_normalized: normalizeName(fullName),
aliases: aliases.length ? aliases : null,
type,
country,
programs: programs.length ? programs : null,
dob,
ids: ids.length ? ids : null,
addresses: addresses.length ? addresses : null,
remarks: remarks || null,
};
}
Whole parser is ~250 lines. Handles:
- 19,416 SDN entries
- Aliases (aka blocks)
- IDs (passport, tax, registration, website, email)
- Addresses (multiple per entity)
- Nationality / citizenship
- Date of birth
Gotcha I hit: extractAllTags initially captured nested XML tags. <programList><program>CUBA</program></programList> returned ["<program>CUBA"] instead of ["CUBA"]. Fix: strip nested tags with .replace(/<[^>]+>/g, "").
The fuzzy matching
For a compliance tool, exact string matching is useless β you need fuzzy. Here's what I use in the Worker:
function matchScore(queryNorm, candidateNorm) {
const jw = jaroWinkler(queryNorm, candidateNorm);
const tok = tokenSimilarity(queryNorm, candidateNorm);
const lev = levenshtein(queryNorm, candidateNorm);
const levScore = 1 - lev / Math.max(queryNorm.length, candidateNorm.length);
return 0.5 * tok + 0.35 * jw + 0.15 * levScore;
}
Three algorithms weighted:
- Token similarity (0.5) β handles word order ("Rosneft Oil Company" vs "Oil Company Rosneft")
- Jaro-Winkler (0.35) β handles typos and short strings
- Levenshtein (0.15) β tie-breaker
Plus normalization before matching:
- Lowercase
- ASCII-fold (Cyrillic β Latin:
ΠΠ»Π΅ΠΊΡΠ°Π½Π΄Ρβoleksandr) - Strip punctuation
- Remove legal suffixes (
LLC,Inc,GmbH,PJSC, etc.) - Collapse whitespace
Threshold 0.82 by default. Rosneft β OPEN JOINT-STOCK COMPANY ROSNEFT OIL COMPANY scores 1.0 (100% match). Gazprom β CLEAR (correct β Gazprom itself isn't on SDN, but Gazprombank and Gazpromneft are).
The 100 SEO pages
products-compliance.json (excerpt):
[
{"slug":"sanctions-ofac-sdn","name":"OFAC SDN Sanctions Check","type":"sanctions","category":"Sanctions","list_code":"OFAC_SDN"},
{"slug":"sanctions-ofac-sdn-individuals","name":"OFAC SDN β Individuals","type":"sanctions","type_filter":"individual"},
{"slug":"sanctions-russia","name":"Russia Sanctions Screening","type":"sanctions","program_filter":"RUSSIA"},
{"slug":"pep-head-of-state","name":"PEP β Head of State","type":"pep","level":"head_of_state"},
{"slug":"entity-verify-llc","name":"LLC Entity Verification","type":"entity","entity_type":"LLC"},
{"slug":"verify-medical-license","name":"Medical License Verification","type":"license","license_type":"medical"}
]
100 entries across 5 categories:
- 30 Sanctions β by list (OFAC SDN, EU, UK, UN) + by type (individual, entity, vessel, aircraft) + by program (Russia, Iran, DPRK, Cuba, etc.)
- 20 Licenses β medical, nursing, legal, contractor, real estate, insurance, accounting, pharmacy, dental, etc.
- 20 Checklists β country + segment (Germany import, US export, fintech, crypto, healthcare, etc.)
- 15 PEP β head of state, minister, parliament, judiciary, military, SOE executive, etc.
- 15 Entity β LLC, Corp, PLC, Ltd, GmbH, AG, SA, SARL, BV, NV, Oy, AB, Pty, LLP, Π’ΠΠ
generator-compliance.cjs:
node generator-compliance.cjs
β
β
ΠΠ³Π΅Π½Π΅ΡΠΎΠ²Π°Π½ΠΎ 100 ΡΡΠΎΡΡΠ½ΠΎΠΊ Ρ output/compliance/
β
sitemap.xml ΠΎΠ½ΠΎΠ²Π»Π΅Π½ΠΎ β Π²ΡΡΠΎΠ³ΠΎ 729 URL
β
llms.txt ΠΎΠ½ΠΎΠ²Π»Π΅Π½ΠΎ β 100 compliance checks
Each page has:
- Unique
<title>+ meta description - Working live demo (hits real Worker with real D1 data)
- Schema.org
Product+BreadcrumbList+FAQPage - 5 FAQ items customized per check type
- Related checks chips
- Pricing block with Suby checkout
Pricing
- Free β 5 checks/day, no API key
- Pro ($99/mo) β 500 checks/mo, all 5 lists, PEP, entity, license, webhooks
Higher price than other streams ($19.99) because:
- Recurring compliance need (regulatory requirement)
- B2B audience pays more
- Different value prop (legal risk mitigation, not a one-shot check)
Deployment
Two Cloudflare accounts came in handy:
-
Account #1 β
jsonexlab.com(custom domain) + Pages project - Account #2 β Workers + D1 + R2 + KV
Workers stay on *.boring-saas-infra.workers.dev, Pages on jsonexlab.com. Clean separation.
Full stack
- Cloudflare Workers β API (compliance-engine, finance-validators, watch-engine)
- Cloudflare D1 β SQLite storage (compliance-db: 19,416+ sanctions)
- Cloudflare R2 β raw file buffer (not actively used β kept as fallback)
- Cloudflare KV β response cache
- Cloudflare Pages β static site (729 URLs)
- GitHub Actions β daily ingestion (OFAC + UK OFSI)
- Node.js generators β no deps, ~300 lines each
- Suby β payments (crypto + card)
- IndexNow β 729 URLs pinged to Bing + Yandex
Total monthly cost: $0.
What's next
- EU + UN ingestion β currently only OFAC + UK
- PEP data β WikiData + OpenSanctions (needs commercial licence)
- Webhook alerts β get notified when a party on your list gets sanctioned
-
Google Sheets add-on β
=SANCTIONS_CHECK(A1)
Try it
- Landing: https://jsonexlab.com/compliance
- Example pages:
- API: https://compliance-engine.boring-saas-infra.workers.dev/check/sanctions?q=Rosneft
If you're building KYC, AML, or trade compliance workflows β give it a shot. Free tier is genuinely free.
Happy to answer questions about the Cloudflare Workers + D1 + GitHub Actions setup, or the programmatic SEO pattern.
Top comments (2)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.