Screen 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.
Authorization
bearerAuth 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
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Body of POST /v1/kyt/crypto/screen. THE validate TAGS ARE ENFORCED: the handler calls apireply.DecodeJSONInput, which runs commonutil.ValidateStruct after decoding, so required, oneof and the rest all fire. A violation is 400 common.invalid_input from validationAppError, carrying a details array that names the offending JSON field — not a 500 and not a passthrough. One consequence: the handler's own three-field check on transaction_id, customer_id and wallet_address is unreachable, because required on those same fields refuses first. SEVEN FIELDS CARRY validate:"required" — transaction_id, customer_id, currency, amount, direction, wallet_address and network — BUT ONLY SIX ARE ENFORCED. Amount is a decimal.Decimal, a plain struct, and validator/v10 skips required on a nested non-pointer struct unless the validator was built with WithRequiredStructEnabled(). Both commonutil and apireply call a bare validator.New(), so the tag on amount never runs: a body omitting amount decodes to the zero decimal, passes validation, answers 201 and screens a zero amount at the provider. amount is kept in this schema's required array because clients MUST send it — but the server will not refuse them if they do not, so do not read this required as a server-side guarantee. The earlier required list named only the handler's three hand-checked fields, which would have let a generated client omit currency and meet a 400 the spec said was impossible.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/v1/kyt/crypto/screen" \ -H "Content-Type: application/json" \ -d '{ "transaction_id": "string", "customer_id": "160c0c4b-9966-4dc1-a916-8407eb10d74e", "currency": "string", "amount": "string", "direction": "INBOUND", "wallet_address": "string", "network": "string" }'{
"screening_id": "c21341e0-a82a-41e6-9578-cd022db5aa1a",
"provider": "string",
"provider_ref_id": "string",
"status": "string",
"action": "string",
"kyt_flag": "string",
"risk_score_level": 0,
"risk_score_label": "string",
"alert_count": 0,
"alerts": [
{
"alert_id": "string",
"rule_id": "string",
"status": "string",
"risk_score_level": 0,
"risk_score_label": "string",
"category": "string",
"message": "string",
"issued_at": "2019-08-24T14:15:22Z"
}
],
"screened_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": 409,
"message": "Crypto screening failed: idempotency_conflict",
"code": "kyt_m.crypto_screening_failed",
"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 input",
"code": "common.invalid_input",
"class": "business"
}{
"status": 502,
"message": "Crypto KYT provider unavailable (xziel): unreachable",
"code": "kyt_m.crypto_provider_unavailable",
"class": "temporary",
"retryable": true
}{
"overall_status": "unhealthy",
"message": "Service is shutting down",
"timestamp": "2026-08-27T15:04:05Z"
}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.
Description
Next Page