Description
KYC Screening API
The KYC Screening API provides universal Know Your Customer screening capabilities with support for multiple providers. It implements a database-first strategy for efficient screening and comprehensive search capabilities.
Purpose and use
KYC screening records document identity checks, risk signals, and review status for individuals and other screened parties. The module supports onboarding decisions, periodic review, remediation, and evidence retention for compliance audits.
Who uses this. Compliance analysts, onboarding teams, and operations managers use KYC screenings when deciding whether a person can be onboarded, needs enhanced due diligence, or requires remediation.
How it works. A screening records the subject, screening result, status, timestamps, and supporting metadata. Reviewers use the stored screening history to compare current status against previous checks and to explain decisions during audit review.
What users do. Users start a screening, review the result, search historical screenings, check statistics, and update the review state once a compliance decision has been made.
Outcomes and side effects. Screening outcomes inform customer activation, task creation, risk classification, and audit trails. They do not post ledger entries, but they can block or delay onboarding and product access.
Related manuals: KYB, Risk Assessment, Customers, Tasks.
Core API Methods
The KYC API provides three core methods that work universally across all providers:
1. Screen Entity (POST)
POST /v1/kyc/screening
Universal screening method with db-first check strategy. Checks database for existing results first, then performs new screening if needed.
Query Parameters:
force(string): Force new screening, bypassing database cache (case-insensitive, accepts 'true', 'TRUE', 'True', etc., default: false)
Request Body:
{
"entity_type": "customers",
"individual": {
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1980-01-01",
"nationality": "US",
"addresses": [
{
"country": "US",
"city": "New York"
}
]
},
"screening_types": ["sanctions", "peps"],
"metadata": {
"source": "customer_onboarding",
"case_id": "123e4567-e89b-12d3-a456-426614174000"
}
}Examples with Force (case-insensitive):
POST /v1/kyc/screening?force=true
POST /v1/kyc/screening?force=TRUE
POST /v1/kyc/screening?force=TrueResponse. Five fields in it are computed, not free-form, and every one of them has been wrong in an earlier revision of this example:
These are four rules over five fields, and only the first is shared with mock. The mean/max
aggregation is; the other three are opensanctions-only. mock hard-assigns each result's risk_level by
screening type rather than mapping a score, writes per-type English sentences for summary
("No PEP matches found", not "0 matches found"), and appends English string literals for
recommendations instead of resolving catalogue keys — so under a non-English DefaultLanguage
the two drivers answer in different languages. xziel differs again on summary:
xzielScreeningResults writes fmt.Sprintf("%d matches found", len(matches)) unconditionally, so
a requested type that nothing evidences carries "0 matches found" — the very string the bullet
above says mock avoids. Under xziel the response is
built by toUniversalKYCResponse, which does not aggregate over results at all:
overall_risk_level maps the provider's action (ALLOW→low, REVIEW→medium, BLOCK→critical;
the mapper's fourth arm is unreachable because validation refuses any other action), overall_score is screening.RiskScore / 100, total_matches is the
provider's own count rather than a sum over the results, requires_review is simply
"action ≠ ALLOW", and recommendations come from xzielRecommendationCodes — sanctions key on
BLOCK only, adverse-media key never. entity_type is set: toUniversalKYCResponse omits it,
but XZielService.ScreenEntity assigns it, along with entity_id and case_id, on the response
the helper returned. The read and cache-hit paths render from the stored row and are identical
across drivers.
overall_scoreandoverall_risk_levelare aggregated two different ways, and can disagree. The score is the mean of the per-type scores (calculateOverallScore) — here(0.0 + 0.75) / 2 = 0.375. The level is the maximum per-type level:ScreenEntityseeds itlowand raises it throughisHigherRiskfor each result, so onehightype forces the whole screeninghighregardless of what the mean does. Three types scoring 0.75, 0.0 and 0.0 giveoverall_score0.25 next tooverall_risk_levelhigh. Branch on the level, not the score — the score dilutes a single serious hit as the array widens.risk_levelper result comes frommapScoreToRiskLevelagainst thresholds that default tocritical0.9,high0.7,medium0.5. So0.75ishigh, notmedium.summaryisfmt.Sprintf("%d matches found", n)verbatim —"1 matches found", ungrammatical and not localised — or the literal"No matches found".recommendationsare i18n catalogue strings only, resolved withconfig.DefaultLanguage()rather than the request'sAccept-Language. Acritical/highresult contributes one per type (kyc_m.high_risk_sanctions_match,kyc_m.pep_match_found,kyc_m.adverse_media_found); if none qualifies but matches exist, the single fallback iskyc_m.matches_found_review, "Matches found - Review recommended"; with no matches,kyc_m.no_matches_found, "No matches found - proceed with standard due diligence". Free-form advice never appears.
{
"request_id": "123e4567-e89b-12d3-a456-426614174000",
"entity_type": "customers",
"overall_risk_level": "high",
"overall_score": 0.375,
"screening_results": [
{
"screening_type": "sanctions",
"risk_level": "low",
"score": 0.0,
"status": "completed",
"summary": "No matches found",
"last_updated": "2025-09-03T18:32:38.854422+02:00"
},
{
"screening_type": "peps",
"risk_level": "high",
"score": 0.75,
"status": "completed",
"summary": "1 matches found",
"matches": [
{
"match_id": "NK-3vJ8pQwLm2",
"match_type": "name_match",
"confidence": 0.75,
"risk_level": "high",
"matched_entity": {
"id": "NK-3vJ8pQwLm2",
"name": "John Doe",
"schema": "Person",
"datasets": ["peps"]
},
"match_reason": "Match found with score 0.75",
"source": "OpenSanctions",
"source_id": "NK-3vJ8pQwLm2",
"pep_status": "PEP"
}
],
"last_updated": "2025-09-03T18:32:38.854422+02:00"
}
],
"total_matches": 1,
"has_matches": true,
"requires_review": true,
"screened_at": "2025-09-03T18:32:38.902118+02:00",
"recommendations": [
"PEP match found - Enhanced Due Diligence and senior management approval required"
],
"metadata": {
"provider": "opensanctions",
"user_id": "66260c8c-7743-4dfb-84ba-ae9cf6794c9a"
}
}2. Get All Screenings (GET)
GET /v1/kyc/screening
Comprehensive search method for existing screening records in the database with advanced query capabilities.
Advanced Query Parameters:
search.*- Advanced search operators (see Advanced Search section below)limit(integer): Number of results to return (default: 10, max: 100). A non-numeric value is a400, not a default;-1skips the data fetch and returns totals onlyoffset(integer): ignored on this route — see "Pagination past page one does not work" belowsort(string): Sort fields (comma-separated, prefix with - for descending)stack(string): Group results by field
Example:
GET /v1/kyc/screening?search.entity_type=customers&search.has_matches=true&limit=203. Get Screening by ID (GET)
GET /v1/kyc/screening/{id}
Retrieve an existing screening record from the database by ID.
This route applies no record-level authorization. GetScreening calls GetScreeningByID,
a bare WHERE id = ?; the resolved user id reaches neither the query nor an RbacRecordType.
Any caller holding the endpoint grant can read any screening row by id, matches included. The
CUSTOMER record scoping that GET /v1/kyc/screening applies is not applied here, so a 404 from
this route means "no such id" and never "not yours". This is the same gap the statistics route
has — and the list route is no better: it asks for CUSTOMER-record scoping and then applies the
permitted customer ids to kyc.screening.id, so it answers with an empty page or an unscoped
one (see "The totals are not scoped to the caller" below). No route in this module is a tenant
boundary. Endpoint-level RBAC is the only one that holds.
Query Parameters:
include_matches(string): parsed case-insensitively, then discardedinclude_response(string): parsed case-insensitively, then discarded
Neither flag does anything. The handler reads both and the service forwards both, but
convertToUniversalResponse declares them as blank parameters —
convertToUniversalResponse(screening *models.KYCScreening, _, _ bool) — so nothing reads them.
matches is always rendered, out of the stored response column, whether or not
include_matches is sent; and include_response cannot add the provider's raw payload because
UniversalKYCResponse has no field to put it in. They are documented because they are still
accepted, not because they work — a client must not depend on either to widen or narrow the body.
Examples:
GET /v1/kyc/screening/123e4567-e89b-12d3-a456-426614174000?include_matches=true
GET /v1/kyc/screening/123e4567-e89b-12d3-a456-426614174000?include_matches=TRUE
GET /v1/kyc/screening/123e4567-e89b-12d3-a456-426614174000?include_matches=True&include_response=trueStatistics
Get Screening Statistics
GET /v1/kyc/screening/stats
Get comprehensive statistics about screening operations.
Two things about this route are not what they look like.
The date range does not work. The handler runs the full shared query parser and hands the
result to the service, which then reads the keys date_from and date_to. The parser never emits
those keys — its switch recognises limit, offset, sort, stack, distinct, filter,
search, search_text and fill_gaps, plus any search. key, and everything else hits
default: return nil. Both are therefore
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.
The totals are not scoped to the caller. 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. Any caller holding the endpoint grant sees the whole table's totals.
The list route, GET /v1/kyc/screening, asks for scoping and misapplies it, which is not much
better. It sets RbacRecordType: constants.CustomerRecName, so the permitted customer ids are
resolved — and MergeSearchParams injects them under constants.IDIn, i.e. id.in, a predicate
on kyc.screening.id. A screening id is never a customer id. So a caller without customer
read-all gets a non-nil permitted list and an empty page, and a caller with read-all gets nil,
MergeSearchParams returns the parameters untouched, and the page is unscoped. There is no
middle case in which someone sees exactly their own customers' screenings. Endpoint-level RBAC is
the only boundary that actually holds on these six routes.
The keys of by_entity_type are the stored entity_type values, so in practice customers and
organizations — the legacy person/business/legal spellings below cannot occur. by_status
keys are pending, completed, failed and error. recent_screenings carries omitempty, so
it is absent, not [], when there is nothing to report.
Response:
{
"total_screenings": 1250,
"total_matches": 45,
"match_rate": 0.036,
"by_entity_type": {
"customers": 800,
"organizations": 450
},
"by_collection": {
"default": 600,
"sanctions": 400,
"peps": 250
},
"by_status": {
"completed": 1200,
"pending": 30,
"failed": 20
},
"recent_screenings": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"entity_type": "customers",
"entity_name": "John Doe",
"entity_schema": "Person",
"collection": "sanctions",
"status": "completed",
"screening_date": "2025-01-02T10:30:00Z",
"match_count": 1,
"highest_score": 0.75,
"has_matches": true
}
]
}Advanced Search Capabilities
The GET /v1/kyc/screening endpoint supports advanced search operations using the query package:
Search Operators
All search parameters must be prefixed with search.:
| Operator | Description | Example |
|---|---|---|
| eq (default) | Equal | search.entity_type=customers |
| ne | Not Equal | search.status.ne=failed |
| gt | Greater Than | search.match_count.gt=0 |
| gte | Greater Than or Equal | search.highest_score.gte=0.8 |
| lt | Less Than | search.screening_date.lt=2024-12-31T23:59:59Z |
| lte | Less Than or Equal | search.highest_score.lte=0.5 |
| in | In Array | search.status.in=completed,pending |
| nin | Not In Array | search.entity_type.nin=organizations,unknown |
| like | Case-insensitive substring — rendered ILIKE '%value%' ESCAPE '\'. Your own % never reaches the pattern: it is stripped before the clause is built, see below | search.entity_name.like=John |
| start_with | Case-insensitive prefix | search.entity_name.start_with=Jo |
| end_with | Case-insensitive suffix | search.entity_name.end_with=Doe |
These eleven are validOperators in full. Note the spellings: start_with and end_with, not
startswith. ilike and contains are not here — see the 400 they produce in the error
table below.
Every string search value is silently rewritten before it becomes a predicate.
createSearchCondition runs sanitizeValue on the parsed value, and for a string that means
sanitizeStringValue: it keeps letters, digits, ., space, /, @, - and _, and deletes
every other rune, then trims. Only a value sanitizeDate recognises as a timestamp escapes it.
The rewrite happens before escapeLikeValue, so a caller's % and _ are not escaped as
literals — the % is gone by then, and _ survives and is escaped.
This is not confined to like. ?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 than the caller asked for. Neither is an error: the route answers 200 with a page
the caller cannot explain from their own query. There is no way to search a name containing an
apostrophe, an ampersand or a comma on this route.
Searchable Fields
id,entity_type,entity_id,entity_name,entity_schema,collectionstatus,screening_date,match_count,highest_score,has_matchesrequested_by,assigned_to,assigned_at,case_idcreated_at,updated_at— see the warning below
updated_at and modified_at are both wrong, in opposite directions. The valid-field map
declares updated_at against a column named updated_at, but kyc.screening has no such column:
it carries modified_at, from the embedded BaseModelV2. So search.updated_at.gte=… builds a
predicate on a column that does not exist and the query fails at the database rather than at
validation. And modified_at, the column that is there, is absent from the map, so
search.modified_at=… is refused as an invalid search field. Neither spelling filters by
last-modification date; use screening_date or created_at.
?search.id.in=… panics the request for a caller without customer read-all. This one never
reaches the envelope at all. MergeSearchParams asserts that a pre-existing id.in value is
[]string, so it can append the permitted records to it; a value the caller supplied has been
through JSON and is []any, so the assertion fails and the function returns
(nil, 400 query_m.invalid_field). Its only caller, getall.Service.getQueryParams, then writes
queryParams.RbacPermittedRecords through that nil pointer before testing the error. The
deref panics. It happens at the top of GetAllTotal, before any database transaction is
opened, so nothing is rolled back and nothing intercepts it — the metrics middleware's recover
re-panics deliberately and sits outside the recoverer anyway. middleware.Recoverer answers
w.WriteHeader(500) with an empty body. A caller with read-all is
unaffected — permittedRecords is nil and MergeSearchParams returns before the assertion — so
the same request is a bodiless 500 for one caller and a 200 for another.
An unrecognised search. is a 500, not a silent drop and not a 400. The parser's
default: return nil branch — the one that swallows a misspelt key — is only reached by keys that
are not search.-prefixed. ParseQueryParameters puts every search.-prefixed key into the
search map without looking at it. The refusal happens later, in validateFieldName, which builds
MsgInvalidSearchField with no WithCode; applySearch in the GetAll pipeline returns it
unchanged, and an AppError with an empty Code makes HandleAppErrorWithCode fall through to
InternalServerError500. So ?search.modified_at=x answers 500 query_m.invalid_search_field,
class: business — a caller's typo reported as a server fault, and the same for
search.updated_at, which fails one step later in the database.
The neighbouring branch of the same function behaves differently. A field name that fails the
identifier pattern — ?search.foo-bar=x — is checked before the valid-field lookup and is
built WithCode(400), so it answers 400 query_m.invalid_field. Only the valid-field branch,
MsgInvalidSearchField, is left uncoded and therefore rendered as a 500. Two rejections one
if apart, two statuses.
There is a path that turns query_m.invalid_search_field into a 400: query.ApplySearchAndSort
re-raises it WithCode(400). It is not this route's path. kycRepository.SearchScreeningsWithQuery in
calls it, but nothing calls that method — the handler calls the
identically-named method on UniversalScreeningService, which goes through
getall.Service.GetAllTotal instead. Two functions, one name, one of them dead; read the service,
not the repository.
The silent-drop rule still holds for a key that is neither search.-prefixed nor one of limit,
offset, sort, stack, distinct, filter, search, search_text, fill_gaps.
Pagination & Sorting
limit(integer): Results per page (default: 10, max: 100)offset(integer): inert — parsed, carried, never applied
Pagination past page one does not work. FetchKYCScreenings calls
applyParams(..., isCount: false), and applyPaginationAndSort ends return result.Limit(limit)
— there is no .Offset(...) anywhere on this path. Of the 41 Fetch* functions in that file, 39 apply an offset — 37 through GetOffset,
FetchTariffs directly from *params.Offset, and FetchTransactionsV2 through
buildLimitOffsetClauses into its UNION SQL. The two that apply none are FetchKYCScreenings and
FetchUnifiedAccounts, so the unified-accounts route shares this defect. So
?offset=10&limit=10 returns the same first ten rows as ?limit=10, and a client walking
offsets re-reads page one forever. Page with a predicate instead — search.screening_date.lt or
search.created_at.lt against the oldest row you have seen, with sort=-screening_date.
sort(string): Sort fields (comma-separated, prefix with-for descending)stack(string): Group results by field
Examples
Basic Search:
GET /v1/kyc/screening?search.entity_type=customers&search.has_matches=trueAdvanced Search:
GET /v1/kyc/screening?search.match_count.gt=0&search.highest_score.gte=0.8&sort=-screening_date&limit=20Grouped Results:
GET /v1/kyc/screening?search.has_matches=true&stack=entity_typeUniversal API Design
The KYC API is designed with a universal interface that works across all providers. This eliminates the need for provider-specific endpoints and ensures consistency regardless of the underlying KYC provider being used.
Key Benefits:
- Provider Agnostic: the same API is served by whichever driver is configured. Three exist:
opensanctions(default),xzielandmock. ComplyAdvantage is not one of them — it is a KYT connector, wired to transactions, not to KYC. - Database-First Strategy: Checks existing data before making external calls
- Consistent Interface: No need to learn different endpoints for different providers
- Future Proof: Easy to add new providers without changing the API
- Simplified Integration: Three core endpoints handle all KYC operations
Screening drivers
Which backend answers a screening is decided by one config value, kyc.settings.driver. Nothing about the API changes with it: the route, the request body, the cache and the stored record are the same either way.
| Driver | What it does |
|---|---|
opensanctions | Calls the OpenSanctions match API directly. Default. |
xziel | Screens through XZiel, which runs the same lists. |
mock | Deterministic answers for tests. |
The xziel driver
XZiel is not a different list, it is a different place for the evidence to live. A screening sent through it is stored under the calling tenant in XZiel and appears in XZiel's own screening list, which is where a compliance officer reviews matches. The verdict still comes back inline and is still written to kyc.screening, so nothing downstream of this module has to know which driver ran.
Configuration lives under kyc.xziel.* — base_url, api_key, tenant_id, timeout. These are deliberately separate from kyt.xziel.*, which screens payments: the two may point at different XZiel tenants. tenant_id must be a UUID, because XZiel parses the tenant header before it looks at the key.
Set kyc.settings.driver as a config value to choose it. The Configurator's dropdown for that key still lists only opensanctions and mock: a seed can attach an option list to a key only by also writing that key's value, and seed import overwrites the stored value regardless of who last set it — shipping the option list that way moved every environment running mock onto opensanctions, which is how the gap was found. Pync 447980 tracks fixing the seed mechanism; until then the dropdown is short and the value is settable directly.
The driver refuses to build if any of the three is missing, still an unresolved ${...} placeholder, if the base URL is not an absolute http/https address, or if the tenant is not a UUID. That is not the same as failing the boot — GetKYCService logs the error and the service stays unbuilt, so screening calls fail rather than the process. It is still fail-closed in the sense that matters: a misconfigured driver never returns a clean verdict.
Five differences worth knowing before switching:
-
The verdict is reported under each requested screening type. XZiel sweeps sanctions, PEPs and the default dataset in one call and returns one decision, but the response repeats it per requested type rather than collapsing it into one
comprehensiveresult —modules/riskclassifies by switching on the screening type and would score a blocked subject as no risk otherwise. Where the matches evidence a requested type by their topics, the attribution is exact and each type carries only its own matches — with one exception:sanctionsis evidenced by aBLOCKdecision alone, regardless of topics, and if no match carries a sanctions topic the filter comes back empty andmatchesEvidencingfalls back to the full list. So aBLOCKwhose only match isrole.pepfiles that PEP match under the sanctions result too. Deliberate — a block is sanctions-grade by construction and under-reporting it is the worse failure — but it means a sanctions result's matches are not always sanctions-topic matches. Where no requested type is evidenced, every one of them inherits the overall verdict — that covers matches with no topics at all, and equally matches carrying only topics this driver does not classify. XZiel reviews on those too, and zeroing every requested type would handmodules/riska clean subject XZiel had flagged. Over-reporting a hit is recoverable; dropping one is not.Which topics evidence which type. A match is attributed by its OpenSanctions topics, and a topic counts for a family when it equals it or refines it with a dot —
role.pep.familycounts asrole.pep,adverse-media.fraudasadverse-media. The dot is the boundary, not a bare string prefix: without itsanctionedwould read assanctionandreg.warningasreg.warn, and an unrelated match would be filed under sanctions wheredetermineSanctionTypereads a programme off it.sanctionsis evidenced bysanction(so alsosanction.linkedandsanction.counter),debarmentandasset.frozen;pepsbyrole.pepandrole.rca;adverse_mediabyadverse-media.role.rcaandasset.frozenare named separately because OpenSanctions publishes a refinement without its parent, and these two name a risk this driver already reports while sharing no prefix with it — a politician's relative or close associate isrole.rcaand neverrole.pep, an asset freeze isasset.frozenand neversanction. Unlisted, they evidenced nothing: a screening carrying one of them alongside a match that did attribute had the family it belonged to zeroed to low with no matches, and its evidence filed under the leftovercomprehensiveresult — a stored record reading "PEP screening: low risk, no matches" for a politically exposed relative.The exposure a PEP match carries is reported in two places, because they answer two different questions in two different vocabularies.
matches[].match_details.pep_statussays in WHAT WAY the subject is exposed, in the vocabularymodules/riskscores with:foreignforrole.pep,familyforrole.rca.matches[].pep_status— the published field — says only WHETHER they are exposed at all, and readsPEPfor either, which is the same value the OpenSanctions driver sets; a relative or close associate counts, since FATF puts them under the same requirements. A match carrying both topics is the politician.Both stay absent unless the topics name an exposure. A sanctions or unclassified hit reports neither: that is not the same claim as naming a type, and
modules/riskthen applies its own fallback. OpenSanctions does not say whether an office is domestic or foreign and nothing else on a match does either, so a politician is reported asforeignrather than guessed at; that is also the risk module's fallback and the top of its ranking. See Risk Assessment for how the type becomes a factor and why that ranking does not follow the configured scores.The topics XZiel also reviews on that this driver deliberately does not attribute to a type are
role.oligarch,crime,wanted,export.control,reg.action,reg.warn,corp.disqualandpoi. None of them maps onto a screening type this module offers, and inventing one would be a compliance decision rather than a parsing improvement. They are not lost: a screening carrying only these hits the fallback above, and one carrying them beside an attributable match reaches thecomprehensiveleftover result, so the evidence is always reachable from the record. -
metadata.xziel_screening_idis the handle back to the screening, notrequest_id— the service layer replacesrequest_idwith its own row id before an API consumer sees it. XZiel's public surface has no lookup by ourreference_id, so losing that metadata key loses the evidence. -
requires_reviewfollows the provider's decision, not the match count. XZiel can returnREVIEWon evidence this connector never sees. -
The idempotency key is derived from the request, not from the call. XZiel replays a cached response for 24 hours when a key repeats — once there is one to replay. A second request arriving while the first is still in flight is screened too, because the peer dedupes on a stored response and has none yet; that needs two callers colliding within one screening call, and nothing in this codebase screens a subject from two goroutines. The guarantee is deliberately used for the sequential case: if a response is lost after XZiel has already screened, the module marks the row failed and its cache — which only returns completed rows — sends the next attempt out again. A per-call key would make that a second screening and a second charge; a request-derived one reaches the first screening and recovers its result. The key is a hash of the endpoint and the marshalled payload plus the entity type and entity id, so a field that reaches XZiel always reaches the key too — otherwise the same key would arrive with a changed body and XZiel would answer
409for the rest of the window. The requested screening types are not part of it: they never reach XZiel, which sweeps every dataset in one call, so two requests differing only in the types asked for are the same provider screening.A deliberate re-screen defeats the replay through
ScreeningAttemptID, which is not part of the JSON contract (json:"-") and is set only by a service:force=trueon this module, and every risk assessment inmodules/risk, which calls the driver directly and therefore has neither this cache nor the force flag. Were the field settable over the API, a caller could vary it per call and buy a screening every time.XZiel stores answers below
500too, refusals included. A tenant whose screening quota ran out at 09:00 would otherwise keep receiving the 09:00 refusal for the rest of the day, on the same key, with no way to ask again. So a refusal that XZiel marks as a replay (Idempotency-Replayed: true) is treated as stale state and the screening is sent once more under a fresh attempt. A replayed success is left alone — that one is the recovery the key exists for, and asking again would buy a second screening. -
The decision is an allowlist. Only
ALLOW,REVIEWandBLOCKare accepted — XZiel's public surface clampsHOLDandESCALATEtoREVIEWbefore they leave it, so a fourth value means the peer broke its contract. The response is refused rather than filed as a completed screening whose verdict nobody can read.
A provider fault is never re-served as XZiel's own status: passing a 401 through would tell a correctly authenticated caller they are unauthenticated, and a 429 would invite them to slow down over a quota that is not theirs. A 404 on a read is the exception — that one really is "no such screening" for the caller too.
The default OpenSanctions driver does the opposite for two statuses and something worse for
every other one. ScreenEntity loops the requested types and, when a call fails, returns early
only for upstream 401 (as 401 kyc_m.provider_auth_error) and upstream 403 (as
403 kyc_m.provider_unauthorized). So a wrong OpenSanctions key answers 401 to a caller whose own
token is perfectly valid, and a client that reacts by re-authenticating loops against a fault it
cannot fix. Branch on code, not on status alone.
Every other provider failure is swallowed. An upstream 429, any 5xx, a transport error or an
unparseable body is logged and the loop continues. A single-type screening whose one call failed
that way answers 201 with screening_results: [], overall_risk_level: "low",
overall_score: 0, has_matches: false — a clean verdict for a screening that never ran, and
nothing in the body distinguishes it from a subject the provider actually 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, which 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, since a still-failing provider
produces the same clean empty verdict. kyc_m.rate_limit_exceeded exists and the driver builds it, but it is
never delivered.
The XZiel rule above is a deliberate departure from all of this: it re-serves provider faults as
502 and never files a partial screening.
XZiel marks such an error 502 and the caller sees 502. Ondato does not:
CreateIDVSession re-serves the upstream status verbatim (WithCode(resp.StatusCode) for anything
>= 400), so kyc_m.idv_provider_error arrives at Ondato's own status, and 401, 403, 404,
409 and 429 all pass through. Note where a bad credential fails: CreateIDVSession calls
getAccessToken before building any request, so a wrong client_secret never reaches the session
endpoint — it comes back from requestAccessToken with reason=token_endpoint_error and the
token endpoint's status — typically 400 or 401 depending on that endpoint, since RFC 6749
§5.2 permits either and these credentials go in the form body rather than the Authorization
header. The platform forwards whatever Ondato returns, so branch on reason, not on the status. Only its transport, body-read and parse failures are genuinely 502. On the IDV POST,
any of those statuses carrying kyc_m.idv_provider_error is a provider fault rather than the
platform outcome the status normally means. The GET never shows one: tryRefreshVerification
swallows every provider error and returns the cached row. What follows — with one
exception worth guarding against: an XZiel body that will not unmarshal at all is
500 kyc_m.invalid_response, class: business, retryable: false. That is the shape a proxy's
HTML error page takes, so the one case where the peer is most obviously broken is the one case a
client branching on invalid_response ⇒ retryable upstream fault gets wrong. (The OpenSanctions driver never produces one. It builds 429 and 500 errors, but
ScreenEntity delivers only 401 and 403 and swallows the rest — see above.) HandleAppErrorWithCode has
carried an explicit case "502" since the envelope landed — the comment on it says why: letting
502 fall through to the default branch would stamp a genuine upstream failure class: business, retryable: false, the opposite of what a client should do with it. So these errors reach the wire
as 502 with class: temporary and retryable: true, which is what the OpenAPI spec documents.
The same is true of 504.
What distinguishes one provider fault from another for a caller is the message code plus the
reason parameter, since the status is the same 502 for most of them:
| Upstream | Message code | reason |
|---|---|---|
401 | kyc_m.provider_auth_error | provider_unauthenticated |
403 | kyc_m.provider_unauthorized | provider_forbidden |
429 | kyc_m.rate_limit_exceeded | provider_rate_limited |
400 | kyc_m.failed_to_process | provider_rejected_payload |
409 | kyc_m.failed_to_process | idempotency_conflict |
| no answer in time | kyc_m.provider_config_error | provider_timeout |
unreachable, or answered 408/5xx | kyc_m.provider_config_error | provider_unavailable |
| unusable body, over the size ceiling | kyc_m.invalid_response | provider_unavailable |
| unusable body, a required field missing | kyc_m.invalid_response | — (field instead) |
| body that is not decodable JSON at all | kyc_m.invalid_response | — (and the status is 500, not 502) |
kyc_m.failed_to_process also covers local failures and a caller who went away (reason: cancelled, the one provider-path error answered 408 rather than 500), so it is the reason that tells them apart, not the code on its own. A 404 on a read is kyc_m.not_found and a genuine 404.
Historical search is not offered by XZiel's public surface, so SearchScreenings returns an empty page and the search endpoints answer from kyc.screening as they always did.
Entity Types
entity_type is a string, matched case-sensitively. Seven values are declared
(), but only two of them can produce a screening:
| Value | What happens |
|---|---|
customers | Works. Requires an individual object with a non-empty first_name and last_name; screened against the person schema. |
organizations | Works. Requires a company object with a non-empty company_name; screened against the company schema. |
shareholders, signatories, users, personas, unknown | Rejected with 400 kyc_m.invalid_entity_type, and the validator's own allow-list says otherwise. They are declared, getEntitySchema maps four of them to the person schema, and the handler's validEntityTypes map lists all seven as valid — but the switch immediately after has a case for customers and organizations only, so the other five fall to default and are refused anyway. The map is dead weight; only the switch decides. |
Anything not in that list is refused the same way. Note that also declares
an older set — person, business, legal, vessel, aircraft — which no route accepts and
which no row can hold; they are dead constants and must not be used in a query or a request.
The validate:"required,oneof=customers shareholders signatories users personas organizations"
tag on the request struct never runs: the handler decodes with a plain json.NewDecoder and calls
no struct validator, so the switch is the only real gate. Note also that validateUniversalRequest
exists twice under the same name — once on the handler and once on the service. The handler's copy
runs first, so it is the one whose codes you see; the service's copy is reached only through paths
the handler already filtered, and where the two disagree the handler wins.
Screening Types
screening_types is an array and must be non-empty — an empty or absent one is refused with
400 kyc_m.invalid_screening_types. The service layer has its own copy of the same check that
answers kyc_m.invalid_collection instead, but the handler runs first, so that code is not
reachable on this route.
- sanctions: Sanctions and watchlists screening
- peps: Politically Exposed Persons screening
- adverse_media: Adverse media and negative news screening
- identity_verification: Identity verification and validation
- comprehensive: Comprehensive screening across all types
Every element is screened, but only two types have a collection of their own.
OpenSanctionsService.ScreenEntity loops the array and calls the provider once per element,
appending one ScreeningResult each. The collection it queries comes from the driver's
getCollectionForScreeningType, which maps sanctions and peps and sends adverse_media,
identity_verification and comprehensive to the generic default collection. So
["sanctions", "peps", "adverse_media"] issues three calls but hits two dedicated datasets and the
default one, and ["adverse_media", "comprehensive"] issues two identical calls. Note this is a
different function from the service's getCollectionFromScreeningTypes, which does have an
adverse_media arm and is what gets stored in the row's collection — so a screening can be filed
under a collection name that was never queried. A provider error on one type is logged and that type skipped
while the rest run — except a 401 or 403, which aborts the whole call.
But only the FIRST element decides the stored collection, and that is the cache key.
getCollectionFromScreeningTypes switches on screeningTypes[0] and ignores the rest, while
FindRecentScreeningByEntityID matches on collection. So a three-type screening is filed under
sanctions, and a later ["sanctions"] request for the same subject within 24 hours is served
that stored three-type result — wider than it asked for. The reverse is the one to watch: after
a ["sanctions"] screening, asking for ["sanctions", "peps", "adverse_media"] hits the same
cached row and returns the narrow result. It is detectable, but only if you look:
screening_results carries one entry per type that actually ran, so compare the
screening_type values you get back against the ones you asked for. A missing type is not
reported any other way. Reordering so a different type leads the array does miss the cache and screen afresh;
force=true says it outright and is the reliable way.
reference_id and entity_id are populated by opposite paths — on two of the three drivers.
Every driver copies reference_id onto the response it builds, and convertToUniversalResponse,
which renders a stored row, never assigns it. On opensanctions and mock entity_id is the
reverse: no driver sets it, only convertToUniversalResponse does. So there a fresh 201 carries
reference_id and no entity_id, while a cache hit or a GET .../{id} carries entity_id and no
reference_id.
xziel breaks that symmetry: ScreenEntity assigns entity_id and case_id from the request,
so a fresh 201 carries both fields at once. Do not use the presence of entity_id to detect a
cache hit — use screened_at, which is the stored screening_date on a hit under every driver.
assigned_to and assigned_at are absent from every response: no driver sets them and the
columns have no writer anywhere in the tree — the reviewer-assignment fields are declared and read,
never written. A client correlating requests by reference_id still loses the correlation on cached
answers.
What that key does not contain is the caller. FindRecentScreeningByEntityID matches
entity_id + collection + status = completed + screening_date > now() - 24h when entity_id is
supplied, and falls back to entity_name + collection + … when it is not. Neither query filters on
requested_by, on a tenant, or on anything else identifying who asked — so a hit can be a
screening another caller paid for, and a name-only request can return the screening of a different
subject who happens to share a common name. The answer is also indistinguishable from a fresh one:
both are 201, and request_id is simply the id of whichever row was served. screened_at is
the one tell: on a cache hit it is the stored screening_date of the original row, so a value up
to 24 hours old means the provider was not called. Send force=true whenever the answer must be
current or must belong to the subject you named — it skips the cache read entirely
(createScreeningWithoutCache) rather than invalidating it.
Errors
The envelope
Every non-2xx answer on every route is the shared apireply envelope:
{"status": 400, "message": "Invalid entity type. Must be one of: person, business, legal, vessel, aircraft", "code": "kyc_m.invalid_entity_type", "class": "validation"}message is the resolved i18n template, not a description of what went wrong with your
request; code is the machine key to branch on. code, class, retryable and details are
stamped only outside the 2xx range, so a success is exactly {"status": 200, "message": "OK"} —
never make class required in a generated client. ClassForStatus has no empty branch: 400 and
422 are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry
retryable: true; everything else, 5xx included, is business.
Two message quirks are worth knowing before you show message to an operator:
kyc_m.service_not_readyhas no entry inbackend.kyc.i18n.yaml, but the siblingbackend.kyc.20260731T165143Z.diff.yamlsupplies one and the seed loader imports every*.diff.yamlfrom the same directory, so it resolves to "The screening service is unavailable. Check the configured KYC provider." An environment seeded before 2026-07-31 falls through andmessageis the raw key string rather than a sentence.kyc_m.invalid_entity_typeresolves to "Must be one of: person, business, legal, vessel, aircraft" — an obsolete value set that matches nothing this API accepts. Branch oncodeand write your own text; see Entity Types for the real values.
Module codes
| Status | Code | When |
|---|---|---|
400 | kyc_m.invalid_entity_type | Missing entity_type, or one of the five declared-but-unusable values — and also a body that did not decode at all: the handler answers BadRequest400(errs.New(MsgKYCInvalidEntityType)) on any json.Decode failure, so a truncated or non-JSON body is reported as a bad entity type |
400 | kyc_m.invalid_entity_name | entity_type is right but the matching object is absent or its name fields are empty |
400 | kyc_m.invalid_screening_types | screening_types empty or absent |
400 | kyc_m.not_found | A malformed {id} on the read route — the uuid fails to parse and the handler answers a not-found code with a 400 status |
400 | common.invalid_input | A malformed filter JSON or a non-numeric limit/offset. Not a failed body decode on POST /v1/kyc/screening — see the first row |
400 | query_m.invalid_sort_field | sort names a field outside validFields — built WithCode(400), but validated only on the fetch pass, so an empty result set answers 200 for the same input |
400 | query_m.invalid_search_value | A search value unparseable as its declared type, e.g. ?search.id=abc; also two operators at once on _text, e.g. ?search._text.like=a&search._text.contains=b; also an .in/.nin value ParseInOperatorValue cannot split — except that third one is not reachable from a URL: every two-segment .in/.nin key arrives as []string, a longer key loses its operator, and the JSON round trip yields []any, all of which the function handles before its default branch |
400 | common.invalid_input | Also ?distinct=, from applyDistinct |
400 | query_m.invalid_field, query_m.invalid_format | ?stack naming an unknown field, or one missing its closing bracket; also a search. whose name fails the identifier pattern, e.g. ?search.foo-bar=x — that branch of validateFieldName is built WithCode(400), unlike its valid-field sibling, which renders as 500. query_m.invalid_format_rule is not reachable here — ValidateFormatRule only rejects a format for field type date or number, and this module declares only uuid, string, datetime, int, float, boolean |
400 | query_m.invalid_operator | On an ordinary field, .ilike and .contains — ParseFieldAndOperator recognises them, validOperators does not, so ?search.entity_name.contains=John is refused while the identical .like is accepted. On the reserved _text field the gate is validTextSearchOperators instead, whose complement is the other seven: ?search._text.gt=1 is refused with field=_text |
500, empty body | (none — the request panics) | A caller-supplied ?search.id.in=… from a caller without customer read-all. See below |
404 | kyc_m.not_found | No screening row with that id |
500 | kyc_m.failed_to_create, kyc_m.failed_to_get, kyc_m.failed_to_store, kyc_m.failed_to_parse, common.database_error | Storage or driver failure. failed_to_store is what a caller sees when the screening ran and the row could not be saved; failed_to_parse when the response could not be marshalled for storage |
| — | kyc_m.failed_to_search | Not deliverable at 500. It has seven constructors and not one of them reaches a client. Four are WithCode(500): FindRecentScreening (no caller anywhere), FindRecentScreeningByEntityID twice (whose error CreateScreening logs and continues past) and the dead kycRepository.SearchScreeningsWithQuery. Three more are WithCode(400) — the list handler's json.Marshal of the parsed query map, the stats handler's, and GetScreeningStatsWithQuery's unmarshal of the same — and all three move a map the shared parser has just produced, so none can fail. Over HTTP the code appears only at 408 |
404 | kyc_m.idv_signatory_not_found | No signatory with that id under that customer — on both IDV routes. ensureSignatoryOwnership and fetchSignatoryState run on the read path too, and this is the only 404 the GET genuinely has |
404 | kyc_m.idv_session_not_found | Not reachable over HTTP. Only the webhook and poller paths construct it |
408 | kyc_m.failed_to_search | The caller's context was already done — ensureContextActive, reason=context_done. On the list route (action=search_screenings) and stats (screening_stats_query) |
408 | kyc_m.failed_to_get | Stats only, and only in a narrow window: GetScreeningStatsWithQuery checks the context, then calls GetScreeningStats, which checks again with this code and action=screening_stats |
408 | kyc_m.failed_to_process | XZiel only: the caller went away mid-call, reason=cancelled — distinguished from a provider timeout, which is a 502 |
409 | kyc_m.idv_already_in_progress | A verification for this signatory is already running |
429 | rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit, and two free-text keys | From auth.RateLimitMiddleware, never from this module. kyc_m.rate_limit_exceeded is not a 429 — under xziel it is a 502, and under opensanctions it is built and swallowed |
500 | kyc_m.idv_not_supported | kyc.idv.provider is unset or none — not a property of the screening driver. compositeService.CreateIDVSession builds this itself when its fallback to the screening driver fails, which it does for every screening driver except mock. Built WithCode(501) and delivered as 500 — HandleAppErrorWithCode's switch has no case "501", so it falls to InternalServerError500. An apireply.NotImplemented501 helper exists and is wired to nothing. Two helpers are missing from the switch — this and NoContent204, which bypasses the dispatcher entirely — but 204 is not declared on any route here, so this is the only gap that matters. Branch on the code |
| any | kyc_m.idv_provider_error | IDV POST only. Ondato's own status, forwarded verbatim from CreateIDVSession or requestAccessToken (reason=token_endpoint_error). A status the switch does not know falls to 500, so the POST's declared response set is not closed. The third passthrough, GetIDVSession, is reached only from the GET — where the error is swallowed — and from the poller, so it reaches no client |
502 | Seven codes. On the screening POST under xziel, providerError stamps 502 on all six of kyc_m.provider_auth_error (upstream 401), kyc_m.provider_unauthorized (403), kyc_m.rate_limit_exceeded (429), kyc_m.failed_to_process (400, 409), kyc_m.provider_config_error (408, 5xx, default) and kyc_m.invalid_response (size cap, missing field). On the IDV POST, kyc_m.idv_provider_error. | The reason parameter separates them — see the XZiel table above. kyc_m.invalid_response is 500 on its third path, a body that will not unmarshal at all; kyc_m.idv_provider_error usually arrives at Ondato's own status rather than 502 |
503 | kyc_m.service_not_ready | Either kyc.settings.driver or kyc.idv.provider is unusable; one sync.Once builds both, so either failure takes down all six routes, screening included — see the note at the end of this section. Checked inside the handler, which runs only after auth.Middleware admits the request, so an unauthenticated caller gets 401, never this |
Rate limiting
auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but
only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag
once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent
from the chain entirely — not mounted and idle — and no route can answer 429. Changing the
flag takes a restart. When it does fire
the 429 code is not common.too_many_requests — that key is only the fallback the reply
helper stamps when it is called with no AppError at all. The codes that really arrive are
rate_limits_m.exceeded, rate_limits_m.global_exceeded and
rate_limits_m.failed_to_increment_ip_limit, plus two free-text strings, rate limit exceeded and
Global rate limit exceeded, that are not i18n keys.
Refusals the middleware writes before the handler runs
These apply to every route in this module and are produced by the root router's middleware
chain — auth.RateLimitMiddleware when rate_limits.rate_limits_switcher is on, then
health.LifecycleMiddleware, then auth.Middleware — not by module code, which
is why they are easy to overlook.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | No bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired | The licence branch. Only these two are reachable on this module. license_m.module_not_licensed is not: kyc implements loader.LicensedModule, so the loader calls IsModuleLicensed("kyc") at boot and skips the whole module when the licence does not cover it — the routes are never registered and an unlicensed tenant gets chi's plain 404, not a JSON 403. The per-request check re-reads the same env licence key, whose module list cannot change while the process runs. (On a module the loader does not gate, that code is reachable per request.) Two further codes the middleware matches cannot reach any client: license_m.license_service_unavailable is written to the licence-error context by no code path, and license_m.license_key_missing needs licenseService == nil, a state a running process cannot be in — rbac.Init calls InitLicenseService unconditionally and routes a failure through logger.Fatalf |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler. Whether it precedes everything depends on the flag: with rate_limits.rate_limits_switcher on, auth.RateLimitMiddleware is registered two lines earlier and a caller over its limit gets a 429 even while the server drains; with the flag off the limiter is not in the chain at all and LifecycleMiddleware is the first middleware after CORS, so every in-flight request is answered with this body |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy — and only when the rate limiter is off, see below |
The 503 is two different bodies, and a client has to branch on the shape rather than assume
the envelope. While the server is draining, health.LifecycleMiddleware writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} —
with no status, code, class or retryable field on it at all, and whose message is a fixed
English string, not an i18n key, so Accept-Language does not translate it. The auth-cache 503
is the envelope, but its code is auth_m.internal_server_error, not common.server_error:
ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package
auth, so the constant that resolves is auth.MsgInternalServerError. A client branching on
common.server_error to detect a dead cache never matches.
And that second 503 is only observable with the rate limiter off.
auth.RateLimitMiddleware is registered before auth.Middleware and touches the same
Redis/valkey, so with rate_limits.rate_limits_switcher on a dead cache is answered by the limiter
first — 500 for an authenticated caller, 429 for an anonymous one — and ensureCacheAvailable
is never reached. That 500 is generic as well: handleRateLimitError's default branch calls
apireply.InternalServerError500(w, r) without the AppError, so
auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and
rate_limits_m.failed_to_increment_ip_limit are discarded and the body reads
common.server_error. Do not use the 503 as your cache-down signal in a deployment that rate
limits.
created_by and modified_by are stripped for most callers
Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares
also runs RemoveAuditFieldsHandler. It triggers on the Content-Type: application/json that
apireply.WithJSON always sets, unmarshals the whole body, deletes every created_by and
modified_by key at every nesting depth, replaces every comply_advantage_meta with null —
set to nil, not deleted, so the key survives with its value destroyed — and re-marshals.
The comply_advantage_meta clause is inert on these six routes. Despite the name, ComplyAdvantage
is a KYT connector: the only Go fields carrying that JSON key are ComplyAdvantageMeta on
models.Transactions and its counterpart on the transactions transform struct, and no KYC model, response or stored provider payload has it. The rewrite
still walks every KYC body looking for it and finds nothing. created_by and modified_by are
what actually gets removed here.
Two further consequences of the round trip: every number passes through float64, so an int64
beyond 2^53 comes back altered; and the rewrite is skipped entirely when
rbac.HasInternalRole returns an error, so the same request is answered with or without the
subtraction depending on database health. Key order is not preserved either.
Note that this module has a third 503, kyc_m.service_not_ready, which is the envelope and
is written by the handler itself. Either configuration axis triggers it, on all six routes.
GetKYCService runs initScreeningProvider and initIDVProvider under one sync.Once and stores
either failure in initErr; NewHandler then fails and Module.Handler falls back to
&Handler{service: nil}. So an unrecognised kyc.idv.provider, or ondato with an empty
api_url, makes the screening routes answer 503 too — and the reverse holds. The
validateIDVService branch for a nil IDV service object is dead: NewIDVService never returns nil,
and the one construction that leaves the field nil leaves service nil as well, so
validateService answers first. It does not precede authentication:
auth.WrapWithMiddlewares builds auth.Middleware(...) around the handler, so the driver check
inside validateService is reached only by an already-authenticated request. curl with no
bearer token against a driver-less instance gets 401 common.unauthorized.
Data Storage
All screening results are stored in the kyc.screening table with intelligent caching:
- Database-First Strategy: Checks existing results before external calls
- Force Option: Use
force=trueto bypass cache and force new screening - Comprehensive Search: Advanced query capabilities for existing records
- Provider Agnostic: Works with any KYC provider through universal interface
Identity Verification (IDV)
Beyond list screening, the KYC module exposes signatory-focused identity verification backed by Ondato. IDV follows the same transport conventions as screening and is fully wired through , , and .
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /v1/kyc/{customer_id}/signatories/{signatory_id}/idv | Creates a provider session for the referenced signatory. No body is required—the service pulls persona + user metadata inside the transaction. |
GET | /v1/kyc/{customer_id}/signatories/{signatory_id}/idv?refresh=true | Returns cached status, optionally forcing a refresh when refresh=true. Case-sensitive, unlike every other boolean flag in this module: the IDV handler compares r.URL.Query().Get("refresh") == "true" with no strings.ToLower, so refresh=TRUE and refresh=True are read as false and silently serve the cache. force, include_matches and include_response on the screening routes are lowercased first. The handler only parses the two UUIDs (extractSignatoryPathParams); the nil-UUID refusal is in the service and the ownership check is ensureSignatoryOwnership, inside the transaction. Like the POST, the route rejects the nil UUID in either position with 400 common.invalid_input and `field=signatory_id |
Both endpoints require the standard auth middleware (auth.WrapWithMiddlewares in ). The POST response mirrors models.IDVInitiationResponse, while the GET response returns models.IDVStatusResponse with verification_id, provider session ID, URLs, status, timestamps, and the latest provider payload stored under response_data.
Status Refresh Strategy
GetSignatoryIDV implements a hybrid cache in :
- Calling
GET .../idvwithoutrefreshserves cached data unless the session is active (pendingorin_progress) and older thankyc.idv.settings.cache_refresh_minutes. - Supplying
refresh=truebypasses the age check and fetches from Ondato even for terminal statuses. - Completed/failed/expired/aborted sessions never auto-refresh, but you can still force-refresh them.
- If the provider call fails (
refreshFromProvider), the service logs a warning and returns the cached snapshot so the endpoint never errors just because Ondato is down.updated_atis left untouched by that path, so it is the only staleness signal in the body. - A signatory with no verification at all is an unguarded nil dereference, not a
404.fetchVerificationSnapshotreturns(nil, nil)when the signatory has never had IDV initiated, andloadSignatoryIDVStatushands that nil totryRefreshVerification, which dereferences it on both branches —shouldAutoRefreshreadsverification.Statuswithoutrefresh, and withrefresh=truethe||short-circuits past it sorefreshFromProviderreadsverification.SessionIDinstead. The request panics. GORM'sTransactiondoes not recover it — it setspanickedand lets a deferredRollbackrun while the panic unwinds — so the original panic reachesmiddleware.Recovereron the root router, which callsw.WriteHeader(500)and nothing else: the response has an empty body and noContent-Type. Not the envelope, and not parseable as an error of any shape. There is a third read of the pointer further down, atverification.ID != uuid.Nil, but one of the two dereferences above always fires first.buildStatusResponse, one frame further down, does checkverification != nil— the nil case was foreseen and the guard landed too late. Initiate IDV before reading it.
| Session status | Cache age | refresh query | Behavior |
|---|---|---|---|
pending / in_progress | TTL | false | Automatically calls Ondato and persists the update |
pending / in_progress | any | true | Forces provider refresh |
completed / failed / expired / aborted | any | false | Always returns cached data |
completed / failed / expired / aborted | any | true | Forces provider refresh |
| no verification row at all | — | either | Panics — empty 500 from the recoverer, not a 404 |
updated_at in the response reflects the last database write (initiation, webhook, auto-refresh, or poller). completed_at mirrors provider data and is set whenever Ondato supplies a timestamp — not only on a terminal status. updateVerification writes it whenever payload.completedAt != nil and applyProviderUpdate whenever freshData.CompletedAt != nil; the terminal gate, shouldPersistCompletion, guards only the signatory's idv_completed_at. Since buildStatusResponse prefers the signatory's value and falls back to the verification's, a payload carrying a timestamp beside a non-terminal status surfaces completed_at on a session the same body reports as pending.
Configuration
All IDV knobs live under kyc.idv.* and ship with documented defaults (). The sample configuration in .samples/data/config/kyc.config.yaml looks like:
idv:
provider: ondato
ondato:
api_url: https://idvapi.ondato.com
token_url: https://id.ondato.com/connect/token
client_id: your_client_id
client_secret: your_client_secret
verification_url: https://idv.ondato.com
webhook_secret: your_webhook_secret
settings:
cache_refresh_minutes: 5
poller:
enabled: false
interval_seconds: 45
batch_size: 50
worker_concurrency: 4
stale_minutes: 5
urls:
callback: https://your-domain.com/webhooks/ondato
success: https://your-domain.com/onboarding/success
failure: https://your-domain.com/onboarding/retry
queue:
driver: rabbitmq
host: localhost
routing_key: ondato.*
queue_name: ${KYC_RABBITMQ_QUEUE:-ondato.webhooks}
listener_route: /webhooks/ondatoNote a mismatch inside the seed itself: the key the code reads is kyc.idv.queue.name
(constants.KYCIDVQueueNameKey, resolved in ), and the file writes queue_name.
The block above is abridged — the file also carries setup_id, scope, timeout_seconds,
${CONFIG_KYC_IDV_ONDATO_*} placeholders for the credentials, and a dozen more queue keys
(exchange, port, username, password, virtual_host, durable, auto_delete,
prefetch_count, buffer_size, worker_count). Read the file for the full set. The queue_name
point stands: a deployment relying on this seed to set the queue name gets the built-in default
instead.
Key settings and how the code uses them:
- Cache refresh TTL (
kyc.idv.settings.cache_refresh_minutes): Loaded inNewIDVService. Values ≤0 fall back toconstants.KYCIDVDefaultCacheMinutes(5). Active sessions exceeding this age auto-refresh inshouldAutoRefresh.
Set provider: mock plus dummy URLs when you need deterministic integration tests; the service will short-circuit with errs.MsgIDVNotSupported if the driver is missing.
Operational Notes
- Webhooks are the primary source of truth; the queue consumer rejects malformed payloads (
ShouldRetryreturnsfalsefor permanent failures) and applies updates viaHandleOndatoWebhook, which callsupdateVerificationandupdateSignatoryFromWebhookinside one transaction. - The poller and
refresh=trueendpoint are safety nets when a webhook is delayed or missed. These are the only two callers ofapplyProviderUpdate— the poller, and the GET whenever it refreshes, which isrefresh=trueor an active session past the cache TTL, notrefresh=truealone. The webhook path does not use it, and the two have different semantics:applyProviderUpdatederives the signatory update fromfreshData.Status, while the webhook path resolves the status first (resolveStatusfalls back to the stored one when the event carries none) and updates the signatory from that. GetSignatoryIDValways validates that the{signatory_id}is linked to the{customer_id}before returning data. That is not a tenant boundary, despite how it reads:ensureSignatoryOwnershipchecks the signatory-to-customer link only — it never checks that the caller may access that customer, and the handler resolves the user id and discards it (if _, appErr := auth.GetUserID(r)). Any caller holding the endpoint grant who knows a valid pair reads that verification. Consistent with the rest of this module: no route here is a tenant boundary.
Stores an answer and advances the flow. Answers 200, with the same two-body split as navigate: KYBFlowResponse while the flow runs, KYBFlowCompletedResponse once its status is COMPLETE (upper case). Only actor_id and flow_name are validated. question_name is intentionally unchecked — it is empty when the flow has already ended. The body is read with a plain json.NewDecoder, so no struct validation runs and unknown members are ignored.
Returns the cached verification state for one signatory. ?refresh=true re-reads it from the provider first. status can be aborted, which this spec previously omitted — a terminal outcome distinct from failed and expired. A PROVIDER FAULT IS NOT AN ERROR ON THIS ROUTE. No 502 is declared because none is reachable: tryRefreshVerification swallows every error from refreshFromProvider, logs a warning and returns the cached verification. ?refresh=true against a dead provider answers 200 with STALE data and no indication that the refresh failed — compare updated_at against now if freshness matters — tryRefreshVerification returns the row untouched on a provider failure, so updated_at is the only staleness signal in the body. DO NOT CALL THIS FOR A SIGNATORY WITH NO VERIFICATION. That case is an unguarded nil dereference, not a 404 — see the 404 below.