CorebanqCorebanq Developer Docs
KYT

Description

Purpose and use

KYT records the compliance assessment of a payment or crypto movement before, during, or after execution. It helps the bank identify suspicious transactions, high-risk wallets, sanctions concerns, and transactions requiring manual review.

Who uses this. Compliance officers, financial-crime operations, payment operations, and audit teams use KYT results when deciding whether to release, hold, reject, or further investigate a transaction.

How it works. A transaction or crypto activity is screened and receives status, labels, risk indicators, provider response data, and review metadata. The result can be queried later from the transaction investigation workflow.

What users do. Users review KYT responses, filter by status or label, open the related transfer, and document any follow-up action such as release, hold, escalation, or remediation.

Outcomes and side effects. KYT can pause or reroute a transaction workflow, create compliance evidence, and support later audit review. It does not itself post ledger entries; transfer execution decides whether funds move.

Related manuals: Transfers, Risk Assessment, CRP Network Fees, Tasks.

Two rails, one prefix

Everything under /v1/kyt belongs to one of two unrelated subsystems. They share the path prefix and nothing else — not a table, not a provider, not an error namespace.

FiatCrypto
RoutesGET /v1/kyt, GET /v1/kyt/{transaction_id}POST /v1/kyt/crypto/screen, GET /v1/kyt/crypto/screenings, GET /v1/kyt/crypto/screenings/{screening_id}, GET /v1/kyt/crypto/poller/stats
What they doRead only. Nothing here screens anything; both routes read rows ComplyAdvantage screening already wrote into kyt.transactions.Call a provider, store the verdict, and report on the poller. mock is the default — GetCryptoKYTService falls back to constants.KYTProviderMock when app-config kyt.crypto_driver is unset, and that driver makes no HTTP call at all: it derives its verdict from the transaction-id prefix. A fresh install therefore works, and everything this page says about provider faults — the 409, both 502 codes — is reachable only under kyt.crypto_driver = xziel.
Error namespacecomplyadvantage_m.get_transaction (the by-id route's DB error) and kyt_m.get_kyt_response (the list route's). No transactions_m.* code is reachable from either fiat routethe four crypto codes — kyt_m.crypto_screening_failed, kyt_m.crypto_provider_unavailable, kyt_m.crypto_provider_not_configured, kyt_m.crypto_feature_not_licensed — plus kyt_m.unsupported_provider, common.failed_to_decode_data, common.invalid_input, common.record_not_found, common.server_error, and — through the per-request licence factory, not the middleware — license_m.license_key_missing and license_m.license_validation_failed — not license_key_invalid, which license.NewService rebuilds as key_missing, so a present but malformed key is reported as absent. See the error table
Primary callerThe compliance UIThe DSL interfaces, not HTTP. These routes are the direct-access surface.

Two things to know before writing a client

Several caller-fault refusals answer 500. Errors built with errs.New(...) and no WithCode reach apireply.HandleAppErrorWithCode with an empty Code, which that function short-circuits to InternalServerError500 in a guard before its status switch runs — not in the switch's default arm, which is a separate line answering the same way for an unrecognised code. So an unparseable screening_id, or a screening that does not exist, both arrive as 500. A missing wallet_address does not — the struct validator refuses that with a 400. Note the order inside the handler, though: the kyt.xziel.tenant_id guard runs first, so on an instance where that key is absent from app_config a body missing wallet_address answers 500 kyt_m.crypto_provider_not_configured and never reaches the validator. Branch on code, not on the status. common.invalid_input on a 500 is a 400 in disguise; common.record_not_found on a 500 is a 404. Retrying either unchanged will not help.

No route in this module scopes anything by the caller. No handler passes a user id into a query, and no RbacRecordType is set anywhere. Any caller holding the endpoint grant reads every customer's KYT results.

Fiat routes

List the review queue

GET /v1/kyt

Despite the path, this is not a list of every KYT response. The query joins transactions.transactions and filters on status = 'suspended' and kyt.transactions.metadata IS NOT NULL, so what comes back is the review queue: a transaction that was screened and allowed never appears here. Ordered by created_at descending.

Two things the queue does not promise. First, one entry per side, not per transaction — kyt.transactions holds a row for each half of a transfer (_sender and _recipient, written separately by the connector), both carrying the same transaction_id_relation, and the GROUP BY includes customer_id and created_at, which differ between the sides. One suspended transfer therefore yields two entries with the identical transaction_id, and limit=10 can mean five transfers. Second, one poisoned row breaks the route for everybody: when the provider reports that every rule passed, getNegativeResults returns nil and validateResult stores the JSON scalar null, which survives metadata IS NOT NULL because a jsonb null is not an SQL NULL — LATERAL jsonb_array_elements then errors on it, so the whole list answers 500 kyt_m.get_kyt_response until that row is repaired. One clean screening takes the queue down for every caller; the failure is not proportional to the bad data.

The body is a bare array, not the shared list envelope — no total, no keys, no total_unfiltered.

Only limit and offset are read, and everything else changes nothing — but not all by the same route. search. never reaches the parser's switch at all: ParseQueryParameters intercepts the search. prefix first and collects those keys into the search map, which this module then ignores. filter is recognised by the switch, is parsed as JSON, and is the one other parameter that can fail — a malformed value is 400 common.invalid_input. Only the genuinely unrecognised keys hit default and vanish silently.

  • limit — default 10, over 100 clamps to 100, zero or negative becomes 10. Except -1, which the parser passes through as its skip-data-fetching sentinel; this module then rejects it as negative and answers 500 complyadvantage_m.invalid_limit. A non-integer is a genuine 400.
  • offset — default 0. A negative value is clamped to 0 by the parser, so the module's own invalid-offset branch is unreachable.
[
  {
    "transaction_id": "987fcdeb-51a2-43f1-9b2c-8d7e6f5a4b3c",
    "customer_name": "Acme Trading AG",
    "codes": [
      { "code": "PEP_MATCH", "type": "Soft Stop" }
    ],
    "created_at": "2026-03-20T10:00:00Z"
  }
]

transaction_id is kyt.transactions.transaction_id_relation — the id in transactions.transactions, not the suffixed key the KYT table is actually keyed by. customer_name is sub-selected from customers.customers and cannot come back empty: fk_kyt_transactions_customer is ON DELETE RESTRICT, so a referenced customer cannot be deleted, and customers.name is TEXT NOT NULL. The sub-select applies no active filter either, so a logically-deleted customer still returns its name. codes[].type is the action label despite the name.

Read both sides of one transaction

GET /v1/kyt/{transaction_id}

kyt.transactions stores a screening per side, keyed "_sender" and "_recipient". The path segment is the base id; the suffixes are appended by the repository and must not be sent.

An unknown id answers 200 null, not 404. When neither side exists the repository returns (nil, nil) — no error — and the handler serialises that nil straight out, so the body is the four bytes null. A client that dereferences the response without a nil check breaks here, and one that treats any 200 as "found" reports a screening that does not exist.

Either side can be null on its own, too: a one-sided screening is normal. The two sides are read by two independent First queries with no surrounding transaction, so the pair is not a consistent snapshot — the halves can come from different instants, and a client comparing sender against recipient status can act on a combination that never existed at one moment.

{
  "sender_transaction": {
    "datetime": "2026-03-20T10:00:00Z",
    "label": "Soft Stop",
    "metadata": [
      { "rule": { "code": "CTF" }, "action": { "label": "Soft Stop" } }
    ],
    "status": "Open",
    "customer_id": "3f1c…",
    "created_at": "2026-03-20T10:00:00Z",
    "transaction_id": "987fcdeb-51a2-43f1-9b2c-8d7e6f5a4b3c"
  },
  "recipient_transaction": null
}

The struct's primary-key field carries json:"-", so the suffixed key is never serialised — the transaction_id you see is the relation id.

datetime is not a provider event time. The column has one writer — storage.CreateTransaction seeds it with a bare time.Now() — and the same Create carries clause.OnConflict{UpdateAll: true}, so re-screening the same transaction_id overwrites it with a later clock. It is the moment this service inserted or refreshed the row, alongside created_at, and never the moment ComplyAdvantage assessed the transaction.

Crypto routes

Screen a crypto transaction

POST /v1/kyt/crypto/screen

Sends the transaction to the configured provider and stores the verdict. Answers 201 even when the provider is still working — status PROCESSING or PENDING, with no verdict fields at all. 201 means screening accepted, not screening decided.

The verdict fields (action, kyt_flag, risk_score_level, risk_score_label) are included only for a final status: SUCCEEDED, FAILED, COMPLETED or CLOSED.

The struct tags ARE enforced, and the handler's own three-field check is dead code. ScreenCryptoTransaction decodes through apireply.DecodeJSONInput, which runs commonutil.ValidateStruct immediately after decoding — so required, oneof and the rest all fire, and a violation is 400 common.invalid_input from validationAppError, carrying a details array naming the offending JSON field. Because transaction_id, customer_id and wallet_address all carry required, the handler's hand-check of those same three fields can never be reached. currency, network and direction's oneof are enforced along with the rest, so direction: "sideways" is a 400, not a passthrough.

But amount is the one exception: its required tag never runs. Amount is a decimal.Decimal, a plain struct, and validator/v10 skips required on a nested non-pointer struct unless the instance is built with WithRequiredStructEnabled(). Neither commonutil nor apireply builds it that way — both call a bare validator.New() — so six of the seven tagged fields are enforced and amount is not. A body that omits amount altogether decodes to the zero decimal.Decimal, passes validation and answers 201, and a zero amount is sent to the provider and persisted. Send amount explicitly; nothing on the server will catch you if you do not.

metadata is accepted and dropped. The field decodes into models.CryptoScreenRequest.Metadata and stops there: buildTransactionRequest never reads it, and neither persistScreening nor the mock's persist writes it to CryptoScreeningRecord.Metadata, though that column exists. A correlation id put here reaches neither the provider nor the stored row. Use external_ref_id, which is persisted.

The provider service is memoised; the licence key is not. GetCryptoKYTService caches the built service per driver name under a mutex, and loadXZielConfig runs only inside NewXZielService. So after xziel has been built successfully, edits to kyt.xziel.base_url, api_key or timeout have no effect until the process restarts — while the licence key on the same route is re-read on every request. tenant_id is the exception and the awkward one: the handler re-reads it per request and stores it on the row, but the X-Tenant-Id header keeps the memoised copy, so after an edit the tenant recorded against a screening and the tenant the provider was asked about disagree. A failed build is not cached, so fixing a broken setting works at once; changing a working one does not.

A 201 does not guarantee the row exists. Both drivers persist best-effort: persistScreening and the mock's persist log repo.Create's error and return, and the handler answers 201 with the verdict regardless. Three classes of loss are reachable. (a) A customer_id or currency the foreign keys fk_kyt_crypto_customer and fk_kyt_crypto_currency do not resolve. (b) A duplicate idempotency_key, per the paragraph below. (c) Column length, entirely caller-controlled — the request struct carries only required and oneof tags, no max, while the table bounds every string: blockchain_network VARCHAR(50), currency VARCHAR(10), and transaction_id/wallet_address/tx_hash/external_ref_id/idempotency_key at VARCHAR(255). A 51-character network passes validation, reaches the provider, is answered 201 with a verdict, and then fails the insert with 22001. In every case the caller holds a screening_id whose GET /v1/kyt/crypto/screenings/{screening_id} answers 500 common.record_not_found. Treat the 201 body as the authoritative verdict and the stored row as a convenience.

The connector retries before you see anything. postJSON makes up to kyt.xziel.max_retries + 1 attempts — four by default — with jittered backoff. Retry-After is not clamped: a delay longer than retryWaitMax aborts the loop and returns the last provider error at once, so a peer asking for thirty seconds produces a fast 502 after one attempt. And retryAdvice never retries 401, 403, a 429 without a Retry-After, or the provider_credentials_invalid body code — those 502s follow exactly one call.

Idempotency is the provider's, and the default driver has none. Everything in this section about replayed keys and the 409 describes XZiel. CryptoMockService.ScreenTransaction reads req.IdempotencyKey only to persist it and assigns a fresh uuid.New() on every call, and kytCryptoRepository.GetByIdempotencyKey is called from nowhere — the handler's own comment explains that wiring it in would need scoping first, since its WHERE clause is the key alone. So on a seeded install two byte-identical requests carrying the same idempotency_key both answer 201, with different screening_ids — the mock mints a fresh uuid each time. They do not leave two rows: a unique partial index on idempotency_key means the second insert violates it and persist swallows the error. One row survives, holding the first screening's id, so the second caller is handed an id whose read answers 500 common.record_not_found.

Send tx_timestamp if you may retry. It is optional, and when absent the handler fills it with the current clock — which changes the payload between two otherwise identical requests. The idempotency key is derived from the request as the client sent it, before that default, so the key is stable while the payload is not. The provider hashes the payload against the key and answers a mismatch with a conflict, so an identical retry that omitted tx_timestamp earns a 409. Supplying it makes the request byte-stable and the retry safe.

The idempotency key resolves in this order: the idempotency_key field, then the X-Idempotency-Key header, then that derived hash. The tenant id comes from the kyt.xziel.tenant_id setting — not from the caller and not from the token.

List crypto screenings

GET /v1/kyt/crypto/screenings

The customer_id and provider filters do not work. The handler looks for those keys in the map the shared parser returns, but the parser's switch recognises only limit, offset, sort, stack, distinct, filter, search, search_text and fill_gaps — plus any search.-prefixed key, which is intercepted before the switch — and everything else hits default and is dropped. Both keys are therefore always absent, both branches are dead, and every call falls through to the list-all path. Asking for one customer's screenings returns every customer's, with a 200 and no warning.

Only limit, offset and sort survive into the query, and sort is not validated against an allowlist of sortable fields, unlike the other list routes in this service. Omit it unless you need a specific order: when it is absent the repository orders by created_at DESC. Note that unlike GET /v1/kyt, this route does not re-check the value, so limit=-1 reaches the repository as -1, where list hands it to query.Limit(-1) — which GORM treats as no limit at all, so the call returns every row in kyt.crypto_screenings, on a route this page already notes has no per-caller scoping.

data is always an array — [] when empty, never null. total is the count before paging.

Read one crypto screening

GET /v1/kyt/crypto/screenings/{screening_id}

Reads the stored row. One thing differs from the 201 body built from the same schema, and one thing that looks like it should differ does not:

  • alerts is never populated here. The mapper does not copy them, even for a screening that has alerts. Read them from the 201 response of the screen call, or from the provider.
  • screened_at is always a real timestamp on rows this code writes, and the two routes would render a null differently. Both writers persist it from the driver result — mapTransactionResponse seeds it with time.Now().UTC() before any provider override — and they do so for PROCESSING and PENDING rows alike; neither UpdateStatus nor UpdateFromPolling clears the column. So there is no "not yet screened" signal on either route: use status. The column is nullable in the migration all the same, and the two routes disagree about how a null would surface: mapRecordToItem on the list route passes the *time.Time straight through, while this route runs it through timeValue, which turns a nil into "0001-01-01T00:00:00Z".

Neither failure on this route is the status it should be. An absent or unparseable screening_id and a screening that does not exist both answer 500; only code tells them apart (common.invalid_input versus common.record_not_found). Worse, the handler answers common.record_not_found for any repository error, so a genuine database failure is reported as a missing record.

Poller statistics

GET /v1/kyt/crypto/poller/stats

Health and progress of the background poller. It finishes nothing under the default driver: processScreening asks GetScreening about the original transaction id, and CryptoMockService re-derives the status from that same id prefix, so a row screened as processing… or pending… returns non-final forever, UpdateFromPolling never runs, and pending_count only grows. The route's stated purpose holds under xziel alone. Note too that pending_count is not a guaranteed count: Stats leaves it at zero when CountPending errors, so {"enabled": true, "pending_count": 0} during a database outage looks exactly like a drained queue. Takes no parameters and cannot fail on its own: when no stats have been registered it answers 200 with {"enabled": false, "pending_count": 0}, and it does so when GetPollerStats returns nil, which happens when no poller was constructed — not when a constructed one has yet to register stats, since Stats copies p.cfg.Enabled and returns a count. So "enabled": false means one of two things: switched off, or the poller failed to construct — initCryptoPoller returns leaving it nil when GetCryptoKYTService or NewCryptoKYTPoller errors, which is what crypto_driver = xziel with crp unlicensed, or any driver name outside xziel/mock, produces — and more besides: a licence service that will not construct, or any of kyt.xziel.base_url, api_key or tenant_id resolving empty under the xziel driver, land here too. Those are the same conditions the screen route's 500 documents, so enabled: false and a 500 from POST /v1/kyt/crypto/screen usually share one cause. So a deployment whose configuration says enabled can report false here. There is no "not yet started" case: initCryptoPoller runs synchronously inside Module.Handler, which the loader calls before it collects the module's routes, and Enabled is copied from the config rather than from whether Run has begun. The inverse holds too: a live poller copies p.cfg.Enabled, which can only be true, so false never comes from a running one.

Status, label and flag values

kyt.transactions.status and .label are the fiat rail's. declares the status values and three of the four labels; the fourth, Error, is errorLabel in and is written by the ComplyAdvantage connector rather than by this module. Both columns are unconstrained strings — these are the values the platform writes, not a constraint the database enforces — and nothing in modules/kyt writes this column at all. storage.CreateTransaction inserts Open; the only caller of UpdateKYTTransactionStatus is the transactions module, and it writes Not Suspicious. Suspicious is declared twice, in and , and assigned nowhere in the repository — do not branch on it.

FieldValues
statusNot Suspicious, Open (the column default) — and Suspicious, declared twice and assigned nowhere in the repository
label (open set)All Good, Soft Stop, Hard Stop, Error — validateErrorsExists writes the errorLabel constant whenever the ComplyAdvantage response carries an Errors array, and that is the writer whose rows carry metadata the list query can expand but not read — its elements are models.CAError, with neither rule nor action, so the codes come back as {"code": "", "type": ""}. It is not the writer that breaks the route; that is validateResult, below. Treat the set as open regardless: validateResult stores MaxPriorityAction.Label verbatim from the provider with no check against the CALabel constants

The crypto rail uses a different vocabulary entirely:

FieldValues
statusPROCESSING, PENDING, SUCCEEDED, FAILED, COMPLETED, CLOSED — the last four are "final" and gate the verdict fields
action (open set, less constrained than kyt_flag)ALLOW, REVIEW, BLOCK, HOLD, ESCALATE — the five KYTAction constants. HOLD really ships from the default driver: deriveAction maps kyt-hold to it, and a hold… transaction id derives a final status, so the 201 carries it. But nothing validates this field at any point: mapTransactionResponse upper-cases whatever the provider sent, and the fail-closed rewrite fires only when ValidateKYTFlag() is false — a method that switches on kyt_flag alone. So a SUCCEEDED row with a valid kyt-green flag can carry any action string and count as fully normalised
kyt_flag (open set)kyt-green, kyt-amber, kyt-red, kyt-hold, kyt-escalate. A CLOSED row skips the normalisation entirely and carries the provider's raw value

On a final status with an unrecognised provider flag the connector fails closed: kyt-amber with action REVIEW. But "final" means two different things here. The connector's isFinalStatus covers SUCCEEDED, FAILED and COMPLETED only, and gates on provider_status; the handler's isFinalScreeningStatus also includes CLOSED and gates on status. So a CLOSED row — or one with status = SUCCEEDED and provider_status = PENDING — has its verdict fields emitted without ever passing through the normalisation, and carries the raw provider kyt_flag and action, outside the values tabulated above.

The audit-field rewrite runs on all six routes and finds nothing

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.

In practice the rewrite finds nothing on these six routes. comply_advantage_meta is declared only on models.Transactions and its transform struct, which the transactions module serves — no KYT response type carries it, or created_by, or modified_by. And the middleware skips entirely for three further reasons beyond the config flag: GetUserID fails, rbac.HasInternalRole errors, or the caller has the internal role — which the compliance operators these routes are written for do. What survives is the cost of the round trip, below.

Consequences of that round trip, which happens whenever the middleware does run: every number passes through float64, so an int64 beyond 2^53 comes back altered, and key order is not preserved.

Errors

The envelope

{"status": 502, "message": "Crypto KYT provider unavailable (xziel): unreachable", "code": "kyt_m.crypto_provider_unavailable", "class": "temporary", "retryable": true}

message is the resolved i18n template for code, with {provider} and {reason} substituted from the error's params — it is not a description of what the caller did wrong. code, class, retryable and details are stamped only outside 2xx, so a success is never this shape. details is populated on POST /v1/kyt/crypto/screen, and only there. The module does not call WithDetails itself — validationAppError does, inside apireply.DecodeJSONInput, with one entry per offending field. The other five routes take no body and never reach it.

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.

Module codes

StatusCodeWhen
400common.invalid_inputlimit/offset not an integer or a malformed filter (shared parser); a transaction_id path segment that is not a uuid; a crypto screen body that did not decode or failed the struct validator, the latter carrying a details array naming the field
409kyt_m.crypto_screening_failedXZiel only. The provider reports an idempotency conflict — matched on the body code xziel_m.screenings_idempotency_conflict, not on an HTTP 409, so a provider 409 with a different body code lands on 502 instead. The one provider refusal a caller can act on, so the connector keeps it as 409 instead of flattening it into 502
500common.invalid_inputAn unparseable screening_id. Not a failed idempotency-hash serialisation — that cause is declared and unreachable, since every member clientRequestKey marshals is a string, uuid.UUID, decimal.Decimal, *time.Time or a json.RawMessage a successful decode just produced.
500common.record_not_foundA misreported miss. No crypto screening with that id — and also any repository error on that read
500complyadvantage_m.invalid_limitReachable only through limit=-1 on GET /v1/kyt. Note the ComplyAdvantage namespace on a KYT route
500complyadvantage_m.invalid_offsetUnreachable — the parser clamps a negative offset to 0
500kyt_m.get_kyt_responseThe review-queue query or its codes unmarshal failed
500auth.invalid_requestUnreachable, and note the namespace — auth., not complyadvantage_m. or kyt_m.. GetKYTResponse raises errs.MsgInvalidRequest when json.Marshal of the parsed query map fails; every value the parser puts in that map is a string, an int or a nested map of those, so it cannot
500complyadvantage_m.get_transaction → Error getting transactionA database error reading either side of a fiat transaction on the by-id route. GetKYTTransactions builds errs.MsgGetTransaction, whose catalogue entry lives in the tenant bundle rather than the service repo's .samples seeds. Built with no WithCode, so HandleAppErrorWithCode answers 500 from its empty-Code guard, before the status switch
500kyt_m.crypto_provider_not_configuredTwo producers. The handler raises it when kyt.xziel.tenant_id resolves empty — which the seeded ${CONFIG_KYT_XZIEL_TENANT_ID} does not, since ExpandEnv keeps an unresolved placeholder; it needs the key absent from app_config or seeded as ${VAR:-}. NewXZielService raises the same code when base_url or api_key or tenant_id is empty, so an unresolved kyt.xziel.base_url produces it with a perfectly good tenant id
500kyt_m.crypto_feature_not_licensedThe tenant's licence does not cover the crp module. Skipped entirely when the driver is mock
500kyt_m.unsupported_providerApp-config kyt.crypto_driver is set to something other than xziel or mock — a configuration mistake reported as a server fault. An unset key never reaches this branch: GetCryptoKYTService trims the value and substitutes mock when it is empty, so a typo produces this and an absent key does not. Mind the constant: this is the connector-local MsgUnsupportedKYTProvider (); the identically-named errs.MsgUnsupportedKYTProvider is a different string, kyt_m.unsupported_driver, and no crypto path raises it
500common.failed_to_decode_dataXZiel only. The provider answered 2xx with a body that is malformed or matches no shape the connector knows — three sites in : the screen decode, the retry-attempt decode, and the response-shape check
500license_m.license_key_missing, license_m.license_validation_failedA second licence path, per request, that the middleware's 403 does not cover. Under any driver but mock, GetCryptoKYTService calls license.EnsureModuleLicensed, which constructs a fresh license.NewService() on every call; when that construction fails it returns fatal and the connector returns the licence error verbatim, at 500. The key is read through config.GetSecret, which dispatches on the configured secret driver — app_config only under the file/default driver, AWS SSM under aws, the local secret manager under lsm — so editing license.key in app_config clears this on a file-driver deployment and changes nothing under aws. Either way a process that started healthy can enter this state without a restart
500common.server_errorThe crypto list query failed — written with no AppError, so it carries the helper's default code
502kyt_m.crypto_provider_unavailableProvider unreachable or a cancelled call, any provider 5xx, and the four 4xx statuses unavailableReason claims: 401, 403, 408 and 429. A provider rate-limit is this code, not crypto_screening_failed. Also whenever the body code is xziel_m.provider_credentials_invalid, at any HTTP status — providerStatusError special-cases two body codes, not one
502kyt_m.crypto_screening_failedA provider 4xx that unavailableReason does not claim — 400, 404, 422 and the like. Including 404

A provider 404 does not keep its own status on any route documented here. providerStatusError has no 404 case and unavailableReason returns false for it, so it falls to the final branch and becomes 502 kyt_m.crypto_screening_failed. The branch that does preserve a provider 404 is XZielService.GetScreening, which two callers reach — the poller () and the DSL interface kyt_crypto_check (). The HTTP read-one route is not one of them; it reads the local repository instead. The two callers treat that 404 very differently: cryptoScreeningCheckHandler hands any error to fallbackCryptoCheckResult, which returns a synthetic PENDING result and a nil error to the DSL, with the reason tucked into an error key the step is free to ignore. A payment step that reports pending forever is as likely to be this as a genuinely unfinished screening.

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, and that flag is read once at bootstrap, so the middleware is either in the chain or absent for the life of the process. Turning the flag on through Configurator changes nothing until 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 called with no AppError. 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 that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded.

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 (only 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
401permission denied — free text, not an i18n key, and it lands in code and messageOnly with the rate limiter on. auth.RateLimitMiddleware runs before auth.Middleware and resolves the endpoint itself: findMatchingEndpoint scans the user_limits::* cache, which FetchAndCacheUserLimits builds from the caller's own rbac.api_permissions rows, and returns errs.New("permission denied").WithCode(401) when nothing matches. handleRateLimitError answers it through Unauthorized401, and since errs.New keeps the string it is handed as the Key, writeAppError copies it into code — so the body is "code": "permission denied", a code that is in no MsgCode list. So a caller lacking the grant for a kyt route is refused here, at 401, and never reaches the 403 in the row below — a client that reads 401 as "token expired" will refresh-loop instead of reporting a permissions problem
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. Reached only with the rate limiter off — with it on, the missing grant is already a 401, per the row above
403license_m.license_invalid, license_m.license_expiredThe licence branch. Only these two are reachable on kyt. The middleware matches a third, module_not_licensed, not reachable here; on an ungated module it means the tenant's licence does not cover it — routes present in the binary, refused per request. kyt is one of the six the loader gates (crif, crp, kyc, kyt, noga, zefix), so its routes are never registered and an unlicensed tenant gets chi's plain 404 instead. Two further codes the middleware matches cannot reach a client through it: 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, which rbac.Init prevents by routing an InitLicenseService failure through logger.Fatalf. But see the 500 row: license_key_missing and license_validation_failed DO reach a client on POST /v1/kyt/crypto/screen, by a different path the middleware never touches.
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 — though not everything when the rate limiter is on: auth.RateLimitMiddleware is then registered two lines earlier, so a caller over its limit gets a 429 even while the server drains. With the flag off the limiter is absent from the chain, health.LifecycleMiddleware is the first of the three, and a draining server answers 503 to every caller
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.

Use cases

  • Compliance monitoring — the fiat review queue is GET /v1/kyt; it is already filtered to suspended transactions, so it needs no status filter of its own (and would ignore one).
  • Investigating one transfer — GET /v1/kyt/{transaction_id} gives both sides at once. Check for null before dereferencing.
  • Crypto pre-flight — normally reached through the DSL kyt_service interface rather than these routes; use POST /v1/kyt/crypto/screen directly only when there is no product step to hang it on.
  • Operational health — GET /v1/kyt/crypto/poller/stats for whether in-flight screenings are being finished.

POSTScreen an entity

Runs a screening and stores the result. Answers 201 Created. Database-first by default: an existing, unexpired screening is returned instead of buying a new one from the provider. ?force=true bypasses that cache and always screens. WHAT THE CACHE MATCHES, AND WHAT IT DOES NOT. With entity_id set, FindRecentScreeningByEntityID matches entity_id + collection + status completed + screening_date within 24h. With entity_id absent it falls back to entity_name + collection + status + the same window. NEITHER query is scoped to the caller, to requested_by or to a tenant, so a screening another caller paid for is a legitimate hit — and a name-only request for a common name can return a screening of a different subject who happens to share it. A cached answer carries the same status as a fresh one — both are 201, and RequestID is the id of whichever screening row was returned — but it is not undetectable: screened_at is the stored screening_date of the original row, so a value up to 24 hours old means the provider was not called. Two further consequences of the cache key being screening_types[0]: every type IS screened on a fresh call (ScreenEntity loops the array, one provider call per type), but the row is filed under the FIRST type only, so a later request naming MORE types hits the same row and gets the narrower stored result. screening_results carries one entry per type that actually ran, so compare what came back against what you asked for. ?force=true skips the cache read entirely (createScreeningWithoutCache) rather than invalidating it. Only entity_type customers and organizations can succeed — see UniversalKYCRequest. Do not rely on record scoping to keep screenings apart. The list route asks for CUSTOMER-record scoping and misapplies it (see that operation); GET /v1/kyc/screening/{id} and the stats route apply none at all. Endpoint-level RBAC is the only real boundary on these six routes. TWO STATUSES ON THIS ROUTE COME FROM THE PROVIDER, NOT FROM THE PLATFORM, AND EVERY OTHER PROVIDER FAILURE IS SWALLOWED. OpenSanctionsService.ScreenEntity loops the requested types and, on a failure from any one of them, returns early ONLY for upstream 401 (401 kyc_m.provider_auth_error) and upstream 403 (403 kyc_m.provider_unauthorized). Every other outcome — an upstream 429, any 5xx, a transport error, an unparseable body — is logged and `continue`d to the next type. THE CONSEQUENCE IS THE DANGEROUS PART. A single-type screening whose one provider call fails that way answers 201 with screening_results: [], overall_risk_level low, overall_score 0 and has_matches false — a clean verdict for a screening that never ran, indistinguishable in the body from a subject the provider cleared. A multi-type screening silently drops the failed types and returns the rest. AND THE FABRICATED VERDICT IS THEN CACHED. The row is written Status: completed, and FindRecentScreeningByEntityID matches status = completed AND screening_date > now() - 24h, so the empty result is re-served as a 201 to every later request for the same subject and collection for a full day. HOW TO DETECT IT, ON EITHER PATH. A successful screen appends exactly one ScreeningResult per requested type, whether or not that type matched anything, so an empty screening_results is NEVER a legitimate "the provider found nothing" answer — that would be one result per type with empty matches. A short list means the missing types failed. convertToUniversalResponse copies the stored array through unchanged, so the cache hit renders the same short list and the check works there too. ?force=true is what displaces the bad row once you have detected it; on its own it distinguishes nothing, because a still-failing provider produces the same clean empty verdict. A misconfigured provider API key still answers 401 to a correctly authenticated caller, so branch on code, never on status alone. (The XZiel driver does none of this — it re-serves provider faults as 502 and never files a partial screening.)

GETCrypto KYT poller statistics

Health and progress of the background poller. IT FINISHES NOTHING UNDER THE DEFAULT DRIVER: processScreening calls GetScreening with the record's provider_ref_id, which the mock set to the original transaction id, and CryptoMockService re-derives the status from that same id prefix — so a row screened as processing or pending returns non-final forever, UpdateFromPolling never runs, and pending_count only grows. The route's purpose holds under xziel alone. Takes no parameters and cannot fail on its own. It answers 200 with {"enabled": false, "pending_count": 0} when GetPollerStats returns nil, which happens only when no poller was constructed — see the enabled property. A LIVE poller is a separate matter: its pending_count is not a guaranteed count, because Stats keeps the field at zero when CountPending errors, so {"enabled": true, "pending_count": 0} during a database outage looks exactly like a drained queue.

On this page