Screening API
The Screening API is what the Screening page and Settings → Screening use, plus several
things that have no page in the app yet: screening a name on demand, setting up providers and
importing lists. Every endpoint is for signed-in people: an API key gets 401 sign in required.
For what each feature does, read the guides under Sanctions and PEP screening first.
Permissions
Section titled “Permissions”| Permission | Allows |
|---|---|
screening.view |
Every GET below, and POST /screening/screen against the lists (without "providers": true) |
screening.manage |
Everything screening.view allows, plus every change: settings, providers, re-screens, list and bundle imports. Also POST /screening/subjects/{type}/{id}, and POST /screening/screen with "providers": true |
settings.view |
Only the GET endpoints for settings, sources, versions, runs and providers |
screening.review |
POST /screening/hits/{id}/assign, /comments and /resolve; also enough for GET /screening/hit-filters. Reading the matches themselves needs screening.view |
screening.approve |
Approving a screening change request, including a decision on a potential match (through the Change requests API) |
screening.accept_removal |
accept_removal=true on POST /screening/bundles, together with screening.manage |
Endpoints
Section titled “Endpoints”| Endpoint | What it does | Answer |
|---|---|---|
GET /api/v1/screening/settings |
Your workspace’s screening settings | 200 |
PUT /api/v1/screening/settings |
Change them (may need approval) | 200, or 202 |
GET /api/v1/screening/sources |
The installation’s lists and your workspace’s own, each with its current version and whether you screen against it | 200 |
GET /api/v1/screening/sources/{key}/versions |
A list’s versions, newest first | 200 |
POST /api/v1/screening/sources/{key}/import |
Import a file as one of your workspace’s own lists, import:<name> (may need approval) |
200, or 202 |
POST /api/v1/screening/bundles |
Import a signed list bundle (self-hosted only; may need approval) | 200, or 202 |
POST /api/v1/screening/rescreen |
Queue a full re-screen | 202 |
GET /api/v1/screening/runs |
Recent re-screens | 200 |
POST /api/v1/screening/screen |
Screen a name on demand, storing nothing | 200 |
POST /api/v1/screening/subjects/{type}/{id} |
Screen one customer or counterparty now and record the result | 200 |
GET /api/v1/screening/hits |
Potential matches | 200 |
GET /api/v1/screening/hits/{id} |
One potential match, with your record, the list entry and the timeline | 200 |
GET /api/v1/screening/hit-filters |
What the queue can be filtered by, and who a match can be assigned to | 200 |
POST /api/v1/screening/hits/{id}/assign |
Assign an open match, or take it back | 204 |
POST /api/v1/screening/hits/{id}/comments |
Add a comment to a match’s timeline | 204 |
POST /api/v1/screening/hits/{id}/resolve |
Resolve an open match as a true match or a false positive (may need approval) | 200, or 202 |
GET /api/v1/screening/providers |
Configured providers and the provider catalogue | 200 |
PUT /api/v1/screening/providers |
Add or change a provider (may need approval) | 200, or 202 |
DELETE /api/v1/screening/providers/{id} |
Remove a provider (may need approval) | 204, or 202 |
Errors
Section titled “Errors”| Status | Meaning | Examples |
|---|---|---|
401 |
Not signed in, or called with an API key | sign in required |
403 |
Missing permission, a list that isn’t yours to change, or not allowed on this installation | missing permission screening.manage; asking the screening providers sends the name to a third party: missing permission screening.manage; not allowed: ng-nigsac is one of the installation's lists, the same for every workspace: …; not allowed: the official lists are managed by SpeculaPro on the hosted service |
404 |
No such potential match, provider, customer or counterparty in your workspace | not found |
409 |
No list is loaded yet, or the match is not in a state that allows the action | no list has been loaded yet: the worker fetches the official lists, or import one; the match is already cleared; conflict: the match is superseded; only open matches are assigned; conflict: the customer this match is about no longer exists |
413 |
The body is larger than the endpoint allows: 2 MiB for a list import, SP_INGEST_MAX_BODY_BYTES for a bundle, SP_HTTP_MAX_BODY_BYTES elsewhere |
request body too large |
422 |
A field fails its rules, or the request can’t be carried out as things stand | invalid: the threshold must be between 0.50 and 0.99; invalid: unknown list source "x"; invalid: use an https base URL; invalid: World-Check One needs a group id; invalid: an imported list's key is import:<name> (2 to 57 characters: a-z, 0-9, - _ .); invalid: a workspace keeps at most 20 lists of its own; invalid: screening is turned off for this workspace; turn it on to re-screen; not a list in the expected format: the header needs key and name columns |
428 |
Approval is required and the request has no X-Change-Reason header. See Approvals |
|
202 |
The change is waiting for a second person’s approval; the body is the change request |
Approvals
Section titled “Approvals”These endpoints can need a second person’s approval, under the action type Screening sources and
thresholds (screening.config) of the workspace’s
approval policy. It is on by default once two active people hold
screening.approve.
| Endpoint | Change request operation |
|---|---|
PUT /api/v1/screening/settings |
screening.settings |
PUT /api/v1/screening/providers |
screening.provider_save |
DELETE /api/v1/screening/providers/{id} |
screening.provider_delete |
POST /api/v1/screening/sources/{key}/import |
screening.import_list |
POST /api/v1/screening/bundles |
screening.import_bundle |
When approval applies, a request without the X-Change-Reason header is answered 428. With it,
the answer is 202 and the change request; nothing changes until someone with screening.approve
approves it. See Change requests API.
A request is checked before it is proposed: a missing permission, a key your workspace may not
import under, a file that doesn’t parse, or a bundle whose signature doesn’t verify is answered
403 or 422, and no change request is made.
If what a request depends on changed before it was approved, it is closed as stale: for a list
import, the list got another version; for a bundle, one of the lists it carries did.
A decision on a potential match has two action types of its own:
| Endpoint | Decision | Action type | Operation |
|---|---|---|---|
POST /api/v1/screening/hits/{id}/resolve |
A true match, or clearing a match against a sanctions list | screening.disposition (on by default) |
screening_hit.resolve |
POST /api/v1/screening/hits/{id}/resolve |
Clearing a PEP, adverse-media or watch-list match | screening.clearance (off by default) |
screening_hit.clear |
The reason in the request body serves as the change reason, so no header is needed. See Resolve a potential match.
POST /screening/rescreen, POST /screening/screen, POST /screening/subjects/{type}/{id}, and
assigning or commenting on a match, never go through approval.
Settings
Section titled “Settings”GET /api/v1/screening/settings
Section titled “GET /api/v1/screening/settings”{ "enabled": true, "threshold": 0.85, "screen_customers": true, "screen_counterparties": true, "sources": ["eu-fsf", "gb-uksl", "un-sc", "us-ofac-cons", "us-ofac-sdn"], "default_sources": true}| Field | Meaning |
|---|---|
enabled |
Screening on. Off: the worker does no automatic screening for the workspace |
threshold |
Score from which a candidate is a potential match, 0.5 to 0.99 |
screen_customers, screen_counterparties |
Which subjects are screened |
sources |
Keys of the lists the workspace screens against |
default_sources |
Present and true when no choice has been saved. sources is then the lists of the workspace’s jurisdiction (see Default lists by jurisdiction) and follows it until a choice is saved |
PUT /api/v1/screening/settings
Section titled “PUT /api/v1/screening/settings”Send the whole object (without default_sources). The answer is the saved settings.
thresholdoutside0.5to0.99is422. So is asourceskey that is neither one of the installation’s lists nor one of your workspace’s own.- Changing the threshold or the sources, or turning screening on, queues a full re-screen (unless one is already waiting or running).
- Each fetched list in
sourcesstarts being fetched for the installation if it wasn’t. - Open potential matches on a list that is no longer in
sourcesare closed assuperseded, withclosed_reasonsource_removed. Provider matches are left open. - Audited as
screening.settings, with the settings before and after, and asscreening.hits.closewhen matches were closed.
GET /api/v1/screening/sources
Section titled “GET /api/v1/screening/sources”An array, one object per list: every list of the installation, and your workspace’s own. Another workspace’s own lists are never in it.
| Field | Meaning |
|---|---|
key, name, publisher, jurisdiction |
The list. jurisdiction is a country code, UN, EU or UEMOA |
format |
The publisher’s format (un-xml, ofac-xml, eu-fsf-xml, uk-xml, fic-xml, sema-xml, dfat-xlsx, ke-xlsx), import or provider |
url |
Where a feed is fetched from, including an address the operator set |
origin |
builtin, import (an imported list, the installation’s or your own) or provider |
workspace |
true: your workspace’s own imported list, which no other workspace sees. false: one of the installation’s lists |
feed |
true: fetched on a schedule. false: imported |
installed |
The installation keeps this list (a feed that is off is not fetched) |
enabled |
This workspace screens against it |
interval_seconds |
How often a feed is checked |
last_fetch_at, last_fetch_status, last_error |
The last fetch: ok (a new version), unchanged, older (the publisher’s file was older than the version held, which stays; last_error carries a note saying so) or error. A fetch refused because it would remove too much of the list is also error, with the counts in last_error |
next_fetch_at |
When a feed that is on is next due |
notes |
Notes about the publisher |
version |
The current version, absent when the list has never been loaded. Its added, changed and removed are always 0 here; read them from the versions endpoint |
GET /api/v1/screening/sources/{key}/versions
Section titled “GET /api/v1/screening/sources/{key}/versions”limit is 1 to 200 (default 50). Newest first.
[ { "id": "0199f1c2-…", "source": "un-sc", "seq": 4, "published_at": "2026-10-09T00:00:00Z", "fetched_at": "2026-10-09T06:00:12Z", "file_sha256": "9f2c…", "content_sha256": "41ab…", "entries": 1012, "added": 3, "changed": 1, "removed": 0, "origin": "fetch" }]| Field | Meaning |
|---|---|
seq |
The version number for this list: 1, 2, 3 … |
published_at |
The publisher’s date, when the file gives one |
fetched_at |
When it was fetched or imported |
file_sha256 |
SHA-256 of the file as published |
content_sha256 |
SHA-256 of the parsed entries. Two files with the same entries have the same value, and no new version is stored |
entries, added, changed, removed |
The size of the list and what changed since the previous version |
origin |
fetch, import, bundle or provider_sync |
bundle_key_id |
For a bundle, the key that signed it |
The versions are those of the list as your workspace sees it: your own list by that key if you
have one, otherwise the installation’s. A key that is neither answers 200 with an empty array.
Import a list
Section titled “Import a list”POST /api/v1/screening/sources/{key}/import
Stores a file as a new version of one of your workspace’s own lists. Only your workspace sees
the list and screens against it. It works on self-hosted installations and on the hosted service.
Needs screening.manage. The app’s Import a list button under Settings → Screening calls
this endpoint; it sends no X-Change-Reason, so use the API directly when the import needs
approval.
The body is the file, in the import format, with
Content-Type: text/csv or application/xml, up to 2 MiB. {key} is import:<name>, where
<name> is 2 to 57 characters: a-z and 0-9, and after the first character also -, _ and
.. The optional query parameter name (up to 120 characters) is the list’s display name: it
names a new list, and renames an existing one when sent again.
{"source": "import:internal-watchlist", "version_id": "0199…", "seq": 3, "unchanged": false, "entries": 214, "added": 2, "changed": 1, "removed": 0}| Field | Meaning |
|---|---|
source |
The list’s key |
version_id, seq |
The version stored. With unchanged or older, the current version, which stays |
unchanged |
true: the entries are the same as the current version, and nothing was stored |
older |
Present and true when the file was published before the current version, so nothing was stored. Only a fetch or a signed bundle is compared this way; an import through this endpoint never answers it |
entries |
Entries in the file |
added, changed, removed |
What changed since the previous version |
accepted_removal |
Present and true on a version stored although it removed more than a list may lose by itself: by the operator with --accept-removal, or by a bundle import with accept_removal=true |
The checks, in order. Everything down to the file check happens before any change request is made:
| Check | Answer |
|---|---|
| The body is larger than 2 MiB | 413 |
{key} is a built-in list (ng-nigsac, gh-domestic, uemoa-gel, un-sc …) |
403, not allowed: … is one of the installation’s lists, the same for every workspace: the operator imports it (speculapro screening import-list, or a signed list bundle). A workspace imports its own lists as import:<name> |
{key} is not a valid import:<name> |
422, invalid: an imported list’s key is import:<name> (2 to 57 characters: a-z, 0-9, - _ .) |
{key} is an import:<name> list of the installation |
403, not allowed: … is one of the installation’s lists; give your own list another name |
| The list would be your workspace’s twenty-first | 422, invalid: a workspace keeps at most 20 lists of its own |
| The file doesn’t parse | 422, for example not a list in the expected format: the header needs key and name columns |
Approval applies and there is no X-Change-Reason |
428 |
| Approval applies | 202, with the change request. Its summary shows the list, the size in bytes and the first 16 characters of the file’s SHA-256 |
- The 2 MiB limit is this endpoint’s own. A smaller
SP_HTTP_MAX_BODY_BYTESdoes not shorten it. - The new version is audited as
screening.list.version, in your workspace. - Importing does not add the list to the settings’
sources. Add it withPUT /screening/settingsto screen against it.
The installation’s lists that have no feed (ng-nigsac, gh-domestic, uemoa-gel) are imported by
the operator with speculapro screening import-list. See
Screening lists for operators.
Import a signed bundle
Section titled “Import a signed bundle”POST /api/v1/screening/bundles
Imports a bundle of lists signed by SpeculaPro into the installation’s lists, for every
workspace. Self-hosted installations only. Needs screening.manage.
The body is the bundle file, sent as Content-Type: application/json or
application/octet-stream. The signature is checked before anything in the file is read.
What happens, in order:
- On the hosted service the request is refused:
403, not allowed: the official lists are managed by SpeculaPro on the hosted service. - The signature is checked, and each list’s key. A bundle that doesn’t verify is
422, invalid: list bundle is not valid: …. So is a bundle with no list, one that carries a list twice, or one that carries animport:<name>key a workspace uses for its own list. - If approval applies (
screening.config): withoutX-Change-Reasonthe answer is428. With it, the file is kept on the server and the answer is202with the change request. The request carries the bundle’s SHA-256, the id of the key that signed it, its creation time and the lists it contains with their entry counts and publication dates, not the file itself. The approver approves it under Approvals as usual. - If approval does not apply, the bundle is imported at once and the answer is
200.
On approval the kept file is read back, checked against the SHA-256 in the request, verified again
and imported; the kept file is then removed. A kept file that nobody approved is removed after 8
days. If it is gone when the request is approved, the approval fails with the uploaded bundle is no
longer kept; send it again. If one of the bundle’s lists got a newer version since the proposal,
the request is closed as stale.
The 200 answer is an array with one result per list in the bundle, in the same shape as an import:
[ {"source": "un-sc", "version_id": "0199…", "seq": 5, "unchanged": false, "entries": 1012, "added": 2, "changed": 1, "removed": 0}, {"source": "us-ofac-sdn", "version_id": "0199…", "seq": 9, "unchanged": false, "older": true, "entries": 17804, "added": 0, "changed": 0, "removed": 0}]- All or nothing. Every list in the bundle is stored in one transaction. If one fails, none is stored.
"older": true: the installation already holds a version of that list published later, and it stays. See A list never goes back in time."unchanged": true: the list’s entries are the ones already held.- A list that would lose many entries is refused. If a list in the bundle would remove more
than 20% of a current version of 50 entries or more (the operator’s settings), the import
answers
422, invalid: large removal refused: the new … would remove … of the current version’s … entries …, and no list in the bundle is stored. When approval applies, the refusal comes when the request is approved. - Accepting a large removal. After checking with the publisher that the entries really were
removed, send the same request with
?accept_removal=true. It needs the permissionscreening.accept_removalas well asscreening.manage(without it:403, missing permission screening.accept_removal). When approval applies, the change request’s summary says ACCEPTING LARGE REMOVALS so the approver sees it. Each list accepted this way is audited asscreening.bundle.accept_removal, with the list, the version, how many entries were removed and the bundle’s SHA-256. The operator can do the same from the command line withspeculapro screening import-bundle <file> --accept-removal. See When a list loses many entries. - The body limit is
SP_INGEST_MAX_BODY_BYTES(8 MiB by default). The bundle format itself allows 96 MiB. For a larger bundle raise that setting or use the command line,speculapro screening import-bundle <file>: see Screening lists for operators. - Each stored version is audited as
screening.list.version, in the workspace of the person who proposed the import.
Re-screens
Section titled “Re-screens”POST /api/v1/screening/rescreen
Section titled “POST /api/v1/screening/rescreen”No body. Answers 202:
{"id": "0199f1d0-…", "queued": true}"queued": false means a full re-screen was already waiting or running; its id is returned and
nothing new was queued. Audited as screening.rescreen when one is queued.
While screening is turned off for the workspace the request is refused: 422, invalid: screening
is turned off for this workspace; turn it on to re-screen.
GET /api/v1/screening/runs
Section titled “GET /api/v1/screening/runs”limit is 1 to 200 (default 50). Newest first.
| Field | Meaning |
|---|---|
kind |
full, or delta (a list change) |
trigger |
manual, settings or list_change |
source, from_seq, to_seq |
For a delta: the list, and the versions applied (after from_seq, up to to_seq) |
status |
queued, running, done or failed |
subjects, hits |
People screened, and the potential matches they have |
error |
Why a failed run stopped. A queued full re-screen of a workspace that turned screening off before it ran ends as failed with not run: screening is turned off for this workspace |
requested_by |
The person who started a manual run |
created_at, started_at, finished_at |
Times |
Screening on demand
Section titled “Screening on demand”Screen a name
Section titled “Screen a name”POST /api/v1/screening/screen
Screens a name against the workspace’s lists, and its providers if asked. The name and the result are not stored.
- Against the lists it needs
screening.vieworscreening.manage. - With
"providers": trueit needsscreening.manage, because the name goes to a third party that may bill the call. Without it the answer is403, asking the screening providers sends the name to a third party: missing permission screening.manage.
Every call is written to the audit log as screening.screen_name, before any provider is
called. If the entry can’t be written, nothing is sent. The entry never holds the name:
| Audit field | Meaning |
|---|---|
name_hmac |
A keyed fingerprint of the name (an HMAC with your workspace’s key, over the name in lower case with single spaces). The same name gives the same value in your workspace; the name can’t be worked back from it without the key |
kind |
The kind sent |
with_date_of_birth, with_id_number |
Whether a date of birth and an ID number were given, not their values |
countries, threshold |
As screened |
lists |
The lists screened |
providers |
The providers the name was sent to; empty when none were asked |
{ "name": "Mamadou Ousmane", "kind": "person", "date_of_birth": "1975", "countries": ["NG"], "providers": false}| Field | Rules | Meaning |
|---|---|---|
name |
Required, 2 to 200 characters | The name to screen |
kind |
person, organization, vessel or empty |
Empty: compared as unknown |
date_of_birth |
YYYY, YYYY-MM or YYYY-MM-DD |
|
countries |
Up to 5 two-letter codes | Nationality or country |
id_number |
Up to 64 characters | Compared with list ID numbers of every type |
threshold |
0 to 0.99 |
Defaults to the workspace threshold (also when 0) |
limit |
Up to 50 | Most list results; default 20 |
providers |
Needs screening.manage |
true: also ask the workspace’s providers. Your provider may bill for it |
{ "matches": [ { "source": "un-sc", "entry_key": "QDi.000", "entry_name": "Muhammad Usman", "entry_kind": "person", "categories": ["sanction"], "list_version": {"id": "0199f1c2-…", "source": "un-sc", "seq": 4}, "score": 0.95, "explanation": { "score": 0.95, "name_score": 1, "method": "jaro_winkler_token_set", "query_name": "Mamadou Ousmane", "matched_name": "Muhammad Usman", "tokens": [ {"query": "muhammad", "entry": "muhammad", "sim": 1, "how": "variant"}, {"query": "usman", "entry": "usman", "sim": 1, "how": "variant"} ], "factors": [ {"code": "dob_year", "effect": 0.02, "detail": "DOB year match"}, {"code": "country_mismatch", "effect": -0.07, "detail": "nationality/country mismatch (NG vs PK)"} ], "text": "name 1.00 (Jaro-Winkler token set; muhammad≈muhammad variant; usman≈usman variant), DOB year match, nationality/country mismatch (NG vs PK)" } } ], "threshold": 0.85, "sources": ["eu-fsf", "gb-uksl", "un-sc", "us-ofac-cons", "us-ofac-sdn"], "took_ms": 3.4}The entry above is made up to show the shape; the numbers are illustrative.
| Field | Meaning |
|---|---|
matches |
Results at or above the threshold, best first |
sources |
The lists that were actually screened: your lists that have a version loaded |
took_ms |
How long the search took on the server |
errors |
Present when a provider could not be reached, one line per provider |
How to read explanation: How matching works.
409 when none of your lists has a version loaded and you didn’t ask for providers.
Screen a subject now
Section titled “Screen a subject now”POST /api/v1/screening/subjects/{type}/{id}
{type} is customer or counterparty; {id} is its id. Screens it against the workspace’s
lists and providers, whether or not anything changed, and records the potential matches.
Needs screening.manage: the call writes potential matches and calls your providers, which may
bill each search. screening.view alone is answered 403.
{"subject_type": "customer", "subject_id": "0199e0aa-…", "hits": 1, "incomplete": false}When a provider could not be asked:
{"subject_type": "customer", "subject_id": "0199e0aa-…", "hits": 1, "incomplete": true, "errors": ["opensanctions: the provider is unavailable"]}| Field | Meaning |
|---|---|
hits |
The number of open potential matches found |
incomplete |
true: a provider could not be asked. The subject is screened against the lists only for now, that provider’s earlier matches on it stay open, and the worker retries the provider. See Providers |
errors |
Present with incomplete: one line per provider that could not be asked, as provider: error |
404 when there is no such subject. 409 when no list is loaded yet.
Audited as screening.screen_subject on the customer or counterparty, with the lists screened, the
providers asked, the number of hits and whether the screening was incomplete.
Potential matches
Section titled “Potential matches”GET /api/v1/screening/hits
Section titled “GET /api/v1/screening/hits”| Parameter | Meaning |
|---|---|
status |
open, superseded, delisted, cleared or confirmed. Empty: all |
subject_id |
Only this customer’s or counterparty’s |
source |
Only matches against this list or provider: a list key, or provider:<key> |
subject_type |
customer or counterparty |
category |
sanction, pep, pep_rca, adverse_media or watchlist |
assignee |
me, unassigned, or a person’s id. Anything else is 422, assignee is me, unassigned or a person’s id |
before |
Paging: the created_at of the last potential match of the previous page |
before_id |
Paging, with before: the id of that last potential match |
limit |
1 to 500 (default 100) |
Newest first, ordered by created_at and then id. To page, pass both values of the last one
you received:
curl "https://speculapro.example/api/v1/screening/hits?status=open&limit=500&before=2026-10-09T06:00:12.431Z&before_id=0199f1c2-7a10-7c3e-9d0e-2f4b6a8c1e55" -b cookies.txtStop when a page comes back with fewer than limit.
| Field | Meaning |
|---|---|
id |
The potential match |
subject_type, subject_id, subject_name |
Who was screened, and the name as screened |
source |
The list key (one of the installation’s lists or one of your own), or provider:<key> |
entry_key, entry_name, entry_kind |
The list entry: its reference, the name or alias that matched, and its kind |
entry_revision |
A fingerprint of the entry’s content when it matched |
list_version_id |
The list version it was found in (absent for a provider) |
categories |
sanction, pep, pep_rca, adverse_media, watchlist, other |
score, name_score, threshold |
The final score, the name score, and the threshold in force |
explanation |
See How matching works |
status |
See Statuses |
closed_reason |
Why it left the queue without a person’s decision: no_longer_matches (a re-screen no longer found it), entry_delisted (the entry left its list) or source_removed (the workspace stopped screening against the list). Absent on an open match |
closed_at |
When that happened. Absent on an open match |
created_at, updated_at |
When it was first found, and last refreshed |
assignee_id, assignee_name |
The reviewer it is assigned to, if anyone |
decided_by, decided_at |
Who resolved it, and when. Absent on a match nobody decided |
case_id |
The case a confirmed match opened or joined |
pending_approval |
true while a decision on it waits for a second person. The match is still open |
deciding |
Only on GET /screening/hits/{id}, for an open match: who decides each outcome in this workspace now, as {"true_match": …, "false_positive": …}. Each is reviewer (anyone with screening.review, alone), second_person (a reviewer proposes and someone with screening.approve approves) or approver (only someone with screening.approve, alone) |
GET /api/v1/screening/hits/{id}
Section titled “GET /api/v1/screening/hits/{id}”The same fields, plus:
| Field | Meaning |
|---|---|
subject |
Your record as it is now: name, kind, date_of_birth (customers, and only if you hold entities.view_pii), countries and id_type (the kind of identity document; the number is never returned here). Absent once the record is gone |
entry |
The list entry as it stands now: key, kind, names, dobs, nationalities, ids, programs, listed_on, remarks. Absent when the entry has left the list, and for a provider match |
provider_detail |
For a provider match, the provider’s own answer |
designation_id |
The designation a confirmed match set, or the one already in force |
decided_by_name, decision_note |
Who decided, and the reason they gave |
case_number |
The number of the case, such as SP-2026-000123 |
pending_request |
The change request carrying a decision that waits for approval |
events |
The timeline, oldest first. Each has id, type (comment, assigned, unassigned, confirmed, cleared), body (the comment or the decision’s reason), payload, actor_id, actor_name and created_at |
GET /api/v1/screening/hit-filters
Section titled “GET /api/v1/screening/hit-filters”For anyone with screening.view, screening.review or screening.manage.
{ "sources": ["us-ofac-sdn", "import:demo-watch", "provider:opensanctions"], "reviewers": [{"id": "0199e0a1-…", "name": "Abena Owusu"}]}sources are the lists and providers your workspace has matches on. reviewers are the active
people who hold screening.review: the only people a match can be assigned to.
POST /api/v1/screening/hits/{id}/assign
Section titled “POST /api/v1/screening/hits/{id}/assign”Needs screening.review. Answers 204.
{"assignee_id": "0199e0a1-…"}Send null, or leave assignee_id out, to take the match back from whoever has it. Only an open
match can be assigned (409 otherwise), and only to an active person with screening.review
(422, the assignee is not an active person who may review matches in this workspace).
Assigning to the person who already has it changes nothing. Audited as screening.hit.assign.
Assigning a match to someone else queues a notification for them (kind
screening_match_assigned) in the same transaction, sent by email, text message or WhatsApp as
they chose. It names the person who assigned it and links to /screening/{id}; it carries no
name, list or score. Assigning a match to yourself, or taking it back, notifies nobody. See
Notifications.
Nobody is notified.
POST /api/v1/screening/hits/{id}/comments
Section titled “POST /api/v1/screening/hits/{id}/comments”Needs screening.review. Answers 204.
{"body": "Ghana Card checked: born 1996. The list entry gives 1988."}1 to 4,000 characters, on a match in any status. Audited as screening.hit.comment with the
comment’s length; the text itself is kept only on the timeline, where data protection can remove
it.
POST /api/v1/screening/hits/{id}/resolve
Section titled “POST /api/v1/screening/hits/{id}/resolve”Needs screening.review, and in one case screening.approve as well: see Approval below.
{"outcome": "false_positive", "reason": "Customer born 1996 (Ghana Card); the list entry gives 1988."}| Field | Meaning |
|---|---|
outcome |
true_match: the subject is the listed party. false_positive: it is not |
reason |
Required, 3 to 2,000 characters. Kept with the match as evidence, and read by the approver |
The answer says what the decision did:
{"status": "confirmed", "designation_id": "0199f3…", "case_id": "0199f3…"}| Field | Meaning |
|---|---|
status |
cleared or confirmed |
good_guy_id |
False positive: the good-guy entry recorded. The same subject and list entry are not raised again until the entry’s content or the subject’s screened data changes |
designation_id |
True match: the designation set (sanctioned, pep, pep_rca, adverse_media or watchlist, the gravest of the match’s categories), or the one already in force |
case_id |
True match: the case opened or joined. A sanctions match raises a sanctions alert of critical severity; any other an aml alert of high severity, with the rule key screening.match |
Approval:
- A
true_match, and afalse_positiveon a match whose categories includesanction, fall under the action typescreening.disposition(on by default). - A
false_positiveon any other match falls underscreening.clearance(off by default). - When approval applies, the answer is
202with the change request, and the match staysopenwithpending_approval: trueuntil someone withscreening.approveapproves it. Thereasonin the body is the maker’s reason: noX-Change-Reasonheader is needed. - While approval for
screening.dispositionis not in effect (fewer than two active people holdscreening.approve, or the workspace switched it off), a decision either way on a match whose categories includesanctionneedsscreening.approveitself. Without it the answer is403: A match against a sanctions list is decided by someone with screening.approve in this workspace: approval by a second person (screening.disposition) is not in effect, so a reviewer cannot confirm or clear it alone. You can still assign it and comment on it. With it, the decision applies at once (200). The match’sdecidingfield says which case applies before you call. - The request is closed as
staleif, before it is approved, the match’s status, score orentry_revisionchanged. - The decision is applied under the maker’s name:
decided_byis the reviewer. The approver is on the change request.
Errors: 409 the match is already … for a match that isn’t open; 409 when the customer or
counterparty of a true match no longer exists; 422 for a missing or short reason or an unknown
outcome.
Audited as screening.hit.clear or screening.hit.confirm, with the list, the entry, the
categories, the reason and the ids of what was created. See
Reviewing a potential match.
Providers
Section titled “Providers”The app’s Connect a provider, Edit and remove buttons under Settings → Screening call these endpoints. See How to connect a provider.
GET /api/v1/screening/providers
Section titled “GET /api/v1/screening/providers”{ "providers": [ { "id": "0199f200-…", "provider": "opensanctions", "name": "OpenSanctions (yente)", "status": "implemented", "installation": false, "config": {"base_url": "https://api.opensanctions.org", "dataset": "default"}, "has_credentials": true, "enabled": true, "sync_enabled": false, "updated_at": "2026-10-10T09:12:00Z" } ], "catalog": [ {"key": "opensanctions", "name": "OpenSanctions (yente)", "default_base_url": "https://api.opensanctions.org", "status": "implemented", "sync": true, "credentials": ["api_key"], "notes": "…"} ]}providers are the workspace’s configurations and any for the whole installation
("installation": true, read-only). catalog is every provider SpeculaPro knows, with the
credential fields each needs.
| Field | Meaning |
|---|---|
status |
implemented, or skeleton: not available, never called |
has_credentials |
Whether credentials are stored. They are never returned |
last_used_at, last_error |
The last call, and its error if it failed |
sync_enabled |
true on an installation-wide provider whose data feed is loaded into the list provider:<key>. Always false for a workspace’s own provider |
config.data_url |
On an installation-wide OpenSanctions provider: the bulk data address the operator set. A workspace’s own configuration has none |
PUT /api/v1/screening/providers
Section titled “PUT /api/v1/screening/providers”{ "provider": "worldcheck", "enabled": true, "config": {"base_url": "", "group_id": "<your group id>"}, "credentials": {"api_key": "<key>", "api_secret": "<secret>"}}| Field | Meaning |
|---|---|
provider |
opensanctions, complyadvantage, worldcheck, dowjones, bridger or moodys_grid |
enabled |
Whether it is called |
config.base_url |
Empty for the provider’s default address. Must be https and not an internal address (see Network rules). Up to 500 characters |
config.dataset |
OpenSanctions dataset; default when empty |
config.group_id |
World-Check One group; required for worldcheck |
config.data_url |
Leave out. It belongs to the installation-wide data feed, which the operator sets; sending one is 422, invalid: a data URL is for the installation-wide provider feed, which the operator sets |
credentials |
api_key, api_secret, username, password: the ones the catalogue lists for the provider. Leave credentials out to keep the stored ones |
Answers 200 with {"id": "…"}. A workspace has one configuration per provider; saving again
replaces it. Audited as screening.provider.save, recording the provider, the configuration,
whether it is enabled and whether credentials changed, never the credentials.
Required credentials are not checked when you save. A provider saved without them shows as on, and
is skipped with a logged warning when it is used. Check with
Screen a name and "providers": true.
DELETE /api/v1/screening/providers/{id}
Section titled “DELETE /api/v1/screening/providers/{id}”Answers 204. 404 for a provider that isn’t your workspace’s (including an installation-wide
one, which the operator removes with speculapro screening provider remove). Audited as
screening.provider.delete.