Verification

AML screening

Sanctions, PEP, adverse-media and POI screening against OpenSanctions plus your own custom lists — thresholds, hit routing, and ongoing monitoring.

The AML_SCREENING module screens an applicant's identity against global watchlists — sanctions, politically-exposed persons (PEP), adverse media and persons-of-interest (POI). It runs server-side from the identity data already on the session (no applicant capture) and is also available as the stateless /v3/tools/aml-check/ tool.

Screening runs against an OpenSanctions-derived dataset served by a bundled matching engine (yente-style, queried over its /match endpoint). The service re-indexes the sanctions / PEP / adverse-media data on its own schedule, so every screen runs against current data with no manual refresh step. The name, date of birth, nationality, country and document number screened are taken from the identity that document verification hydrated.

Info

This module runs once per person per session at completion time, alongside IP analysis and age estimation — it is a passive check, not an applicant-facing capture step.

How a query is built

The engine assembles a FollowTheMoney Person query from whatever identity data is available. A name is the minimum — with no name to match on, the module goes to review (aml_insufficient_data) rather than screening an empty query. Every other field simply narrows the candidate set:

FieldMaps to (FtM property)
Full name (or first + last)name
Date of birthbirthDate
Nationality (ISO-2)nationality
Country (ISO-2)country
Document numberidNumber
Email / phoneemail / phone

Lists (topics)

The workflow step's lists config selects which topics count. Each candidate's watchlist topics map onto these lists; a candidate on no enabled list never flags the session:

ListCoversTopics folded in
sanctionsSanctions-list entities.sanction*, debarment, export.control
pepPolitically-exposed persons and their close associates (RCA).role.pep, role.rca
adverse_mediaAdverse-media / criminal reporting.crime*, wanted, reg.action, reg.warn
poiPersons of interest (informational unless the source dataset makes it blocking).poi

When lists is omitted, all four are screened. POI-only matches from informational datasets (e.g. unelected local-election candidates) surface as informational notices; POI entities from any other dataset (bribe-takers, war-sanctions POIs) stay blocking. An unknown or empty source dataset fails safe to blocking.

Match scoring and the three tiers

The engine splits candidates into three tiers using the engine's threshold and a lower cutoff:

  • Confirmed hit — the engine returned match: true (score at or above the threshold, default 0.7). Lands in matches.
  • Possible match — scored at or above the cutoff (default 0.5) but below the threshold, or a demoted confirmed hit (see below). Lands in possible_matches.
  • Informational — the candidate's only relevant list is informational (poi) and every source dataset is informational. Lands in informational_matches.

Note

Corroboration rule. A sparse watchlist record with a name but no birth date can score 1.0 on an exact-name match with nothing to contradict it. When your query carried a date_of_birth but the matched entity has none to check against, a PEP / adverse-media confirmed match is demoted to a possible match (visible to the reviewer, non-blocking) and flagged uncorroborated: true. Sanctions are exempt — name-based blocking is the expected posture there.

Result shape and risk levels

The module produces a risk_level plus the three tiers of matches:

{
  "risk_level": "high",
  "matches": [
    {
      "entity_id": "NK-abc123",
      "name": "AARAV SHARMA",
      "schema": "Person",
      "score": 0.94,
      "match": true,
      "lists": ["sanctions"],
      "topics": ["sanction"],
      "datasets": ["us_ofac_sdn"],
      "birth_date": "1990-04-12",
      "countries": ["np"],
      "aliases": ["A. Sharma"],
      "nationalities": ["np"],
      "positions": [],
      "gender": "male",
      "addresses": [],
      "id_numbers": ["PA1234567"],
      "url": ""
    }
  ],
  "possible_matches": [],
  "informational_matches": [],
  "lists_checked": ["adverse_media", "pep", "poi", "sanctions"],
  "provider": "watchlist",
  "dataset": "default",
  "algorithm": "logic-v1",
  "threshold": 0.7
}
risk_levelMeaning
highAt least one confirmed hit on a blocking list.
mediumOnly possible (sub-threshold) matches.
lowOnly informational notices (e.g. POI).
clearNothing relevant found.

Each match carries the score, the lists it appeared on, and the country / DOB / alias / position metadata an operator needs to adjudicate. Read the result from GET /v3/session/<id>/ or decision_v3.aml_screenings on the decision endpoint.

How a hit routes: review vs decline

A confirmed hit doesn't automatically decline — the outcome depends on the per-list action configured on the step. Each screened list carries one of three actions, and the strongest action among the lists actually hit wins (a legacy step with no list_actions defaults every list to review):

ActionModule statusEffect
flagapprovedRecorded and risk-scored, but non-blocking — the watchlist_match warning still feeds the risk engine.
review ⚠️in_reviewRoutes to manual review and opens a case. Default for a missing action.
declinedeclinedBlocks the session outright.

Possible (sub-threshold) matches only route to review when the step has review_possible_matches enabled — otherwise they're carried for the reviewer's context without blocking. Informational notices are always approved with a watchlist_notice warning.

Fails closed and common outcomes

Info

Fails closed. If the screening engine is unreachable, or there isn't enough identity data to build a query, the module lands in manual review — it never silently passes an unscreened applicant.

ConditionModule statusWarning / reason
Confirmed hit on a decline-action listdeclinedwatchlist_match, <list>_match
Confirmed hit on a review-action listin_reviewwatchlist_match, <list>_match
Confirmed hit on a flag-action listapprovedwatchlist_match (scored, non-blocking)
Possible match, review_possible_matches onin_reviewpossible_watchlist_match
Only informational (POI) noticesapprovedwatchlist_notice
No identity data to screenin_reviewaml_insufficient_data
Screening service unreachablein_reviewaml_screening_unavailable
Nothing relevant foundapproved

Ongoing vs one-time screening

The session screen is one-time, but an AML-screened workflow enrols the applicant in ongoing monitoring. On completion the workflow's AML config (lists, threshold, review_possible_matches, ongoing_monitoring) is copied onto the identity and a next_rescreen_at is scheduled.

A daily sweep re-screens every identity whose next_rescreen_at has come due, against the same lists and threshold the tenant configured. A new hit flips the identity to flagged with aml_status: high so they surface in the console identities list — this is how a customer who becomes sanctioned after onboarding still gets caught. Clears simply refresh the screening timestamps. Turning ongoing_monitoring off de-enrols the identity on the next sweep. If the screening service is down during a sweep, the sweep aborts without advancing next_rescreen_at, so the same identities are retried on the next run.

Standalone AML check

Screen a name ad hoc with the same engine, no session attached:

curl -X POST "$TF_BASE_URL/v3/tools/aml-check/" \
  -H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{
        "full_name": "Aarav Sharma",
        "date_of_birth": "1990-04-12",
        "nationality": "NP",
        "match_threshold": 0.7
      }'
const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/aml-check/`, {
  method: "POST",
  headers: {
    "x-api-key": process.env.TF_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    full_name: "Aarav Sharma",
    date_of_birth: "1990-04-12",
    nationality: "NP",
    match_threshold: 0.7,
  }),
});
const screen = await res.json();
console.log(screen.risk_level, screen.matches.length);
import requests

res = requests.post(
    f"{BASE_URL}/v3/tools/aml-check/",
    headers={"x-api-key": API_KEY},
    json={
        "full_name": "Aarav Sharma",
        "date_of_birth": "1990-04-12",
        "nationality": "NP",
        "match_threshold": 0.7,
    },
    timeout=30,
)
screen = res.json()
print(screen["risk_level"], len(screen["matches"]))
full_namestringbodyrequired

Full name to screen. Alternatively pass first_name + last_name. A query with no name returns 400 name_required.

date_of_birthstringbody

YYYY-MM-DD. Narrows candidates by date of birth (and enables the corroboration rule).

nationalitystringbody
ISO-2 nationality.
countrystringbody
ISO-2 country.
document_numberstringbody
Document number to include in the query (idNumber).
listsarraybody

Which lists to screen: sanctions, pep, adverse_media, poi. Defaults to all four.

match_thresholdnumberbody

Match cutoff. Defaults to the tenant AML threshold (0.7).

The response is the same shape as the module result above. An unreachable engine returns 503 screening_unavailable. See Standalone tools for the full tools surface and rate limits.

Custom lists

Beyond the global datasets, tenants maintain their own risk lists via the Partner API (/api/v1/lists/) — phone, email and identifier lists that flows screen against, with per-list actions (route to review, open a case, or block). Values are normalised on write (phone → digits only, email → lowercased, identifier → trimmed). The document number extracted by ID verification is screened against identifier-type lists independently of the OpenSanctions screen.

Common pitfalls

Warning

  1. A flag action does not block. If you configure a list with flag, a confirmed hit still approves the module — it only records and risk-scores the match. Use review or decline if a hit must gate the applicant.
  2. Possible matches are silent by default. Sub-threshold candidates only route to review when review_possible_matches is enabled. Without it, a medium-risk_level result can still be approved.
  3. Screening needs identity data. AML runs from fields ID verification produced. Enabling AML without a document step (or before identity is hydrated) yields aml_insufficient_data → review, not a clear.
  4. Don't treat the standalone tool as a compliance decision. /v3/tools/aml-check/ is a lookup with no audit trail, session, or ongoing monitoring — use the module for a bearing decision.

FAQ