Tutorial · JavaScript / Node.js
Screen names against EU + UN + OFAC + UK + Australia (DFAT) + FR gels + US CSL sanctions in JavaScript
Trigram fuzzy match across seven official sources in one Node.js call (beta). Threshold tuning, false-positive review, audit trail.
The problem
Sanctions screening is the AML control most often implemented poorly. The standard failures: only checking one list (EU but not UN, or OFAC but not UK), exact-match-only screening that misses transliteration variants, lists refreshed weekly instead of daily, no confidence scores, no audit trail. Each official list is published in a different format under a different schedule by a different authority.
Prometiam's /sanctions/screen endpoint does the seven-source union, the fuzzy match, and the confidence scoring server-side. Your Node code just sets a threshold and reads matches.
The full working module (~40 lines)
// sanctions.mjs — production-shape sanctions screener
const API = 'https://api.prometiam.com/functions/v1/risk-api'
export class SanctionsError extends Error {
constructor(msg, status) { super(msg); this.name = 'SanctionsError'; this.status = status }
}
export async function screen(name, opts = {}) {
const {
threshold = 80, // 50–100. 80 is balanced. Production default.
entityType = 'any', // 'person' | 'entity' | 'vessel' | 'any'
limit = 20,
apiKey = process.env.PROMETIAM_API_KEY,
timeoutMs = 8000,
} = opts
if (!apiKey) throw new SanctionsError('Missing PROMETIAM_API_KEY', 0)
if (!name || name.trim().length < 2) throw new SanctionsError('Name too short', 400)
const url = new URL(`${API}/sanctions/screen`)
url.searchParams.set('name', name.trim())
url.searchParams.set('threshold', String(threshold))
url.searchParams.set('entity_type', entityType)
url.searchParams.set('limit', String(limit))
const ctl = new AbortController()
const t = setTimeout(() => ctl.abort(), timeoutMs)
try {
const r = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
signal: ctl.signal,
})
if (!r.ok) {
const body = await r.text().catch(() => '')
throw new SanctionsError(`Sanctions API ${r.status}: ${body}`, r.status)
}
const j = await r.json()
return {
hits: j.data || [],
query_ms: j.meta?.query_ms,
rate_limit: j.meta?.rate_limit,
}
} finally {
clearTimeout(t)
}
}
// Convenience: classify the result for a 3-tier review queue
export function classify(hits) {
if (!hits.length) return { decision: 'pass', tier: null }
const top = hits[0].score
if (top >= 90) return { decision: 'block', tier: 'red', hits }
if (top >= 80) return { decision: 'review', tier: 'amber', hits }
return { decision: 'pass-with-note', tier: 'green', hits }
}Using it in an onboarding flow
import { screen, classify } from './sanctions.mjs'
async function onboardCustomer(legalName, officers) {
// 1. Screen the legal entity
const legal = await screen(legalName, { threshold: 85, entityType: 'entity' })
const legalDecision = classify(legal.hits)
if (legalDecision.decision === 'block') {
return { status: 'rejected', reason: 'sanctions_match_legal_entity', evidence: legal.hits[0] }
}
// 2. Screen each officer
const officerResults = await Promise.all(
officers.map(o => screen(o.full_name, { threshold: 85, entityType: 'person' }))
)
const blocks = officerResults
.map((r, i) => ({ officer: officers[i], decision: classify(r.hits), hits: r.hits }))
.filter(x => x.decision.decision === 'block')
if (blocks.length) {
return { status: 'rejected', reason: 'sanctions_match_officer', evidence: blocks }
}
// 3. Queue any 'review' hits for manual review
const reviews = officerResults
.map((r, i) => ({ officer: officers[i], decision: classify(r.hits), hits: r.hits }))
.filter(x => x.decision.decision === 'review')
return {
status: reviews.length ? 'pending_review' : 'approved',
reviews,
}
}What you get back
{
"data": [
{
"id": 90212,
"name": "JUAN PEREZ",
"type": "person",
"score": 92,
"list": "EU",
"list_url": "https://webgate.ec.europa.eu/.../...",
"added_on": "2024-09-12",
"country_of_birth": null,
"aliases": ["JUAN A. PEREZ", "J. PEREZ"]
}
],
"meta": {
"query_ms": 47,
"rate_limit": { "rpm_remaining": 199, "daily_remaining": 9999 }
}
}Each hit includes the source list (EU, UN, OFAC, OFSI (the UK Sanctions List, published by the FCDO), DFAT (Australian Consolidated List), FR_GELS, plus US export-control lists such as US Entity List (BIS)), a confidence score (0–100), and a list_url pointing at the canonical entry on the issuing authority's website — the audit trail anchor.


Threshold tuning
- 50–69: very loose. Useful when missing a match is catastrophic and false positives are cheap. Most names return at least one weak hit.
- 70–79: liberal. Catches transliteration and short-name variants. ~5% false-positive rate on common names.
- 80 (default): balanced. Catches most variants without flooding reviewers.
- 85–90: strict. Production default for high-volume payment processing.
- 95+: near-exact. Hot-path pre-check after a full screen at lower threshold.
Best practice: screen at 80, auto-block ≥90, queue 80–89 for human review. The classify() helper above implements this.
Audit trail
Compliance and SAR (Suspicious Activity Report) audits expect specific fields per match:
- Match name + alias: from the response
- Source list + URL: from
hit.listandhit.list_url - Match score and threshold: from
hit.scoreand your call - Date the entity was added: from
hit.added_on - Date and time of your check: server-side timestamp from your request log
- Reviewer decision: stored by your application
Persist all of the above in an immutable transaction log. Most teams use an append-only Postgres table or a dedicated event store (e.g. EventStore, Kafka with retention).
Common pitfalls
- Forgetting to screen aliases. The hit's
aliasesfield tells you why a match fired; surface this to the reviewer so they understand the connection. - Using exact match only. Most real-world sanctions evasion uses transliteration variants (Cyrillic ↔ Latin, vowel substitutions). Trigram fuzzy is the floor, not a luxury.
- Not screening on every change. Lists update daily; a customer who passed at onboarding may match next week. Re-screen on every meaningful event (transaction, profile update, periodic sweep).
- Throwing on errors. If the API is briefly unavailable, fail closed (block transaction) — not open. The module above throws; the calling code must decide block-vs-retry.
- Logging full request/response with PII. The matched name is special-category-adjacent. Log the screen request with a hashed reference to the customer record, not the raw name in plaintext.
FAQ
Which lists are covered in one call?
EU consolidated, UN, OFAC SDN, the UK Sanctions List (FCDO), Australian DFAT Consolidated List, French Registre des gels, plus the US Consolidated Screening List. All seven sources with one fuzzy match. Source: Department of Foreign Affairs and Trade website – www.dfat.gov.au (Australian Sanctions Consolidated List), licensed CC BY 4.0. Adapted by Prometiam.
What threshold should I use?
80 default. 70 for high-stakes onboarding. 85 for high volume.
Persons, entities, vessels?
All — set entity_type to person, entity, vessel, or any.
On the 14-day free trial?
Up to 25 calls a month, to try it. Full screening is Professional-tier (€29.99/month).
Related
- Sanctions screening use case
- KYB onboarding pattern — sanctions in the verification flow
- Verify a Spanish NIF in Python
- Daily BORME watchlist