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.

Updated 8 October 2026

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.

Customer dashboard screenshot: a sanctions screen run in the ConsoleCustomer dashboard screenshot: a sanctions screen run in the Console
GET /sanctions/screen in the dashboard's Console: set the name and the threshold, send the request and read the matches (3 credits). Sanctions screening is beta; a name screen is not a clearance. Open it in the dashboard

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.list and hit.list_url
  • Match score and threshold: from hit.score and 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 aliases field 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).

Frequently asked questions

Which sanctions lists are covered in one call?
EU consolidated, UN, OFAC SDN, UK Sanctions List (FCDO), the Australian DFAT Consolidated List, the French Registre national des gels, plus the full US Consolidated Screening List of export-control lists. The Prometiam /sanctions/screen endpoint (beta) queries all seven sources with one fuzzy match and returns matches with confidence scores.
What threshold should I use?
80 is the production default. Drop to 70 for high-stakes onboarding (lending, payment processing) where false negatives are costly. Raise to 85 for high-volume flows where review cost dominates.
Can I screen entities and vessels too, not just persons?
Yes. Pass entity_type=person, entity, vessel, or any (default).
Is this exposed on the 14-day free trial?
Up to 25 calls a month, to try it. Full sanctions screening is a Professional-tier scope (€29.99/month, 10,000 credits/month). The Starter tier includes sanctions monitoring (watchlists + change feed).