Our screening API never returns a match score. Here are the three responses it does return
Most screening API documentation stops at the happy path: a request, a 200, and a clean result. The response that actually consumes your week is the third one — a candidate that matches the name and nothing else. Our v1 API returns no similarity score for it, on purpose, and the three responses printed below are the argument for why the thing you configure is a review policy rather than a number.
- Create a screening
- POST /api/v1/search → 202
- Returns a search_id and an ETA. Nothing is screened synchronously; results are read with GET /api/v1/search/{search_id}.
- Authentication
- X-API-Key header
- Keys are issued with the prefix svai_ and carry per-key permissions: search:read, search:write, batch:read, batch:write.
- Match score in the response
- None — by design
- A candidate is returned as a per-source finding with a Status line and named identifiers. There is no numeric score field and no threshold parameter on the request.
- The one numeric threshold
- 0.95, to discard
- Full Search's review stage removes a candidate only when it is at least 0.95 confident the candidate is a false positive and can write the reason. Nothing is ever surfaced because a number was high enough.
- Price per check
- €0.39 (search_type sanctions_check)
- Deterministic sanctions, PEP and criminal-watchlist screening with a point-in-time evidence record; billed per check, portal and API alike.
Short answer
APIPOST /api/v1/search with an X-API-Key header and a JSON body containing
entity_name. It answers 202 with a search_id — never with a result,
because one check runs across the whole source register. Poll
GET /api/v1/search/{search_id} until status is completed, then read the
per-source fields. Each of those fields is a short finding whose first line is
a Status: Sanctioned, Potential PEP match, Potential watchlist match
or No relevant listing found. There is no numeric score in the response and
no threshold in the request. So the thing you have to design is not "which
number is high enough" but "which statuses enter my review queue, and who
signs the disposition".
The request
REQUESTcurl -X POST https://screenveritai.com/api/v1/search \
-H "X-API-Key: svai_2f9c1b7d4a6e8035c1d29f4b7e60a83d" \
-H "Content-Type: application/json" \
-d '{
"entity_name": "Example Trading Ltd",
"entity_type": "company",
"search_type": "sanctions_check",
"language": "en"
}'
| Field | Required | What it does |
|---|---|---|
entity_name | yes | The string that is actually matched. 1–500 characters. |
entity_type | no | person, company or any. Stored and echoed back; it does not narrow the list matching. |
search_type | no | One of sanctions_check, adverse_media, full_search, deep_research_report. Omitting it selects full_search — send it explicitly. |
country | no | Jurisdiction hint. Used for identity resolution in deep_research_report; ignored by list matching. |
identifier, website | no | Public registration number and site, for the report search types. |
language | no | Report language, e.g. en, pl. |
Read that table once and the design is plain: the name is the query. Everything else is metadata you will be glad to have in your own audit trail. There is one trap in that generosity — unknown JSON keys are ignored rather than rejected, so a misspelled field name fails silently and nothing tells you. Validate the payload on your side.
The answer is immediate and content-free:
{
"search_id": "3f7c2a51-9b4e-4c8a-8d21-6f0e4b7a55d3",
"status": "processing",
"message": "Search queued successfully. Poll the GET endpoint for results.",
"estimated_time_seconds": 15
}
estimated_time_seconds is a fixed hint derived from the search type — 15 for
sanctions_check, 20 for adverse_media, 30 for full_search, 1,800 for
deep_research_report. It is a scheduling hint, not a measurement and not a
service level. Use it to size your first sleep, not your timeout.
Poll for the result
POLLINGcurl https://screenveritai.com/api/v1/search/3f7c2a51-9b4e-4c8a-8d21-6f0e4b7a55d3 \
-H "X-API-Key: svai_2f9c1b7d4a6e8035c1d29f4b7e60a83d"
status | Meaning | Client action |
|---|---|---|
processing | Running | Keep polling, with back-off |
queued | Waiting for the durable worker (report type only) | Keep polling |
completed | Results attached | Read and store |
error_streaming | The run failed mid-execution | Terminal — log and retry as a new search |
error_no_final_payload | The run produced no payload | Terminal |
error_saving_results | The run finished, the write failed | Terminal — contact support with the id |
error_credit_deduction / error_queue_create | Billing or queueing failed before work started | Terminal |
The whole client rule is one line: poll until status == "completed" or
status.startswith("error_"). New failure modes will always arrive with the
error_ prefix, so a prefix test survives changes that an enumeration will not.
Polling counts against your rate limit, so start at the ETA and back off.
Response 1 — no listing found
JSONTrimmed to the fields that carry meaning; the real body repeats the same shape for every jurisdiction.
{
"id": "3f7c2a51-9b4e-4c8a-8d21-6f0e4b7a55d3",
"entity_name": "Example Person 01",
"entity_type": "person",
"status": "completed",
"search_type": "sanctions_check",
"ofac_sanctions": "## OFAC Sanctions Findings\n\n**Status:** *No relevant listing found*\n\n**Details:**\n- No relevant listing was found in this source for the searched entity.\n\n**Source:** *OFAC Sanctions*",
"eu_sanctions": "## EU Sanctions Findings\n\n**Status:** *No relevant listing found*\n\n...",
"uk_sanctions": "...", "un_sanctions": "...",
"criminal_watchlists": null,
"pep": null,
"sanctions_sources": [
{ "source_id": "ofac_sanctions", "title": "OFAC Sanctions", "authority": "Office of Foreign Assets Control", "details": "...", "hit_count": 0 },
{ "source_id": "eu_sanctions", "title": "EU Sanctions", "authority": "", "details": "...", "hit_count": 0 }
],
"created_at": "2026-09-04T09:12:41.882431+00:00",
"completed_at": "2026-09-04T09:12:57.114902+00:00"
}
Field by field, operationally. There is one field per main jurisdiction —
ofac_sanctions, eu_sanctions, uk_sanctions, un_sanctions,
pl_sanctions, fr_sanctions, canada_sanctions, australia_sanctions,
switzerland_sanctions, south_africa_sanctions and new_zealand_sanctions —
and each one is a short Markdown finding written for a person to read. A source
that was screened and found nothing still returns a field, which is the
distinction that carries weight: absence of a field means the source was not
part of that run, an empty finding means it was.
sanctions_sources is the machine-readable twin of those strings, and the one
you should actually branch on: one object per screened source with a
hit_count on each, covering the eleven main jurisdictions and the additional
registers behind them. sum(hit_count) is your alarm; the eleven strings are
for the human. additional_sanctions_sources carries the same objects for the
non-jurisdiction registers only, and exists for older clients.
criminal_watchlists and pep come back null when nothing matched — those
two domains report findings and nothing else, so a null is the no-hit case and
any non-empty string is something to read. completed_at, paired with
created_at, is what you store as the evidence timestamp.
Store this. Do not label it. "No relevant listing found in the checked sources on 4 September 2026" is a defensible sentence; "clear" is a claim about the world that a name lookup cannot support.
Response 2 — an exact sanctions hit
JSON{
"entity_name": "Example Trading Ltd",
"status": "completed",
"search_type": "sanctions_check",
"ofac_sanctions": "## OFAC Sanctions Findings\n\n**Status:** *Sanctioned*\n\n**Details:**\n- **Matched entity:** EXAMPLE TRADING LTD\n- **Identification:** 51234; EXAMPLE TRADING LIMITED; EXAMPLE TRD\n- **Justification:** Acting for or on behalf of a designated entity.\n- **Measures:** SDGT\n- **Dates:** 2026-08-26\n- **Authority:** Office of Foreign Assets Control\n- **Evidence URL:** https://sanctionssearch.ofac.treas.gov/Details.aspx?id=99999\n\n**Source:** *Office of Foreign Assets Control*",
"sanctions_sources": [
{ "source_id": "ofac_sanctions", "title": "OFAC Sanctions", "authority": "Office of Foreign Assets Control", "details": "...", "hit_count": 1 }
]
}
Unescape that string and you get a short structured finding. Every line in it is an instruction:
| Line | What you do with it |
|---|---|
| Matched entity | The register's own spelling. Compare it to your contract spelling; a difference is the start of the review, not the end of it. |
| Identification | The list's unique reference plus aliases. The reference is what you quote in the file — it survives spelling changes. |
| Justification | The authority's stated grounds, where the source publishes them. |
| Measures | Programme or regime code (SDGT, asset freeze, travel ban). Determines which obligation bites. |
| Dates | Listing or designation date. If it is later than your last screening, your last screening was not wrong — it was earlier. |
| Authority | The issuing body. Decides who you notify and under whose rules. |
| Evidence URL | The authority's own record. Put this link in the file; a vendor screenshot is not a source. |
An exact hit on a sanctions list is not a decision you take alone. It is a freeze-and-escalate event under your own procedure, and the evidence URL plus the identification reference are what your report to the competent authority will be built from.
Response 3 — the ambiguous one
JSONThis is the response that actually consumes your week: the name matches, the identity does not resolve.
{
"entity_name": "A. Example",
"status": "completed",
"search_type": "sanctions_check",
"ofac_sanctions": "## OFAC Sanctions Findings\n\n**Status:** *No relevant listing found*\n\n...",
"pep": "## PEP Findings\n\n**Status:** *Potential PEP match*\n\n**Details:**\n- **Matched entity:** A. Example\n- **Identification:** Andrzej Example; 1971; pl\n- **Datasets:** Poland Public Officials Register\n\n**Source:** *Poland Public Officials Register*",
"criminal_watchlists": null
}
Read the status words literally. A sanctions finding says Sanctioned — an assertion about a register. PEP and criminal-watchlist findings say Potential PEP match and Potential watchlist match — an assertion about a resemblance. That word "potential" is the API telling you, in the only vocabulary it has, that this is a candidate and not a conclusion.
What you have here is a two-character given name, a birth year rather than a date, and a country. What you do not have is a person. The next step is not a threshold; it is three cheap identity checks — whether the customer's recorded date of birth contradicts 1971, whether the register entry's role fits the customer's profile, and whether the dataset publishes an identifier your KYC file also holds. Two contradictions close it as a false positive. One agreement and one gap keeps it open.
There is no 0.82
THRESHOLDSThe retrieval layer does compute similarity internally. It is not returned, and that is deliberate: a number without the reasoning behind it is a decision you cannot defend and a control you cannot calibrate. Print a score and every review meeting turns into an argument about the score.
There is exactly one numeric threshold in the pipeline, and it runs the other way. Full Search adds a review stage over the candidates, and a candidate is removed only when the review is at least 0.95 confident it is a false positive and can write the reason. Everything below that bar stays in front of you, reason attached.
Nothing is ever raised to you because a number was high enough. Things are only ever removed because a written argument was strong enough.
So the threshold you choose is a policy, not a parameter:
| Response contains | Queue | Target |
|---|---|---|
Any hit_count > 0 in sanctions_sources | Freeze and escalate | Same day |
Potential watchlist match | Senior review | 1 business day |
Potential PEP match | Standard review, EDD if confirmed | 2 business days |
Everything No relevant listing found | Auto-file as evidence | No human |
The disposition record
PROCESSA screening result is not an audit artefact until a human has written what it means. Store one disposition row per candidate, not per search:
| Column | Example |
|---|---|
search_id | 3f7c2a51-… |
source_id | ofac_sanctions |
matched_entity | EXAMPLE TRADING LTD |
list_reference | 51234 |
disposition | true_match / false_positive / escalate |
rationale | "Date of birth in file is 1984-02-11; register entry states 1971. Different person." |
decided_by, decided_at | analyst id, ISO timestamp |
The rationale field is the whole point. An examiner does not ask what your tool scored; they ask why this candidate was closed, and a stored sentence answers that three years later when the analyst has left.
Re-screening when a list changes
CADENCEEvery POST creates a new search_id, bills one check and stores a new
snapshot. There is no idempotency key, and you should not want one: the
defensible record is a dated result, never a mutated one. Make the client
idempotent instead — key your re-screen queue on (subject_id, list_version)
so a subject is re-screened once per data change rather than once per cron tick.
GET /api/v1/coverage gives you the version signal:
{
"status": "ok",
"checkedAt": "2026-09-04T09:20:03.418662+00:00",
"domains": {
"sanctions": { "domain": "sanctions", "expected_tables": 51, "available_tables": 51, "tables": ["…"], "latest_updated_at": "2026-09-04T04:11:52+00:00" },
"criminal_watchlists": { "domain": "criminal_watchlists", "available_tables": 16, "tables": ["…"], "latest_updated_at": "2026-09-03T22:40:07+00:00" },
"pep": { "domain": "pep", "available_tables": 2, "tables": ["…"], "latest_updated_at": "2026-09-01T03:15:44+00:00" }
}
}
Counts and timestamps above are illustrative — call the endpoint for the live
ones. The field that matters is latest_updated_at, and it is per domain
because sanctions, criminal watchlists and PEP refresh on different cadences.
When latest_updated_at for a domain moves past the timestamp of a stored
snapshot, that snapshot is stale by definition. At €0.39 per check with
search_type: sanctions_check, a 2,000-name book re-screened after a list
movement costs €780 — which is the number to put next to the cost of explaining
a missed designation, not next to the cost of doing nothing.
What this API will not do
LIMITSThere is no official client library in any language, no webhooks and no
callbacks: polling is the only delivery mechanism, 202 is the only answer a
POST gives, and a working integration is one POST, one polling loop and one
row of storage. There is no verdict field either. The response hands you a
status per source, and the match/review/clear rollup is yours to compute and
yours to own — as is the absence of a score, of a threshold parameter and of a
"confidence" key. Errors arrive as a flat envelope,
{"error": …, "message": …, "status_code": …}, with 401 for a missing or
invalid key, 403 for a key lacking search:write or search:read, and 429
with Retry-After when you outrun your tier.
None of that is a gap to apologise for; a screening integration that fits in fifty lines is one your successor can still read. What you should insist on — from ScreenVeritAI or from anyone else — is what the three responses above actually name: the list, the reference, the measures, the date and the authority's own URL. The human who signs the disposition needs something to decide with.
Screening supports compliance review and due diligence documentation. The disposition remains yours.
Frequently asked questions
Q&ADoes the screening API return a match score?
No. The v1 response represents each screened source as a finding with a Status line — Sanctioned, Potential PEP match, Potential watchlist match, or No relevant listing found — plus the matched entity, its identifiers, the programme or measures, the listing dates, the issuing authority and an evidence URL. There is no numeric score field on the response and no threshold parameter on the request, so there is no 0.82 to tune.
How do I get the result of POST /api/v1/search?
Poll. The POST returns HTTP 202 with a search_id and an estimated_time_seconds hint; you then call GET /api/v1/search/{search_id} until status is completed. There are no webhooks and no SDKs, so a client is a loop with a sleep and a timeout. Any status beginning with error_ is terminal — stop polling and log it.
What threshold should I set for a sanctions screening API?
You do not set one on the request. What you set is your review policy: which Status values enter a human queue, who reviews them, and how fast. The only numeric threshold in the pipeline works in the opposite direction — Full Search's review stage discards a candidate only when it is at least 0.95 confident the candidate is a false positive and can state why. Nothing is ever raised to you because a similarity number cleared a bar.
Does sending a country or an entity type narrow the match?
Not for list screening. The request accepts entity_type and country, and both are stored and echoed back on the search record, but the sanctions, PEP and criminal-watchlist matching runs on the name. The country field sharpens identity resolution for the Deep Research Report search type only. Treat both as metadata you will be glad to have in your own audit trail, not as filters.
What does a no-hit response actually say?
Each per-source field reads "No relevant listing found" for that source, with the line "No relevant listing was found in this source for the searched entity". It does not say the subject is clean, safe or cleared, because a screening can only speak about the sources it screened on the day it ran. Record it as evidence of a check, not as a certificate.
How do I re-screen a name after a list changes?
Send the same POST again. There is no idempotency key: every POST creates a new search_id, deducts one check and stores a new point-in-time snapshot — which is exactly what you want, because the defensible artefact is a dated result, not an updated one. Make your own client idempotent instead, by keying your queue on (subject_id, list_version) so a re-screen fires once per data change. GET /api/v1/coverage returns latest_updated_at per domain and is the honest trigger.
What are the rate limits?
They are per key and tier-based: 10 requests per minute and 100 per day on Free, 60 and 1,000 on Standard, 300 and 10,000 on Premium, 1,000 and 100,000 on Enterprise. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After. Polling counts against those limits, so poll on a back-off rather than a tight loop.
Sources
SOURCES- 01ScreenVeritAI API v1 — interactive documentation
ScreenVeritAI · 2026-09-04
- 02ScreenVeritAI API v1 — OpenAPI schema
ScreenVeritAI · 2026-09-04
- 03Specially Designated Nationals and Blocked Persons List (SDN) — human-readable lists
Office of Foreign Assets Control · 2026-09-04
- 04OFAC Sanctions List Service
Office of Foreign Assets Control · 2026-09-04
- 05Overview of sanctions and related tools (EU consolidated financial sanctions)
European Commission, DG FISMA · 2026-09-04
- 06United Nations Security Council Consolidated List
United Nations Security Council · 2026-09-04
Informational analysis of published regulatory sources. Not legal advice. Verify the primary sources before acting.