Screen an entity
Runs a screening and stores the result. Answers 201 Created. Database-first by default: an existing, unexpired screening is returned instead of buying a new one from the provider. ?force=true bypasses that cache and always screens. WHAT THE CACHE MATCHES, AND WHAT IT DOES NOT. With entity_id set, FindRecentScreeningByEntityID matches entity_id + collection + status completed + screening_date within 24h. With entity_id absent it falls back to entity_name + collection + status + the same window. NEITHER query is scoped to the caller, to requested_by or to a tenant, so a screening another caller paid for is a legitimate hit — and a name-only request for a common name can return a screening of a different subject who happens to share it. A cached answer carries the same status as a fresh one — both are 201, and RequestID is the id of whichever screening row was returned — but it is not undetectable: screened_at is the stored screening_date of the original row, so a value up to 24 hours old means the provider was not called. Two further consequences of the cache key being screening_types[0]: every type IS screened on a fresh call (ScreenEntity loops the array, one provider call per type), but the row is filed under the FIRST type only, so a later request naming MORE types hits the same row and gets the narrower stored result. screening_results carries one entry per type that actually ran, so compare what came back against what you asked for. ?force=true skips the cache read entirely (createScreeningWithoutCache) rather than invalidating it. Only entity_type customers and organizations can succeed — see UniversalKYCRequest. Do not rely on record scoping to keep screenings apart. The list route asks for CUSTOMER-record scoping and misapplies it (see that operation); GET /v1/kyc/screening/{id} and the stats route apply none at all. Endpoint-level RBAC is the only real boundary on these six routes. TWO STATUSES ON THIS ROUTE COME FROM THE PROVIDER, NOT FROM THE PLATFORM, AND EVERY OTHER PROVIDER FAILURE IS SWALLOWED. OpenSanctionsService.ScreenEntity loops the requested types and, on a failure from any one of them, returns early ONLY for upstream 401 (401 kyc_m.provider_auth_error) and upstream 403 (403 kyc_m.provider_unauthorized). Every other outcome — an upstream 429, any 5xx, a transport error, an unparseable body — is logged and `continue`d to the next type. THE CONSEQUENCE IS THE DANGEROUS PART. A single-type screening whose one provider call fails that way answers 201 with screening_results: [], overall_risk_level low, overall_score 0 and has_matches false — a clean verdict for a screening that never ran, indistinguishable in the body from a subject the provider cleared. A multi-type screening silently drops the failed types and returns the rest. AND THE FABRICATED VERDICT IS THEN CACHED. The row is written Status: completed, and FindRecentScreeningByEntityID matches status = completed AND screening_date > now() - 24h, so the empty result is re-served as a 201 to every later request for the same subject and collection for a full day. HOW TO DETECT IT, ON EITHER PATH. A successful screen appends exactly one ScreeningResult per requested type, whether or not that type matched anything, so an empty screening_results is NEVER a legitimate "the provider found nothing" answer — that would be one result per type with empty matches. A short list means the missing types failed. convertToUniversalResponse copies the stored array through unchanged, so the cache hit renders the same short list and the check works there too. ?force=true is what displaces the bad row once you have detected it; on its own it distinguishes nothing, because a still-failing provider produces the same clean empty verdict. A misconfigured provider API key still answers 401 to a correctly authenticated caller, so branch on code, never on status alone. (The XZiel driver does none of this — it re-serves provider faults as 502 and never files a partial screening.)
Authorization
bearerAuth JWT token from the authentication endpoint.
In: header
Query Parameters
Bypass the database-first check and screen again. Compared against the literal string "true", case-INSENSITIVELY (the handler lowercases the raw value first). Any other value, including "1", reads as false without an error.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Body of POST /v1/kyc/screening.
THE STRUCT VALIDATOR NEVER RUNS. The handler decodes with a plain json.NewDecoder, not apireply.DecodeJSONInput, so the validate:"required,oneof=…" and validate:"required,min=1" tags on this type are inert and unknown members are ignored. All enforcement is the handler's hand-written validateUniversalRequest.
ONLY TWO ENTITY TYPES ACTUALLY WORK. validateUniversalRequest first accepts seven values, then switches on the type to check its payload — and the switch handles only customers and organizations, with everything else falling to default and answering 400 kyc_m.invalid_entity_type. So shareholders, signatories, users, personas and unknown pass the first check and are refused by the second, always.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/v1/kyc/screening" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "customers", "screening_types": [ "sanctions" ] }'{
"request_id": "string",
"reference_id": "string",
"entity_type": "customers",
"entity_id": "8161163a-f227-466f-bc01-090a01e80165",
"overall_risk_level": "low",
"overall_score": 0.1,
"screening_results": [
{
"screening_type": "sanctions",
"risk_level": "low",
"score": 0.1,
"status": "string",
"matches": [
{
"match_id": "string",
"match_type": "string",
"confidence": 0.1,
"risk_level": "low",
"matched_entity": {
"id": "string",
"name": "string",
"schema": "string",
"properties": {},
"datasets": [
"string"
],
"country": "string",
"birth_date": "string",
"address": "string",
"aliases": [
"string"
]
},
"match_reason": "string",
"match_details": {},
"source": "string",
"source_id": "string",
"last_updated": "2019-08-24T14:15:22Z",
"sanctions_list": "string",
"pep_status": "string",
"adverse_media": [
{
"title": "string",
"summary": "string",
"url": "string",
"source": "string",
"published_at": "2019-08-24T14:15:22Z",
"relevance": 0.1,
"sentiment": "string"
}
]
}
],
"summary": "string",
"details": {},
"last_updated": "2019-08-24T14:15:22Z"
}
],
"total_matches": 0,
"has_matches": true,
"requires_review": true,
"screened_at": "2019-08-24T14:15:22Z",
"expires_at": "2019-08-24T14:15:22Z",
"recommendations": [
"string"
],
"metadata": {},
"case_id": "c74269e5-1f97-4e20-9164-ffbe3494d8d6",
"assigned_to": "b9f52997-ff03-4166-bbff-22fd35e12939",
"assigned_at": "2019-08-24T14:15:22Z"
}{
"status": 400,
"message": "Invalid entity type. Must be one of: person, business, legal, vessel, aircraft",
"code": "kyc_m.invalid_entity_type",
"class": "validation"
}{
"status": 401,
"message": "Unauthorized",
"code": "common.unauthorized",
"class": "business"
}{
"status": 403,
"message": "No access to the record",
"code": "common.rbac_no_rec_access",
"class": "business"
}{
"status": 408,
"message": "Failed to process KYC screening request",
"code": "kyc_m.failed_to_process",
"class": "temporary",
"retryable": true
}{
"status": 429,
"message": "Rate limit for 203.0.113.7 to POST:/v1/kyc/screening exceeded.",
"code": "rate_limits_m.exceeded",
"class": "temporary",
"retryable": true
}{
"status": 500,
"message": "Failed to create KYC screening record",
"code": "kyc_m.failed_to_create",
"class": "business"
}{
"status": 502,
"message": "KYC provider configuration error",
"code": "kyc_m.provider_config_error",
"class": "temporary",
"retryable": true
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05Z"
}Lists stored screening rows through the shared GetAll pipeline, so the body is the standard models.GetAllResponseAPI envelope of KYCScreening rows — NOT the results/limit/offset object this spec used to document, and not UniversalKYCResponse. RECORD SCOPING ON THIS ROUTE IS NOT USABLE AS WRITTEN. The input sets RbacRecordType: constants.CustomerRecName, so GetPermittedRecordsCtx resolves the caller's permitted CUSTOMER ids — and MergeSearchParams injects them under constants.IDIn ("id.in"), a predicate on the queried model's own id column, here kyc.screening.id. A screening id is never a customer id, so the two possible outcomes are both wrong: a caller WITHOUT customer read-all gets a non-nil permitted list and therefore an EMPTY page, whatever screenings exist; a caller WITH read-all gets nil, MergeSearchParams returns the parameters untouched, and the page is UNSCOPED. There is no middle case in which a caller sees their own customers' screenings. Each row carries a full serialised UniversalKYCResponse in response — the module's own normalised body, not the provider's raw payload — with no projection, so pages can be large.
Description
Next Page