CorebanqCorebanq Developer Docs
KYCv1KYC Screening

List screenings

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.

GET
/v1/kyc/screening

Authorization

bearerAuth
AuthorizationBearer <token>

JWT token from the authentication endpoint.

In: header

Query Parameters

limit?integer

Maximum rows. Shared parser, parseLimitOffset: a NON-NUMERIC value is 400 common.invalid_input — it is NOT silently defaulted; 0 or any negative value except -1 is coerced to the default 10; a value above 100 is coerced to 100; -1 passes through as LimitSkipDataFetching: the count is computed and the data fetch is skipped, so the response carries totals and no rows. Note that limit is the only working pagination control here — see offset.

offset?integer

IGNORED ON THIS ROUTE — PAGINATION PAST PAGE ONE IS NOT POSSIBLE. The value is parsed and carried in the parameters, but FetchKYCScreenings never applies it: it calls applyParams(..., isCount=false), whose applyPaginationAndSort ends return result.Limit(limit) with no .Offset(...) anywhere on the path. Around forty sibling Fetch* functions in the same file do call GetOffset and Offset; this one does not. So ?offset=10&limit=10 returns the SAME first ten rows as ?limit=10, and a client that walks offsets re-reads page one forever. Use a search predicate on screening_date or created_at to page instead. Parsing rules, for completeness: a negative value is coerced to 0; a NON-NUMERIC value is a 400 common.invalid_input from parseLimitOffset, not a default.

sort?string

Sort expression, passed through to the query builder.

stack?string

Group rows by this field; data then arrives as an object keyed by the stacked value.

distinct?string

Passed through to the query builder.

filter?string

A JSON document. Malformed JSON is 400 common.invalid_input. It is not the only query parameter that can fail: a non-numeric limit or offset is the same 400 from parseLimitOffset. An invalid search field is NOT a 400 on this route — see search..

search?string

Free-text search. Also accepted as search_text; both land on the same key.

search.<field>?string

Per-field filter, optionally with an operator suffix (.like, .gt, .gte, .in, …). Valid fields are exactly: id, entity_type, entity_id, entity_name, entity_schema, collection, status, screening_date, match_count, highest_score, has_matches, requested_by, assigned_to, assigned_at, case_id, created_at, updated_at.

TWO TRAPS. First, updated_at is listed but kyc.screening HAS NO SUCH COLUMN — the table declares modified_at — so ?search.updated_at=… passes the field-name check and then fails in the database. Second, modified_at, the column that does exist, is NOT in validFields, so filtering on it is refused as an invalid search field. Neither is usable.

HOW AN UNKNOWN FIELD IS REFUSED, AND WHY IT IS A 500. A search.-prefixed key is NOT subject to the shared parser's default: return nil branch — ParseQueryParameters puts every such key into the search map unvalidated. The refusal happens later, in validateFieldName. Its FIRST check is the identifier pattern, which builds MsgInvalidField WithCode(400) — that one is an honest 400. An unknown but well-formed field falls to the check after it, which builds MsgInvalidSearchField with NO WithCode. This route reaches it through the GetAll pipeline (getall.Service.GetAllTotal -> getAllQueryStorage.GetTotal -> applyParams -> applySearch -> query.ParseSearchQuery), and applySearch returns that error UNCHANGED. An AppError with an empty Code makes HandleAppErrorWithCode fall to InternalServerError500. So ?search.modified_at=x answers 500 query_m.invalid_search_field, class business, retryable absent — a caller error reported as a server fault. There IS a path that upgrades the same error to 400: query.ApplySearchAndSort re-raises it WithCode(400). It is not this one. kycRepository.SearchScreeningsWithQuery in common/storage/storage_kyc.go does call it, but nothing calls that method — the handler calls the identically-named method on UniversalScreeningService, which uses the GetAll pipeline instead. Do not read the repository function to predict this route's behaviour.

EVERY STRING VALUE IS SILENTLY REWRITTEN. createSearchCondition runs sanitizeValue on the parsed value; for a string that is sanitizeStringValue, which keeps letters, digits, . space / @ - and _ and DELETES every other rune, then trims. Only a value sanitizeDate recognises as a timestamp escapes it. It runs BEFORE escapeLikeValue, so a caller's % is not escaped as a literal — it is gone by then. ?search.entity_name=O'Brien builds entity_name = 'OBrien' and matches nothing; ?search.entity_name.like=Jo%hn becomes ILIKE '%John%' and matches a different set of rows. Neither is an error: the route answers 200 with a page the caller cannot explain from their own query. A name containing an apostrophe, an ampersand or a comma is not searchable here.

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v1/kyc/screening"
{
  "data": [
    {
      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
      "entity_type": "customers",
      "entity_id": "8161163a-f227-466f-bc01-090a01e80165",
      "entity_name": "string",
      "entity_schema": "string",
      "collection": "string",
      "status": "pending",
      "screening_date": "2019-08-24T14:15:22Z",
      "response": null,
      "match_count": 0,
      "highest_score": 0.1,
      "has_matches": true,
      "requested_by": "cda0f200-65cd-4343-aedd-9c936b908826",
      "assigned_to": "b9f52997-ff03-4166-bbff-22fd35e12939",
      "assigned_at": "2019-08-24T14:15:22Z",
      "case_id": "c74269e5-1f97-4e20-9164-ffbe3494d8d6",
      "created_at": "2019-08-24T14:15:22Z",
      "created_by": "ee824cad-d7a6-4f48-87dc-e8461a9201c4",
      "modified_at": "2019-08-24T14:15:22Z",
      "modified_by": "e8d4374d-93a1-4e98-a6c6-fdcf00c5059f",
      "active": true,
      "metadata": {}
    }
  ],
  "total": 0,
  "total_unfiltered": 0,
  "metadata": {},
  "keys": [
    "string"
  ],
  "has_more": true
}
{
  "status": 400,
  "message": "Invalid input",
  "code": "common.invalid_input",
  "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 search KYC screening records",
  "code": "kyc_m.failed_to_search",
  "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": "Database error",
  "code": "common.database_error",
  "class": "business"
}

{
  "overall_status": "unhealthy",
  "message": "Service is shutting down",
  "timestamp": "2026-08-27T15:04:05Z"
}

GETScreening statistics

Aggregate counts over EVERY screening row in the tenant, not the caller's permitted subset. The handler resolves a user id and passes it down, but GetScreeningStats hands the repository only the date range — the id reaches no query and no RbacRecordType is set — so any caller with the endpoint grant sees the whole table's totals. GET /v1/kyc/screening is no better: it asks for CUSTOMER-record scoping through GetAllTotalInput and then applies the permitted customer ids to kyc.screening.id, so it answers with an empty page or an unscoped one. Neither route is a tenant boundary. THE DATE RANGE DOES NOT WORK. The handler runs the full shared query parser and hands the result to the service, which then looks for the keys date_from and date_to. The shared parser never emits those keys — its switch recognises limit, offset, sort, stack, distinct, filter, search, search_text and fill_gaps, plus any search.<field> key, and everything else hits default: return nil. So both are always nil and the statistics are ALWAYS COMPUTED OVER ALL TIME, whatever the caller sends. Every other query parameter is parsed and then discarded too: only the two date keys are read, and neither can arrive. DATE RANGE: THERE IS NO WAY TO ASK FOR ONE. GetScreeningStatsWithQuery reads queryMap["date_from"] and queryMap["date_to"] and passes the parsed pair to GetScreeningStats, so the service is built for a range — but the keys never arrive. parseStandardQueryParameter has cases for limit/offset, sort/stack/distinct/fill_gaps, search/search_text and filter, and everything else falls to default: return nil. ?date_from=2026-01-01 is therefore handled IDENTICALLY to ?banana=1: both are dropped, and both answer 200 with all-time totals. The two are deliberately NOT declared as parameters for that reason — declaring one would be a contract statement the code does not make, and a generated client would emit a dateFrom argument whose caller receives all-time figures rendered as a filtered range. Contrast offset, which IS declared here and on the list route: parseLimitOffset really parses it and ?offset=abc really is a 400 common.invalid_input, so it exists in the request contract even though FetchKYCScreenings never applies it. date_from has no such foothold.

POSTScreen 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.)