Screening
Screening compares a name against the lists loaded on your install and returns possible matches, each with a score from 0 to 100 and the reasons for it. It decides nothing. A person reviews every match, and a potential match puts an alert in the queue so nobody has to go looking for it.
Loading lists
Lists are loaded with the admin command, which reads SENTINEL_DATABASE_URL:
sentinel-admin screening load -file list.json
Loading a list with a code that's already loaded replaces it in one transaction, so a screening running at that moment sees the old list or the new one, never half of each. Lists are shared by every organisation on the install. Running instances pick up a new list within 30 seconds.
A list file looks like this. data/screening/sample-list.json in the repository is a full example.
{
"code": "sample",
"name": "Sample list (synthetic, for testing only)",
"version": "2026-10-16",
"entries": [
{
"ref": "SMP-0003",
"kind": "person",
"name": "Mohammed Sani Al-Rashid",
"aliases": [
"Muhammad Sani Rashid",
"Mahamadu Sani Alrashid"
],
"countries": [
"NG",
"NE"
],
"category": "sanctions",
"program": "SAMPLE",
"birth_year": 1971
}
]
}
code, name and version are required, and so are each entry's ref (unique in the file), kind (person or business), name and category (sanctions, pep or adverse_media). Countries are two-letter uppercase codes. The command checks the whole file before loading any of it and reports the first problem.
To try screening on a test install, set SENTINEL_SCREENING_SAMPLE=true. The API then loads the sample list on startup, but only when no list is loaded at all, so it never replaces or sits beside a real one.
How matching works
- Names are compared word by word, by spelling and by sound, in any order.
ASANTE, KwamematchesKwame Asante, andMohamedmatchesMuhammad. - Every alias on an entry is tried.
matched_nameis the one that matched best. - Titles are ignored. Particles such as
binandalcount for less.Gold CrestandGoldcrestare the same name. - A birth year that's two or more years off lowers the score a lot. A country that isn't among the entry's countries lowers it a little. A birth year or country that agrees raises it a little.
- People are only matched against people, and businesses against businesses.
- With no list loaded, the API answers
503, never "clear".
Screen a name
POST /v1/screeningsScreens one person or business and stores the result, so it can be fetched later exactly as it was answered.
Request body
person or business.Example
This uses the synthetic sample list.
curl -X POST "$SENTINEL_URL/v1/screenings" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Muhammad Sani Rashid",
"kind": "person",
"birth_year": 1971,
"country": "NG",
"reference": "cus_1043"
}'
{
"id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09",
"reference": "cus_1043",
"status": "potential_match",
"threshold": 80,
"subject": {
"name": "Muhammad Sani Rashid",
"kind": "person",
"birth_year": 1971,
"country": "NG"
},
"matches": [
{
"score": 100,
"list": "sample",
"entry_ref": "SMP-0003",
"entry_name": "Mohammed Sani Al-Rashid",
"matched_name": "Muhammad Sani Rashid",
"kind": "person",
"category": "sanctions",
"program": "SAMPLE",
"countries": [
"NG",
"NE"
],
"birth_year": 1971,
"reasons": [
{
"code": "name_exact",
"detail": "\"muhammad\" is the same word"
},
{
"code": "name_exact",
"detail": "\"sani\" is the same word"
},
{
"code": "name_exact",
"detail": "\"rashid\" is the same word"
},
{
"code": "birth_year_match",
"detail": "both born in 1971"
},
{
"code": "country_match",
"detail": "NG is one of the entry's countries"
}
]
}
],
"lists": [
{
"code": "sample",
"name": "Sample list (synthetic, for testing only)",
"version": "2026-10-16",
"entries": 25,
"loaded_at": "2026-10-16T09:00:02.114Z"
}
],
"near": [],
"resolutions": [],
"review": "open",
"alert_id": "e2f1a0b9-8c7d-4e6f-9a5b-4c3d2e1f0a9b",
"created_at": "2026-10-16T10:41:27.630Z"
}
status is clear when nothing scored at or above the threshold, and potential_match otherwise. At most 25 candidates come back in total, highest score first.
Near misses
near holds candidates that scored at least 50 but under the threshold. They have the same fields as matches. They're there so a reviewer can see what came close, and they never make a screening a potential match. With a threshold of 50, near is always empty.
The alert
A potential_match raises an alert in the queue in the same request, and alert_id points to it. A clear screening has no alert. The alert is high priority if any match is on a sanctions list, and medium otherwise. See Screening alerts for how it looks in the queue.
Review state
review sums up what people have decided about the matches so far:
review | Meaning |
|---|---|
none | Nothing to review. The screening is clear. |
open | At least one match has no decision yet |
pending_approval | A match was cleared and is waiting for a second person |
cleared | Every match was cleared as a false positive, and each clearing was approved |
confirmed | Someone confirmed a true match |
resolutions lists every decision recorded about a match, oldest first. See Reviewing matches.
Reason codes
| Code | Meaning |
|---|---|
name_exact | A word is the same |
name_close | A word is spelled almost the same |
name_phonetic | A word sounds the same |
name_initial | A word could be an initial for the other |
list_has_extra, query_has_extra | One name has a word the other doesn't |
birth_year_match, birth_year_close, birth_year_differs | How the birth years compare |
country_match, country_differs | Whether the country is among the entry's countries |
Use detail for display. It says which words were compared.
List screenings
GET /v1/screeningsReturns stored screenings, newest first, each in the same shape as the response above.
Query parameters
clear or potential_match.reference. Use it to see every check on one customer.400.curl "$SENTINEL_URL/v1/screenings?reference=cus_1043&limit=10" \
-H "Authorization: Bearer $SENTINEL_KEY"
The response is { "screenings": [...] }.
Fetch a screening
GET /v1/screenings/{id}Returns a stored screening. The subject, matches, near misses and list versions are exactly as they were first returned, even if the lists have changed since. That's what lets you explain a result later. resolutions, review and alert_id show where the review stands now.
curl "$SENTINEL_URL/v1/screenings/4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09" \
-H "Authorization: Bearer $SENTINEL_KEY"
Reviewing matches
Each match gets a decision from a person, with a note saying why. There are three outcomes:
| Outcome | Meaning | Takes effect |
|---|---|---|
true_match | It's the person or business on the list | At once |
needs_info | Can't tell yet. The match stays open. | At once |
false_positive | It's someone else | Once a second key approves it |
Clearing a match takes two people, the same as closing an alert. Confirming one or asking for more information doesn't, since neither lowers the risk.
While a cleared match waits for approval, nobody can record another decision on it. Approve or reject the pending one first. Otherwise a later decision replaces the earlier one, and both stay in resolutions.
You can also decide on a near miss. Confirming a near miss as a true match sets review to confirmed.
When every match on a screening is cleared and approved, its alert closes by itself with the disposition false_positive. The key that cleared the last match is recorded as having requested closure, and the key that approved it as having closed it. A confirmed true match leaves the alert open for someone to act on. If the alert is already pending closure or closed, it's left as it is.
Each step is written to the audit log as screening.match_resolved, screening.resolution_approved or screening.resolution_rejected, and an alert that closes this way adds alert.closed.
Resolve a match
POST /v1/screenings/{id}/resolutionsRecords a decision about one match on the screening.
matches[].list.matches[].entry_ref.true_match, false_positive or needs_info.curl -X POST "$SENTINEL_URL/v1/screenings/4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09/resolutions" \
-H "Authorization: Bearer $SENTINEL_KEY" \
-H "Content-Type: application/json" \
-d '{
"list": "sample",
"entry_ref": "SMP-0003",
"outcome": "false_positive",
"note": "Passport shows a different place and date of birth. Copy on file."
}'
{
"id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d",
"list": "sample",
"entry_ref": "SMP-0003",
"outcome": "false_positive",
"note": "Passport shows a different place and date of birth. Copy on file.",
"status": "pending_approval",
"resolved_by": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"created_at": "2026-10-16T10:52:03.418Z"
}
status is pending_approval for false_positive and final for the other two.
Approve a cleared match
POST /v1/screenings/{id}/resolutions/{resolution_id}/approveMakes a pending false_positive final. It must be called with a different key from the one that cleared the match, or it returns 403. No body is needed.
curl -X POST "$SENTINEL_URL/v1/screenings/4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09/resolutions/7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d/approve" \
-H "Authorization: Bearer $SENTINEL_REVIEWER_KEY"
{
"id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d",
"list": "sample",
"entry_ref": "SMP-0003",
"outcome": "false_positive",
"note": "Passport shows a different place and date of birth. Copy on file.",
"status": "final",
"resolved_by": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
"decided_by": "8e7d6c5b-4a39-4281-b0c1-d2e3f4a5b6c7",
"created_at": "2026-10-16T10:52:03.418Z",
"decided_at": "2026-10-16T11:04:40.912Z"
}
If this was the last match left to clear, the screening's review becomes cleared and its alert closes.
Reject a cleared match
POST /v1/screenings/{id}/resolutions/{resolution_id}/rejectSends a pending false_positive back. Its status becomes rejected, decided_by and decided_at are set, and the match has no standing decision again. Any key can reject, including the one that cleared it, so it also works as "withdraw my decision". No body is needed.
Resolution fields
| Field | Type | Meaning |
|---|---|---|
id | string, uuid | The decision's ID |
list, entry_ref | string | The match it's about |
outcome | string | true_match, false_positive or needs_info |
note | string | Why |
status | string | pending_approval, final or rejected |
resolved_by | string | The key ID that made the decision |
decided_by | string | The key ID that approved or rejected it. Absent until then. |
created_at | string, date-time | When it was made |
decided_at | string, date-time | When it was approved or rejected. Absent until then. |
Loaded lists
GET /v1/screening/listsReturns the lists screenings currently run against.
{
"lists": [
{
"code": "sample",
"name": "Sample list (synthetic, for testing only)",
"version": "2026-10-16",
"entries": 25,
"loaded_at": "2026-10-16T09:00:02.114Z"
}
]
}
Not built yet
There's no ongoing re-screening when a list changes. A customer is only checked again when you screen them again. Stored screenings hold personal data, and there's no retention setting for them yet.
The dashboard's Screening screen uses these endpoints: screen a name, look through recent checks, and resolve or approve matches.
Errors
| Endpoint | Code | error |
|---|---|---|
| All | 401 | missing or invalid API key |
GET /v1/screenings | 400 | limit must be between 1 and 200 |
POST /v1/screenings | 400 | name must be 1 to 200 characters |
POST /v1/screenings | 400 | kind must be person or business |
POST /v1/screenings | 400 | birth_year looks wrong |
POST /v1/screenings | 400 | country must be a two-letter code |
POST /v1/screenings | 400 | threshold must be between 50 and 100 |
POST /v1/screenings | 503 | no screening list is loaded, so nothing can be checked |
GET /v1/screenings/{id} | 404 | screening not found |
POST .../resolutions | 400 | outcome must be true_match, false_positive or needs_info |
POST .../resolutions | 400 | note must be 1 to 2000 characters |
POST .../resolutions | 400 | this screening has no such match |
POST .../resolutions | 404 | screening not found |
POST .../resolutions | 409 | a decision on this match is waiting for a second person |
POST .../approve, .../reject | 404 | resolution not found |
POST .../approve | 403 | the person who cleared a match can't approve it |
POST .../approve | 409 | only a cleared match waiting for approval can be approved |
POST .../reject | 409 | only a cleared match waiting for approval can be sent back |