CorebanqCorebanq Developer Docs
KYTv1

List suspended transactions carrying KYT metadata

NOT a list of every KYT response, despite the path. The query filters on transactions.transactions.status = 'suspended' AND kyt.transactions.metadata IS NOT NULL, so this is the review queue: a transaction that was screened and allowed never appears. Ordered by created_at descending. Returns a BARE ARRAY, not the shared list envelope — there is no total, no keys and no total_unfiltered. Only limit and offset are read. Of the rest, search.<field> keys are intercepted before the parser's switch and collected into a search map this module ignores; filter IS recognised and is parsed as JSON, so a malformed value is a real 400; and only genuinely unrecognised keys hit the default branch and vanish. ONE ENTRY PER SIDE, NOT PER TRANSACTION. kyt.transactions holds a row for each half of a transfer — the connector writes <id>_sender and <id>_recipient separately — and both carry the same transaction_id_relation. The query groups by transaction_id_relation, customer_id and created_at, and the two sides differ in the last two, so one suspended transfer yields TWO array entries with the identical transaction_id. A client de-duplicating on transaction_id sees half its page vanish, and limit=10 can mean five transfers.

GET
/v1/kyt

Authorization

bearerAuth
AuthorizationBearer <token>

Every route in this module is registered through auth.WrapWithMiddlewares, so all six require a bearer token; none is public. The token is the one the authentication module issues. A missing, unparseable or blacklisted token is the 401 described by Unauthorized401 — and with the rate limiter on, so is a token whose holder lacks the endpoint grant.

In: header

Query Parameters

limit?integer

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 and this module then rejects: see the 500 response. A non-integer is a genuine 400.

offset?integer

Default 0. A negative value is clamped to 0 by the parser, so the module's own invalid-offset branch is unreachable. A non-integer is a genuine 400. No minimum is declared: parseLimitOffset accepts a negative offset and clamps it to 0, so a schema keyword forbidding it would make a generated client reject a request the server serves.

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X GET "https://example.com/v1/kyt"
[
  {
    "transaction_id": "0fec1e58-b197-4052-99cf-2218496c5482",
    "customer_name": "string",
    "codes": [
      {
        "code": "string",
        "type": "string"
      }
    ],
    "created_at": "2019-08-24T14:15:22Z"
  }
]
{
  "status": 400,
  "message": "Invalid input",
  "code": "common.invalid_input",
  "class": "validation"
}
{
  "status": 401,
  "message": "Unauthorized",
  "code": "common.unauthorized",
  "class": "business"
}
{
  "status": 403,
  "message": "No access to the record",
  "code": "common.rbac_no_rec_access",
  "class": "business"
}
{
  "status": 429,
  "message": "Rate limit for 203.0.113.7 to GET:/v1/kyt exceeded.",
  "code": "rate_limits_m.exceeded",
  "class": "temporary",
  "retryable": true
}
{
  "status": 500,
  "message": "Invalid limit",
  "code": "complyadvantage_m.invalid_limit",
  "class": "business"
}

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

GETList 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, and search.<field> keys never reach it at all — ParseQueryParameters tests the search. prefix and continues first. 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. They are documented below as they are declared in the handler, marked inert, because removing them from the spec would hide the divergence rather than record it. Only limit, offset and sort survive into the query.

POSTScreen a crypto transaction

Sends the transaction to the configured crypto KYT provider and stores the verdict. Answers 201 Created even when the provider is still working — status PROCESSING or PENDING with no verdict fields — so 201 means "screening accepted", not "screening decided". IDEMPOTENCY. The key is taken from idempotency_key, then the X-Idempotency-Key header, then a hash derived from the request as the client sent it. That derivation happens BEFORE the handler's own tx_timestamp default, so the key is stable across an identical retry — but the PAYLOAD is not, because the default stamps the current clock. 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. Send tx_timestamp and the retry is safe. The tenant id comes from the kyt.xziel.tenant_id setting, not from the caller or the token. THE PROVIDER SERVICE IS MEMOISED, THE LICENCE CHECK IS NOT. GetCryptoKYTService caches the built service per driver name under a mutex and returns it unchanged thereafter, and loadXZielConfig runs only inside NewXZielService. So once xziel has been constructed successfully, later edits to kyt.xziel.base_url, api_key and timeout have NO EFFECT for the life of the process. tenant_id is the exception and is worse for it: the handler re-reads it on every request and stores it on the row, while the X-Tenant-Id header keeps using the memoised copy — so after an edit the tenant recorded against a screening and the tenant the provider was asked about disagree, and emptying the key breaks the route at once with 500 kyt_m.crypto_provider_not_configured. The contrast is with base_url, api_key and timeout, which the memo pins; the licence check also runs per request, before the memo is consulted at all. A FAILED build is not cached, so fixing a bad setting does take effect without a restart; changing a good one does not. IDEMPOTENCY IS THE PROVIDER'S, AND THE DEFAULT DRIVER HAS NONE. Everything below about replayed keys and the 409 describes XZiel. CryptoMockService.ScreenTransaction reads req.IdempotencyKey only to persist it and assigns a fresh uuid.New() every call, and kytCryptoRepository.GetByIdempotencyKey is called from nowhere — the handler's own comment says 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 that error. One row exists, holding the FIRST screening's id, and the second caller holds an id whose read answers 500 common.record_not_found. THE 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 reachable loss, one of them entirely caller-controlled. (a) A customer_id or a currency the foreign keys fk_kyt_crypto_customer and fk_kyt_crypto_currency do not resolve. (b) The duplicate idempotency_key above. (c) COLUMN LENGTH: the request struct carries only required and oneof tags, no max, while the table bounds every string — network VARCHAR(50), transaction_id, wallet_address, tx_hash, external_ref_id and idempotency_key VARCHAR(255), currency VARCHAR(10). So a 51-character network passes validation, reaches the provider, is answered 201 with a verdict, and then fails the insert with 22001. Under xziel the same applies to values the PROVIDER supplies: status and provider_status and risk_score_label VARCHAR(50), kyt_flag VARCHAR(30), action VARCHAR(20). In each case a caller holds a screening_id from a 201 whose GET /v1/kyt/crypto/screenings/{screening_id} answers 500 common.record_not_found. (An amount with more decimal places than the column's scale is NOT one of them — NUMERIC(30,10) rounds to scale and overflows only above 10^20.) Treat the 201 body as the authoritative verdict and the stored row as a convenience. AND A REPLAY LEAVES THE TWO ROUTES DISAGREEING. mapTransactionResponse takes the screening id from the provider's response, and persistScreening uses it as the primary key, so a replayed key produces a duplicate-key insert that is swallowed. The stored row stays as first written while this route returns the provider's current verdict: replay a key whose screening moved from PENDING to COMPLETED and you get COMPLETED here and PENDING from the read-one route until the poller catches up. 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 to five seconds: a longer delay than retryWaitMax aborts the loop with the last provider error, so a peer asking for thirty seconds produces a FAST 502 after one attempt. Nor does every 502 follow four calls — retryAdvice never retries 401 or 403, never retries a 429 without a Retry-After, and never retries xziel_m.provider_credentials_invalid at any status. Those follow exactly one. A retryable failure does take up to four, and can far exceed kyt.xziel.timeout. That setting is never named elsewhere in this document.