CorebanqCorebanq Developer Docs
KYC

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=True

Response. 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_score and overall_risk_level are 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: ScreenEntity seeds it low and raises it through isHigherRisk for each result, so one high type forces the whole screening high regardless of what the mean does. Three types scoring 0.75, 0.0 and 0.0 give overall_score 0.25 next to overall_risk_level high. Branch on the level, not the score — the score dilutes a single serious hit as the array widens.
  • risk_level per result comes from mapScoreToRiskLevel against thresholds that default to critical 0.9, high 0.7, medium 0.5. So 0.75 is high, not medium.
  • summary is fmt.Sprintf("%d matches found", n) verbatim — "1 matches found", ungrammatical and not localised — or the literal "No matches found".
  • recommendations are i18n catalogue strings only, resolved with config.DefaultLanguage() rather than the request's Accept-Language. A critical/high result 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 is kyc_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 a 400, not a default; -1 skips the data fetch and returns totals only
  • offset (integer): ignored on this route — see "Pagination past page one does not work" below
  • sort (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=20

3. 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 discarded
  • include_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=true

Statistics

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

OperatorDescriptionExample
eq (default)Equalsearch.entity_type=customers
neNot Equalsearch.status.ne=failed
gtGreater Thansearch.match_count.gt=0
gteGreater Than or Equalsearch.highest_score.gte=0.8
ltLess Thansearch.screening_date.lt=2024-12-31T23:59:59Z
lteLess Than or Equalsearch.highest_score.lte=0.5
inIn Arraysearch.status.in=completed,pending
ninNot In Arraysearch.entity_type.nin=organizations,unknown
likeCase-insensitive substring — rendered ILIKE '%value%' ESCAPE '\'. Your own % never reaches the pattern: it is stripped before the clause is built, see belowsearch.entity_name.like=John
start_withCase-insensitive prefixsearch.entity_name.start_with=Jo
end_withCase-insensitive suffixsearch.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, collection
  • status, screening_date, match_count, highest_score, has_matches
  • requested_by, assigned_to, assigned_at, case_id
  • created_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=true

Advanced Search:

GET /v1/kyc/screening?search.match_count.gt=0&search.highest_score.gte=0.8&sort=-screening_date&limit=20

Grouped Results:

GET /v1/kyc/screening?search.has_matches=true&stack=entity_type

Universal 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), xziel and mock. 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.

DriverWhat it does
opensanctionsCalls the OpenSanctions match API directly. Default.
xzielScreens through XZiel, which runs the same lists.
mockDeterministic 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 comprehensive result — modules/risk classifies 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: sanctions is evidenced by a BLOCK decision alone, regardless of topics, and if no match carries a sanctions topic the filter comes back empty and matchesEvidencing falls back to the full list. So a BLOCK whose only match is role.pep files 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 hand modules/risk a 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.family counts as role.pep, adverse-media.fraud as adverse-media. The dot is the boundary, not a bare string prefix: without it sanctioned would read as sanction and reg.warning as reg.warn, and an unrelated match would be filed under sanctions where determineSanctionType reads a programme off it. sanctions is evidenced by sanction (so also sanction.linked and sanction.counter), debarment and asset.frozen; peps by role.pep and role.rca; adverse_media by adverse-media. role.rca and asset.frozen are 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 is role.rca and never role.pep, an asset freeze is asset.frozen and never sanction. 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 leftover comprehensive result — 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_status says in WHAT WAY the subject is exposed, in the vocabulary modules/risk scores with: foreign for role.pep, family for role.rca. matches[].pep_status — the published field — says only WHETHER they are exposed at all, and reads PEP for 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/risk then 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 as foreign rather 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.disqual and poi. 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 the comprehensive leftover result, so the evidence is always reachable from the record.

  • metadata.xziel_screening_id is the handle back to the screening, not request_id — the service layer replaces request_id with its own row id before an API consumer sees it. XZiel's public surface has no lookup by our reference_id, so losing that metadata key loses the evidence.

  • requires_review follows the provider's decision, not the match count. XZiel can return REVIEW on 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 409 for 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=true on this module, and every risk assessment in modules/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 500 too, 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, REVIEW and BLOCK are accepted — XZiel's public surface clamps HOLD and ESCALATE to REVIEW before 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:

UpstreamMessage codereason
401kyc_m.provider_auth_errorprovider_unauthenticated
403kyc_m.provider_unauthorizedprovider_forbidden
429kyc_m.rate_limit_exceededprovider_rate_limited
400kyc_m.failed_to_processprovider_rejected_payload
409kyc_m.failed_to_processidempotency_conflict
no answer in timekyc_m.provider_config_errorprovider_timeout
unreachable, or answered 408/5xxkyc_m.provider_config_errorprovider_unavailable
unusable body, over the size ceilingkyc_m.invalid_responseprovider_unavailable
unusable body, a required field missingkyc_m.invalid_response— (field instead)
body that is not decodable JSON at allkyc_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:

ValueWhat happens
customersWorks. Requires an individual object with a non-empty first_name and last_name; screened against the person schema.
organizationsWorks. Requires a company object with a non-empty company_name; screened against the company schema.
shareholders, signatories, users, personas, unknownRejected 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_ready has no entry in backend.kyc.i18n.yaml, but the sibling backend.kyc.20260731T165143Z.diff.yaml supplies one and the seed loader imports every *.diff.yaml from 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 and message is the raw key string rather than a sentence.
  • kyc_m.invalid_entity_type resolves to "Must be one of: person, business, legal, vessel, aircraft" — an obsolete value set that matches nothing this API accepts. Branch on code and write your own text; see Entity Types for the real values.

Module codes

StatusCodeWhen
400kyc_m.invalid_entity_typeMissing 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
400kyc_m.invalid_entity_nameentity_type is right but the matching object is absent or its name fields are empty
400kyc_m.invalid_screening_typesscreening_types empty or absent
400kyc_m.not_foundA malformed {id} on the read route — the uuid fails to parse and the handler answers a not-found code with a 400 status
400common.invalid_inputA malformed filter JSON or a non-numeric limit/offset. Not a failed body decode on POST /v1/kyc/screening — see the first row
400query_m.invalid_sort_fieldsort 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
400query_m.invalid_search_valueA 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
400common.invalid_inputAlso ?distinct=, from applyDistinct
400query_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
400query_m.invalid_operatorOn 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
404kyc_m.not_foundNo screening row with that id
500kyc_m.failed_to_create, kyc_m.failed_to_get, kyc_m.failed_to_store, kyc_m.failed_to_parse, common.database_errorStorage 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_searchNot 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
404kyc_m.idv_signatory_not_foundNo 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
404kyc_m.idv_session_not_foundNot reachable over HTTP. Only the webhook and poller paths construct it
408kyc_m.failed_to_searchThe caller's context was already done — ensureContextActive, reason=context_done. On the list route (action=search_screenings) and stats (screening_stats_query)
408kyc_m.failed_to_getStats only, and only in a narrow window: GetScreeningStatsWithQuery checks the context, then calls GetScreeningStats, which checks again with this code and action=screening_stats
408kyc_m.failed_to_processXZiel only: the caller went away mid-call, reason=cancelled — distinguished from a provider timeout, which is a 502
409kyc_m.idv_already_in_progressA verification for this signatory is already running
429rate_limits_m.exceeded, rate_limits_m.global_exceeded, rate_limits_m.failed_to_increment_ip_limit, and two free-text keysFrom 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
500kyc_m.idv_not_supportedkyc.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
anykyc_m.idv_provider_errorIDV 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
502Seven 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
503kyc_m.service_not_readyEither 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.

StatusCodeCause
401common.unauthorizedNo bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expiredThe 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 envelopeGraceful 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
503auth_m.internal_server_error → Internal server errorThe 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=true to 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

MethodPathDescription
POST/v1/kyc/{customer_id}/signatories/{signatory_id}/idvCreates 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=trueReturns 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 .../idv without refresh serves cached data unless the session is active (pending or in_progress) and older than kyc.idv.settings.cache_refresh_minutes.
  • Supplying refresh=true bypasses 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_at is 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. fetchVerificationSnapshot returns (nil, nil) when the signatory has never had IDV initiated, and loadSignatoryIDVStatus hands that nil to tryRefreshVerification, which dereferences it on both branches — shouldAutoRefresh reads verification.Status without refresh, and with refresh=true the || short-circuits past it so refreshFromProvider reads verification.SessionID instead. The request panics. GORM's Transaction does not recover it — it sets panicked and lets a deferred Rollback run while the panic unwinds — so the original panic reaches middleware.Recoverer on the root router, which calls w.WriteHeader(500) and nothing else: the response has an empty body and no Content-Type. Not the envelope, and not parseable as an error of any shape. There is a third read of the pointer further down, at verification.ID != uuid.Nil, but one of the two dereferences above always fires first. buildStatusResponse, one frame further down, does check verification != nil — the nil case was foreseen and the guard landed too late. Initiate IDV before reading it.
Session statusCache agerefresh queryBehavior
pending / in_progressTTLfalseAutomatically calls Ondato and persists the update
pending / in_progressanytrueForces provider refresh
completed / failed / expired / abortedanyfalseAlways returns cached data
completed / failed / expired / abortedanytrueForces provider refresh
no verification row at all—eitherPanics — 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/ondato

Note 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 in NewIDVService. Values ≤0 fall back to constants.KYCIDVDefaultCacheMinutes (5). Active sessions exceeding this age auto-refresh in shouldAutoRefresh.

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 (ShouldRetry returns false for permanent failures) and applies updates via HandleOndatoWebhook, which calls updateVerification and updateSignatoryFromWebhook inside one transaction.
  • The poller and refresh=true endpoint are safety nets when a webhook is delayed or missed. These are the only two callers of applyProviderUpdate — the poller, and the GET whenever it refreshes, which is refresh=true or an active session past the cache TTL, not refresh=true alone. The webhook path does not use it, and the two have different semantics: applyProviderUpdate derives the signatory update from freshData.Status, while the webhook path resolves the status first (resolveStatus falls back to the stored one when the event carries none) and updates the signatory from that.
  • GetSignatoryIDV always validates that the {signatory_id} is linked to the {customer_id} before returning data. That is not a tenant boundary, despite how it reads: ensureSignatoryOwnership checks 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.

On this page