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:
| Field | Maps to (FtM property) |
|---|---|
| Full name (or first + last) | name |
| Date of birth | birthDate |
| Nationality (ISO-2) | nationality |
| Country (ISO-2) | country |
| Document number | idNumber |
| Email / phone | email / 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:
| List | Covers | Topics folded in |
|---|---|---|
sanctions | Sanctions-list entities. | sanction*, debarment, export.control |
pep | Politically-exposed persons and their close associates (RCA). | role.pep, role.rca |
adverse_media | Adverse-media / criminal reporting. | crime*, wanted, reg.action, reg.warn |
poi | Persons 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 thethreshold, default0.7). Lands inmatches. - Possible match — scored at or above the
cutoff(default0.5) but below the threshold, or a demoted confirmed hit (see below). Lands inpossible_matches. - Informational — the candidate's only relevant list is informational (
poi) and every source dataset is informational. Lands ininformational_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_level | Meaning |
|---|---|
high | At least one confirmed hit on a blocking list. |
medium | Only possible (sub-threshold) matches. |
low | Only informational notices (e.g. POI). |
clear | Nothing 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):
| Action | Module status | Effect |
|---|---|---|
flag | approved ✅ | Recorded and risk-scored, but non-blocking — the watchlist_match warning still feeds the risk engine. |
review ⚠️ | in_review | Routes to manual review and opens a case. Default for a missing action. |
decline ❌ | declined | Blocks 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.
| Condition | Module status | Warning / reason |
|---|---|---|
Confirmed hit on a decline-action list | declined | watchlist_match, <list>_match |
Confirmed hit on a review-action list | in_review | watchlist_match, <list>_match |
Confirmed hit on a flag-action list | approved | watchlist_match (scored, non-blocking) |
Possible match, review_possible_matches on | in_review | possible_watchlist_match |
| Only informational (POI) notices | approved | watchlist_notice |
| No identity data to screen | in_review | aml_insufficient_data |
| Screening service unreachable | in_review | aml_screening_unavailable |
| Nothing relevant found | approved | — |
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_namestringbodyrequiredFull name to screen. Alternatively pass first_name + last_name. A query with no name returns 400 name_required.
date_of_birthstringbodyYYYY-MM-DD. Narrows candidates by date of birth (and enables the corroboration rule).
nationalitystringbodycountrystringbodydocument_numberstringbodyidNumber).listsarraybodyWhich lists to screen: sanctions, pep, adverse_media, poi. Defaults to all four.
match_thresholdnumberbodyMatch 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
- A
flagaction does not block. If you configure a list withflag, a confirmed hit still approves the module — it only records and risk-scores the match. Usereviewordeclineif a hit must gate the applicant. - Possible matches are silent by default. Sub-threshold candidates only route to review when
review_possible_matchesis enabled. Without it, a medium-risk_levelresult can still beapproved. - 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. - 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.