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 (1)
tr.ee/dev-to