API · GuideLast reviewed 2026-09-04
    Sanctions News Desk

    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.

    Published By ScreenVeritAI Team
    AT A GLANCE
    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

    API

    POST /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

    REQUEST
    curl -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"
          }'
    
    FieldRequiredWhat it does
    entity_nameyesThe string that is actually matched. 1–500 characters.
    entity_typenoperson, company or any. Stored and echoed back; it does not narrow the list matching.
    search_typenoOne of sanctions_check, adverse_media, full_search, deep_research_report. Omitting it selects full_search — send it explicitly.
    countrynoJurisdiction hint. Used for identity resolution in deep_research_report; ignored by list matching.
    identifier, websitenoPublic registration number and site, for the report search types.
    languagenoReport 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

    POLLING
    curl https://screenveritai.com/api/v1/search/3f7c2a51-9b4e-4c8a-8d21-6f0e4b7a55d3 \
      -H "X-API-Key: svai_2f9c1b7d4a6e8035c1d29f4b7e60a83d"
    
    statusMeaningClient action
    processingRunningKeep polling, with back-off
    queuedWaiting for the durable worker (report type only)Keep polling
    completedResults attachedRead and store
    error_streamingThe run failed mid-executionTerminal — log and retry as a new search
    error_no_final_payloadThe run produced no payloadTerminal
    error_saving_resultsThe run finished, the write failedTerminal — contact support with the id
    error_credit_deduction / error_queue_createBilling or queueing failed before work startedTerminal

    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

    JSON

    Trimmed 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:

    LineWhat you do with it
    Matched entityThe register's own spelling. Compare it to your contract spelling; a difference is the start of the review, not the end of it.
    IdentificationThe list's unique reference plus aliases. The reference is what you quote in the file — it survives spelling changes.
    JustificationThe authority's stated grounds, where the source publishes them.
    MeasuresProgramme or regime code (SDGT, asset freeze, travel ban). Determines which obligation bites.
    DatesListing or designation date. If it is later than your last screening, your last screening was not wrong — it was earlier.
    AuthorityThe issuing body. Decides who you notify and under whose rules.
    Evidence URLThe 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

    JSON

    This 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

    THRESHOLDS

    The 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 containsQueueTarget
    Any hit_count > 0 in sanctions_sourcesFreeze and escalateSame day
    Potential watchlist matchSenior review1 business day
    Potential PEP matchStandard review, EDD if confirmed2 business days
    Everything No relevant listing foundAuto-file as evidenceNo human

    The disposition record

    PROCESS

    A screening result is not an audit artefact until a human has written what it means. Store one disposition row per candidate, not per search:

    ColumnExample
    search_id3f7c2a51-…
    source_idofac_sanctions
    matched_entityEXAMPLE TRADING LTD
    list_reference51234
    dispositiontrue_match / false_positive / escalate
    rationale"Date of birth in file is 1984-02-11; register entry states 1971. Different person."
    decided_by, decided_atanalyst 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

    CADENCE

    Every 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

    LIMITS

    There 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&A

    Does 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

    Informational analysis of published regulatory sources. Not legal advice. Verify the primary sources before acting.