Crypto KYT API documentation

One call per funding event. Base URL: https://kytgate.com (all paths below are relative to it). All requests and responses are JSON. The API surface below (v1) is stable; anything else is subject to change.

Machine-readable spec: /api/openapi.json (OpenAPI 3.1). You can try everything without talking to anyone: create a sandbox tenant (isolated, 50 screenings a month, data deleted after 7 days).

On this page
QUICK START · THREE CALLS TO A FIRST DECISION
  1. Get a sandbox key

    One e-mail link on /sandbox gives you an isolated tenant (baseline policy, 50 screenings a month, data deleted after 7 days). Send the key on every request. The tenant is derived from it.

    Authorization: Bearer kyt_sbx_XXXXXXXXXXXXXXXXXXXXXXXX
  2. Make your first screening call

    Screen an address (or a claimed deposit transaction with kind: "transfer" and txHash). The idempotencyKey is required. Sending the same key again returns the original result.

    curl -X POST https://kytgate.com/api/v1/screenings \
      -H "Authorization: Bearer kyt_sbx_..." -H "content-type: application/json" \
      -d '{"trigger":"deposit","idempotencyKey":"first-1",
           "subject":{"kind":"address","chain":"ethereum","address":"0x..."}}'
  3. Read the decision

    Three fields matter: assessment (what the data says), coverage (which checks ran) and decision (what your policy concluded). Expect REVIEW for an address with no adverse signal. Without processor evidence, free sources never produce ALLOW.

    {
      "screeningId": "…",
      "assessment": "NO_ADVERSE_SIGNAL",   // not "clean" — nothing found on the sources that ran
      "coverage":   { "sanctions": "COMPLETE", "issuerFreeze": "COMPLETE", "processorEvidence": "MISSING", … },
      "decision":   "REVIEW",              // ALLOW | REVIEW | BLOCK_PENDING_MLRO | TECHNICAL_HOLD
      "receiptId":  "…",                   // signed Decision Receipt
      "caseId":     "…"                    // opened for any non-ALLOW decision
    }
AuthenticationBearer API key on every call; the tenant comes from the key, never from the payload.

Every call carries your tenant API key. The tenant is derived from the key, so a tenant id in the payload is ignored, never trusted.

Authorization: Bearer kyt_XXXXXXXXXXXXXXXXXXXXXXXX
POST /api/v1/screeningsScreen an address or a claimed deposit transaction; returns assessment, coverage, decision and a signed receipt.

Screen an address, or a claimed deposit transaction. For transfer subjects KYTGate verifies the transaction on-chain (exists, succeeded, ≥12 confirmations), resolves the actual sender (for USDT/USDC, the token-level sender from the Transfer log) and runs sanctions, issuer-freeze and cross-checks against that counterparty.

{
  "trigger": "deposit",            // whitelist_application | deposit | pre_withdrawal | rescreen | api
  "idempotencyKey": "dep-10293",   // required; same key returns the original result
  "subject": {
    "kind": "transfer",            // or "address"
    "chain": "ethereum",           // ethereum | base | arbitrum | polygon | avalanche | optimism | bsc
                                   //   | tron | bitcoin | solana (sanctions screening on all;
                                   //   on-chain verification on EVM chains today, tron pending config)
    "txHash": "0x…"                // for kind=address: "address": "0x…"
  },
  "amountUsd": 25000               // optional; available to policy rules
}

Response (200):

{
  "screeningId": "…",
  "assessment": "NO_ADVERSE_SIGNAL",   // ADVERSE_SIGNAL | NO_ADVERSE_SIGNAL | INCONCLUSIVE | PROVIDER_ERROR
  "decision":   "REVIEW",              // ALLOW | REVIEW | BLOCK_PENDING_MLRO | TECHNICAL_HOLD
  "assuranceTier": "PUBLIC_BASELINE",
  "coverage": { "sanctions": "COMPLETE", "issuerFreeze": "COMPLETE", … },
  "policyVersion": "baseline-v0.1",
  "firedRuleIds": ["…"],
  "signals": [ { "provider": "sanctions-cache", "category": "sanctions", "severity": 100, … } ],
  "receiptId": "…",                    // signed Decision Receipt, stored append-only
  "caseId": "…"                        // present when a case was opened (any non-ALLOW decision)
}

Idempotent replays return { "replay": true, … } with the stored decision. Screenings never time out into silent approval: if a mandatory check cannot complete, the decision is TECHNICAL_HOLD.

Decision semanticsassessment = what the data says, coverage = what ran, decision = what your policy concluded.
Not clean
NO_ADVERSE_SIGNAL means nothing adverse was found on the sources that actually ran; it is not a statement that the address is safe. Read it together with coverage.
assessment  = what the data says      (NO_ADVERSE_SIGNAL is NOT "clean")
coverage    = which checks actually ran, per dimension
decision    = what YOUR policy concluded from both

ALLOW               only when nothing adverse AND coverage is sufficient
REVIEW              a human should look (default when evidence is thin)
BLOCK_PENDING_MLRO  hard signal matched — funds hold for MLRO; never auto-returned
TECHNICAL_HOLD      mandatory checks could not complete — fail closed

Two things sit underneath your policy. The engine floor: whatever your rules say, a decision is never ALLOW when a mandatory check failed (it becomes TECHNICAL_HOLD) or when the address is directly designated or frozen by its issuer (BLOCK_PENDING_MLRO); when that happens firedRuleIds ends with a rule id beginning engine-floor:, so the receipt shows it. Too early: an EVM transfer with fewer than 12 confirmations is not decided at all: the call answers 425 with retryAfterSeconds, nothing is stored, and the same request with the same idempotencyKey decides it once the transfer has settled.

Risk summary0–100 from the signals that fired, with published weights and every point attributed. Derived on read; it decides nothing.

Every screening response and every GET /api/v1/screenings/{id} carries a risk object. It is an arithmetic summary of the signals that fired. We hold no proprietary address attribution, so it is not an opinion about the wallet.

"risk": {
  "weightsVersion": "rw-v1",
  "score": 62,                    // 0-100. 100 ONLY for an exact listing or a live issuer freeze
  "band": "ELEVATED",             // NONE | LOW | ELEVATED | HIGH | LISTED
  "scoreBasis": "aggregate",      // "listing" when a hard fact set it to 100
  "confidence": 67,               // % of APPLICABLE coverage dimensions that answered
  "confidenceBasis": { "dimensionsCounted": 6, "credited": 4, "notApplicable": 3 },
  "reasons": [                    // ranked; every point in the score is in here
    { "code": "processor_kyt.processor-acme", "severity": 70, "weight": 0.8,
      "points": 56, "text": "processor returned HIGH_RISK", "observedAt": "..." },
    { "code": "behaviour.wallet-churn", "severity": 20, "weight": 0.6,
      "points": 6,  "text": "[wallet-churn] four wallets in 30 days", "observedAt": "..." }
  ],
  "flags": ["behaviour.wallet-churn", "coverage.incomplete", "processor.adverse"]
}

Score and confidence answer different questions, and both are returned. The score measures how much was found. confidence measures how much of what should have been checked answered. If the two were combined, an address nobody holds any data on could score 12 and look safe. A low score with low confidence means the question is still open.

You can recompute it. The summary is a pure function of the signals and coverage that the Decision Receipt already stores, with weights published per version at /methodology. It is derived on read and never written to a row, so there is no stored number that can drift from the findings it summarises, and a receipt from March rescores to March’s figure. Pass an older weightsVersion and you get the number as it stood under those weights; an unknown version is an error rather than a silent fall back to today’s.

It decides nothing
Decisions come from your policy over the signals and the coverage matrix, as they always have. No policy rule can read this score (the condition type does not exist), so no threshold on it can ever hold a deposit. If you want a number to act on, write a rule on the signal that produced the points.
Plain-words explanationThe same stored record in sentences: what was checked, what was NOT, what was found, which rule fired, what people did. Generated, never stored.

GET /api/v1/screenings/{id} carries a narrative object: a headline and sections, each a heading and its lines. It answers the examiner’s usual question, which is why the firm credited a particular deposit, and it is assembled from the record that was already stored and signed.

"narrative": {
  "headline": "On 2026-09-26 at 05:36 UTC, an incoming deposit involving transaction 0xabc… on
               ethereum was decided ALLOW — the funds could be credited — under policy baseline-v0.4.",
  "sections": [
    { "heading": "What was checked", "lines": [...] },
    { "heading": "What was not checked, and what that leaves open", "lines": [...] },
    { "heading": "What was found", "lines": [...] },
    { "heading": "Why that decision followed", "lines": [...] },
    { "heading": "What people did afterwards", "lines": [...] },
    { "heading": "What this document does and does not establish", "lines": [...] }
  ]
}

No model wrote this. It is a pure function of the stored screening, coverage, signals, policy version, fired rules, list snapshot hashes and case timeline, so the same receipt regenerates the same words a year later.

The second section covers what was not checked. It names every check that did not contribute and what each absence leaves open. MISSING is not STALE, and “no attribution provider was consulted” is not the same as “attribution found nothing”. A summary that listed the checks that answered and left out the others would be a selective quotation of your own evidence, and the person reading it has the matrix too.

The record remains the evidence
The coverage matrix, the signals and the signed receipt remain the evidence. The narrative is generated from them, so it cannot contradict them, and its own last section says so. It never calls an address clean: NO_ADVERSE_SIGNAL is rendered as “nothing adverse was found on the sources that actually ran”, every time.

The same text heads the evidence pack, ahead of the tables, because the person who cannot read a coverage matrix is usually the one deciding whether the relationship continues. In a shared copy it takes the pack’s pseudonymiser, so it reads “proposed by Reviewer A, approved by Reviewer B”: four-eyes stays checkable without naming your staff.

Case outcome: GET /api/v1/screenings/{id} and /api/v1/casesNon-ALLOW decisions open a case; effectiveDecision tells you what to act on now, and a RELEASE on a BLOCK is a labelled MLRO override.

A non-ALLOW decision opens a case that your compliance team works in the console. When they close it they must record what the firm actually did: RELEASE (credited / paid out), REJECT (not credited, returned or refused) or NO_ACTION (informational). A RELEASE on a BLOCK_PENDING_MLRO decision is an MLRO override: allowed, four-eyes enforced, and labelled as such on the receipt, the evidence pack, the webhook and here.

GET /api/v1/screenings/{id}        → decision (as made), case { status, finalDecision, override, timeline }, effectiveDecision
GET /api/v1/cases?status=open      → everything still held for your customers (status=closed | all, limit ≤ 200)

effectiveDecision = what to act on NOW:
  case open            → the engine decision still stands (keep holding / reviewing)
  closed with RELEASE  → ALLOW
  closed with REJECT   → BLOCK
  closed with NO_ACTION→ the engine decision

The same fields arrive on the case.closed webhook (finalDecision, override, fourEyes), so you can either poll or subscribe.

POST /api/v1/processor-signalsFeed your processor's KYT verdict; it enters the policy, lifts assurance only with evidence, and conflicts with public sources are flagged.

Feed your payment processor's KYT result for a deposit. Tenant-private, never shared and never resold. An approval without underlying evidence is recorded as such: processorEvidence: MISSING.

{
  "processor": "your-processor",
  "processorTxId": "abc-123",
  "assessment": "LOW_RISK",        // APPROVED | REJECTED | HIGH_RISK | MEDIUM_RISK | LOW_RISK | UNKNOWN
  "evidenceRef": null,             // attach the processor's evidence reference when available
  "txHash": "0x…", "chain": "ethereum",   // lets KYTGate match the verdict to the screening of that transfer
  "settlement": { "currency": "USD", "amount": "25000", "at": "…" }
}

How the verdict is used. When a screening can be matched to a processor signal, by processorRef: { processor, processorTxId } on the screening request, or automatically by txHash for transfer subjects, the processor's assessment enters the policy (HIGH_RISK / REJECTED block), processorEvidence becomes COMPLETE when an evidence reference is present (assurance tier PROCESSOR_ATTESTED) and stays MISSING when only a verdict arrived. Order does not matter: a verdict that lands after the screening is swept against the last 30 days.

Provider conflict monitoring. If the processor cleared a transfer that public sources match (sanctions or issuer freeze), or flagged one where public sources find nothing, KYTGate records a PROVIDER CONFLICT signal on the screening, annotates or opens the case and emits a provider.conflict webhook. The decision itself is not moved by the label: sanctions already block, a HIGH_RISK verdict already blocks; the conflict is evidence that two sources disagreed and that a human saw it.

Using Sumsub for crypto transaction monitoring? Post the transaction object from Sumsub's GET /resources/kyt/txns/{id}/one (fetched when the applicantKytTxn* webhook arrives), unchanged, to POST /api/v1/processor-signals/sumsub. A completed GREEN review is recorded as APPROVED and a completed RED one as REJECTED, with sumsub:kyt:<id> as the evidence reference; a review still on hold is UNKNOWN, with Sumsub's status kept beside it. The Sumsub score, the rules that matched and each data provider's answer are stored with the signal and shown on the case; names and documents are not carried over. The transaction hash in data.info.paymentTxnId links the verdict to KYTGate's own screening of the same transfer. KYTGate holds no Sumsub credentials and never calls Sumsub. Sumsub is a trademark of its owner; KYTGate is not affiliated with it.

Receipt verification: GET /api/v1/receipt-keyEvery decision is Ed25519-signed; fetch the public key and verify any receipt yourself.

Every decision produces an Ed25519-signed Decision Receipt. Verify independently: fetch the SPKI public key from this endpoint, remove the receipt's signature field, serialize with recursively sorted keys, and verify the signature (strip the ed25519: prefix). A receipt names the key that signed it in signatureKeyId (inside the signed content); the endpoint's keys list carries that key even after it has been rotated out, so a receipt verifies for as long as you keep it. Every evidence pack ships these instructions inline, and the in-browser verifier runs the same check without uploading anything.

A signature proves the content but not the date. The signing key is ours, so a signature alone can never rule out a record signed later and stamped earlier. The anchor below covers the date.

Anchors: GET /api/public/attestationsA daily Merkle root over every receipt, published and never changed, and committed to Bitcoin so that, once confirmed, a receipt cannot be backdated.

Once a day every Decision Receipt written so far is hashed into an RFC 6962 Merkle tree and the root is published at /attestations, where it is never rewritten. GET /api/public/attestations lists the roots; GET /api/public/attestations/{receiptId} returns that receipt’s inclusion proof. Both are unauthenticated on purpose, because the party being audited should not control whether the check can be run, and neither returns anything but hashes, counts and timestamps.

To verify by hand: the leaf is SHA-256(0x00 || "kytgate.receipt.v1\n" || canonical receipt without the signature || "\n" || the signature), inner nodes are SHA-256(0x01 || left || right), and the proof siblings fold innermost-first up to batch.root. The receipt verifier does this in the browser, and npx kytgate-verify receipt.json (open source, no dependencies) does it on your own machine, and with --root it checks against a root you recorded yourself rather than one we hand you.

Every root is also submitted to four independent OpenTimestamps calendar servers, which aggregate it into a Bitcoin transaction. batch.externalAnchor.ots lists the calendars that answered and, once the transaction confirms, the block heights; the proof file itself is at /api/public/attestation-proof?period=<period>&seq=<seq>&n=<calendar> as a standard detached.ots, checkable with ots verify -d <root hex> <file> against your own node. Submitted is not the same as timestamped: until a block height appears (normally an hour or two), the file records a calendar’s promise rather than a time, and kytgate-verify reports externallyPinned: false for that reason.

If an instance also names an RFC 3161 Time-Stamping Authority, batch.externalAnchor.tokens carries its tokens verbatim. Each is a signature over the root’s 32 bytes, verified with openssl ts -verify -digest <root hex> -in token.tsr -CAfile <that TSA's CA>. KYTGate reports all of this and validates none of it: the party being audited should not also be the one declaring its own evidence good. Note that neither a Bitcoin anchor nor a token from a service that is not an eIDAS-qualified trust provider carries a legal presumption of time; each is one more independent party attesting that the number existed. That has value, but it is not a legal presumption.

What it proves: the receipt existed, in the form you hold, by the time that root was published, and has not changed since. What it does not: that the decision was right, or anything at all about receipts that are not in a batch. A root KYTGate publishes itself is a tamper-evident log rather than third-party proof, so record the root you see today somewhere we do not control, and check it again when you need it. Where a batch has been pinned externally, externalAnchor says so; when it has not, the field is null and nothing is claimed.

Funding events: /api/v1/events (Behaviour Engine input)Send deposits, withdrawals and trade summaries so behaviour rules can see the customer, not just the address.

Send your ledger's funding lifecycle so KYTGate can see the shape of a customer, not just an address: { kind, customerRef, eventRef, amountUsd?, chain?, address?, txHash?, occurredAt?, meta? } with kind deposit | withdrawal_request | withdrawal_paid | trade_summary (for trade summaries put meta.tradedVolumeUsd). Idempotent on eventRef; batches via { events: [...] }. Screenings that carry customerRef and amountUsd with trigger deposit / pre_withdrawal record their own event automatically. Behaviour rules then emit explainable signals (each names its thresholds): pass-through (withdrawal soon after deposits with little trading, 65), withdrawal to a wallet never used for deposits (55), structuring (several sub-threshold deposits in 24h, 60), wallet churn (50), large first deposit (45), withdrawal requested while a case on the customer is still open (70), an address previously rejected or revoked for another customer of yours reappearing (65), dormant account reactivated (50), shared registered wallet (70) and network link via device / IP / bank reference (50–60). Windowed rules with per-customer baselines: velocity to new wallets (≥ $10,000 withdrawn within 7 days to wallets never seen before, 65), deposit frequency spike (deposits this week ≥ 3× the customer's 90-day weekly rate, floor 2/week, ≥ 30 days of history, 55), amount spike (a deposit ≥ 5× the customer's 90-day median over at least 5 prior deposits, 60), round-tripping (deposit from address A and withdrawal back to A within 72h for ≥ 80% of the amount with little trading in between, 60) and third-party funding (≥ 4 distinct unregistered sender addresses in 30 days, 55). Under the baseline policy, behaviour ≥ 60 → REVIEW; behaviour never blocks on its own. Coverage customerBehaviour is COMPLETE only when a customerRef was given.

Fiat side: card / bank PSP events

Your card and bank desk enters the same timeline, rules and network graph, so crypto-in / fiat-out arbitrage and third-party funding become visible. Fiat kinds: fiat_deposit | fiat_withdrawal | chargeback | card_declined | payment_method_added | name_mismatch (card_declined = 3DS / AVS / CVV failure; name_mismatch = the PSP's payer name ≠ your KYC name). Typed, all-optional meta keys: method (card | bank | wallet), instrumentRef (your hashed or tokenised card / IBAN reference, never the PAN; the API refuses anything that looks like a card number), nameMatch (a boolean: compare the payer name on your side), reasonCode (≤ 40 chars), threeDs (ok | failed | not_attempted), country (ISO-2), bankRef, ip, deviceId. KYTGate never receives card numbers or payer names, only tokens and booleans; payerName, cardNumber, pan, iban keys are rejected. meta is capped at 2 KB.

A fiat leg has no address to screen, so a newly recorded fiat event is evaluated by the Behaviour Engine at once: the POST response carries signals, and any signal ≥ 60 is appended to the customer's open cases (annotatedCases). The same rules also run inside the customer's next crypto screening, which is where a case is opened. Fiat rules and defaults (per-tenant in Settings): crypto-in-fiat-out (65; crypto deposits ≥ $2,000 in 30 days, then a fiat withdrawal with traded volume below the minimum traded ratio) and its mirror fiat-in-crypto-out (65, on pre_withdrawal); chargeback-history (60; ≥ 1 chargeback in 180 days); card-decline-burst (55; ≥ 3 card_declined in 24h); payment-instrument-churn (55; ≥ 3 distinct instrumentRefs added in 30 days); name-mismatch-funding (60; any fiat_deposit with nameMatch false, or a name_mismatch event, in 90 days); shared-instrument (70; the same instrumentRef used by another customer of yours). GateGraph links customers through instrumentRef exactly as it does through bankRef.

Geography. Send the country you observed for a funding attempt (IP geolocation, card BIN or bank country) as country on a screening or meta.country on an event, ISO-3166 alpha-2 in both places. KYTGate performs no geolocation and ships no country list: the code is taken as given, and a broker who reports none gets no geography signals rather than a guess. Three rules follow: restricted-country-funding (70) when a reported country is on your restricted list (Settings → Geography; empty by default) either on this attempt or on an event in the last 30 days; residency-country-mismatch (55) when funding countries in the last 90 days contradict the residencyCountry on the customer profile, since no profile means no claim; and country-hopping (55) at three or more distinct funding countries for one customer in 30 days.

Customer network: GET /api/v1/customers/{ref}/graph (GateGraph)Your tenant's own relationship graph: which customers share wallets, addresses, devices, IPs or bank references.

Your tenant's own relationship graph, built from what you already send: registered wallets, funding-event addresses and tx hashes, screenings with a customerRef, reconciliation processor ids, plus meta.ip, meta.deviceId, meta.bankRef and meta.instrumentRef (hashed card / IBAN token on fiat events) on funding events when you include them. The response lists the customer's attributes and every other customer of yours touching the same attribute, and the Behaviour Engine adds a network link signal on screenings (device/IP 55, bank reference or payment instrument 60, shared funding address 50; a shared registered wallet is already the 70-severity shared-wallet signal). Nothing crosses tenants; nothing is inferred from the blockchain.

Funds trace: GET /api/v1/trace (GateTrace)Observed transfers 1–3 hops around one transaction from public sources; no entity attribution, caveats included.

?chain=ethereum&txHash=0x…&hops=1..3&direction=backward|forward|both. Observed transfers around one transaction from public RPC and Blockscout only: nodes, edges with explorer links, amount-weighted paths and the reasons each path stopped (label, hop limit, budget, dust). Your registered wallets appear as labels; nothing else is labelled: no exchange, mixer or entity attribution, because public data cannot support it. Every result carries caveats; show them to the reviewer.

Public labels. On chains with a public Blockscout instance (Ethereum, Base, Arbitrum, Polygon, Optimism), nodes that are not your own registered wallets may carry the explorer's public tag, contract name, token name or ENS name. Such nodes have labelKind "public", labelSource "blockscout:<host>" and labelFetchedAt. These labels are the explorer's claims, fetched best-effort after the trace is computed: they are not verified by KYTGate, never stop expansion, and never influence a decision. BSC and Avalanche have no public Blockscout and receive no public labels.

Wallet profile: GET /api/v1/wallets/profile (GateDNA)A deterministic fingerprint of one address from its public history: observed facts, confidence, and direct counterparties screened against the official lists. Never a risk score.

?chain=ethereum|base|arbitrum|polygon|optimism|tron&address=…. A deterministic fingerprint of one address from its public history (Blockscout REST v2 on EVM chains, TronGrid on Tron): age, first/last seen, in/out counts, token-transfer count and stablecoin share, distinct and top counterparties (your registered wallets labelled), contract interactions, dust share, active days, burstiness, newlyCreated and singleUse flags. Two blocks matter: observed (facts) and confidence, whose coverage is FULL / BOUNDED (page cap of 5 hit) / PARTIAL (a source failed) / NONE, plus plain-language caveats. facts are observation sentences only; a profile is never a risk score and never labels an address good or bad.

The response also carries counterparties: the observed top counterparties put through the same official list snapshots a screening uses, each with the transfers seen, the direction, your own label when it is one of your registered wallets, and listed naming every list that carries it. One hop. No attribution, no exposure percentage, no taint: those need hop parameters and a label database KYTGate does not have. coverage and bounded travel with it, because after a capped read an empty listedCount is a much weaker statement than after a full one. A listed counterparty is information for the reviewer. The policy holds a deposit for a listed subject, not a listed neighbour.

On the decision path (optional). Settings → GateDNA switches on a profile for every screening of a supported address. When the wallet was first seen in the last 7 days, or has almost no history, the screening carries one wallet-dna signal at severity 20, under every review threshold, so it cannot move a decision by itself; it is there so the reviewer reads it inside the case. Off by default: a profile costs seconds of explorer calls on the funding path, and a timeout or an unavailable explorer yields no signal rather than a hold.

Settlement reconciliation: POST /api/v1/reconcileCompare ledger credit, processor settlement and the on-chain transfer; a gap over 0.5% or a wrong recipient is a MISMATCH.

Give any of three legs: ledgerEventRef (your credited deposit), processor + processorTxId (a processor signal whose payload carries a settlement {currency, amount} block), and chain + txHash (+ optional expectedRecipient), and KYTGate compares them: ledger credit vs processor settlement vs the canonical USDT/USDC Transfer amount read from the receipt. Any pair further apart than the tolerance (0.5%) or a wrong on-chain recipient is a MISMATCH: it annotates or opens the case on that deposit's screening and emits reconciliation.mismatch. Legs you did not give or that could not be observed make the result INCOMPLETE, never a silent match.

Evidence packs: GET /api/v1/screenings/{id}/evidence.pdfA dated PDF (or JSON from the console) of one case: decision, coverage, signals, timeline and the signed receipt.

For any screening that opened a case, download a dated, human-readable PDF: decision, subject, the coverage matrix (what was and was not checked), every signal, the append-only timeline including four-eyes closure, and the signed receipt with its hashes and verification instructions. The machine-readable JSON pack with the raw receipt is available from the console. Both are generated from stored records only, with nothing recomputed at download time.

STR/SAR draft: GET /api/v1/cases/{id}/sar?format=json|goaml|narrative (GateReport)

When a case has to go to the FIU, the draft is a download. It is a jurisdiction-neutral bundle of the case's stored facts: the reporting entity block from Settings (empty fields print as <to be completed>), the subject as your customerRef only, every address / transaction hash / chain / amount involved (screening subject, funding events, reconciliations, the processor's originator / beneficiary record), the timeline from screening through signals and rule hits to remediation answers, four-eyes and any override, the coverage matrix, list snapshot hashes and the signed receipt id + signature.

json is the structured draft; goaml is a goAML v4-style XML (report_code STR, one <transaction> per on-chain leg with t_from_my_client / t_to_my_client account placeholders holding only the customerRef and address, <reason> empty, <comments> facts); narrative is a markdown pack in the UK-style who / what / when / where / why / how structure with the "why" left blank. The reasons for suspicion are never written by the system: the fired rules and signals are listed next to the empty section as a checklist. KYTGate holds no names, so the MLRO adds identity from the firm's KYC file, and goAML schemas differ per FIU (mandatory nodes, code lists, currency), so the XML is schema-shaped for completion in the FIU's own portal, not a submission. Console exports are audited as case.sar_export.

Regulatory data fields (2027): /api/v1/customers/{ref}/profileThe AMLR customer fields a regulator will ask for from 10 Jul 2027, kept per customerRef: LEI, entity type, residency, occasional status, CDD level. No names, no documents.

Regulation (EU) 2024/1624 applies from 10 July 2027 and brings fields most broker stacks do not hold today. KYTGate keeps them per customerRef and does not judge them: the profile never changes a decision. Store what your regulator will ask for, once, next to the screenings it applies to.

PUT /api/v1/customers/{ref}/profile          // partial upsert; GET returns it (404 = none)
{
  "lei": "5493001KJTIIGC8Y1R12",     // ISO 17442: 20 chars, mod-97 check digits verified
  "entityType": "legal",             // natural | legal | null
  "residencyCountry": "MU",          // ISO-3166 alpha-2
  "occasionalCustomer": false,       // true = no established business relationship
  "cddLevel": "standard",            // simplified | standard | enhanced | null
  "cddReviewedAt": "2026-09-01",
  "notes": "reviewed at onboarding"  // free text, never a name
}

Occasional-transaction flag. AMLR Art. 19 requires CDD on occasional transactions of EUR 1 000 or more. When a screening's amountUsd reaches the tenant's threshold (Settings → CDD, default $1,000) and the customer is profiled as occasional, or has no profile, the screening carries an informational cdd-threshold signal (provider gate-cdd, severity 20). Under the baseline policy it never changes the decision; it is there so the case, the evidence pack and the SAR draft show that the threshold was crossed and by which customer type.

Travel Rule passthrough. POST /api/v1/processor-signals accepts optional originator and beneficiary objects, { vaspName?, vaspLei?, accountRef?, country? }, validated (LEI check digits, ISO-2 country) and stored inside the signal payload exactly as received; they are shown on the case and copied into the SAR draft's transaction legs. We store what the PSP hands you; we do not originate Travel Rule messages, and a person's name sent in these objects is dropped, not stored.

Monthly compliance report: GET /api/v1/reports/monthlyOne calendar month of your tenant, every figure from stored decisions, as PDF for the board or JSON for your own reporting.

?month=YYYY-MM&format=json|pdf. One calendar month (UTC) of your tenant, every figure read from stored decisions: screenings by decision / trigger / network / assurance tier / policy version, what the sources found (sanctions, issuer freezes, integrity failures, behaviour rules, provider conflicts), cases opened and closed and how they ended, every MLRO override with both names and the reason, SLA breaches and median time to close, wallets, reconciliation outcomes, shadow agreement, list snapshot freshness and console activity. The PDF is the pack an MLRO hands to the board or the regulator; the JSON feeds your own reporting.

GET /api/v1/reports/rules?days=90 (30–365) is the rule-performance view behind the console's Tune page: for every policy rule that fired in the window, how many screenings it fired on and what share of all screenings that is, the decision split, the cases those screenings opened and how they closed (RELEASE / REJECT / NO_ACTION), MLRO overrides, median hours to close, cases still open, when it last fired, and a one-sentence verdict that states the numbers it used (e.g. "39 cases, 37 released, 0 rejected → produces work but has not changed an outcome"). Rules in the active policy that never fired are listed separately. Read-only: change a rule on the policy page and backtest it first.

Wallet whitelist: /api/v1/walletsRegister a customer's wallet, have them prove ownership (signature, micro-transfer or declaration), and gate withdrawals with /wallets/check.

POST /api/v1/wallets with { customerRef, chain, address, label? } registers a customer's wallet and screens it (trigger whitelist_application). ALLOW → approved; anything else → pending with a case for your reviewers. The response includes an ownership message: have the customer sign it with the wallet (EIP-191 personal_sign) and send the signature to POST /api/v1/wallets/{id}/prove, after which ownership becomes VERIFIED and the coverage dimension walletOwnership reads COMPLETE on later screenings. A wallet already registered by a different customer of yours raises a behaviour signal (shared wallet → REVIEW under the current baseline). Before paying out, call POST /api/v1/wallets/check with { customerRef, chain, address, amountUsd? }: allowed is true only for an approved wallet that also passes a pre_withdrawal screening.GET /api/v1/wallets?customerRef=… lists; POST …/{id}/revoke revokes (never deletes).

Three ownership proofs with different assurance. Signature (POST …/{id}/prove, EVM only) proves control of the wallet's private key, so ownership VERIFIED, method signature. Micro-transfer ("Satoshi test", POST …/{id}/challenge) proves control of the funds in the wallet at that moment: the customer sends one exact random amount, 0.011–0.099 USDT (Tron TRC-20, Ethereum, Avalanche) or USDC (Base, Arbitrum, Polygon, Optimism), or 1,100–9,900 sats on Bitcoin, from the registered wallet to your receiving address on that chain (console → Settings → Wallet ownership; without one the call returns 409 no_destination_for_chain) within 24 hours. KYTGate looks for it on TronGrid / Blockscout / mempool.space on POST …/{id}/challenge/verify, from the customer page's "check now" button, and every 10 minutes automatically; a match makes ownership VERIFIED with method micro_transfer, stores the tx hash, and fires wallet.ownership_verified. Amounts are unique among your open challenges, so one customer's transfer can never prove another's wallet; a transfer from an exchange account does not match (the sender is the exchange, not the wallet). Declared (POST …/{id}/declare { declaredBy: customer|agent, note? }) records a statement, ownership DECLARED, and proves nothing. Reviewers may additionally attest in the console (ATTESTED, method manual).

Withdrawal gate behaviour. /api/v1/wallets/check requires an approved wallet and an ALLOW on the pre_withdrawal screening. Ownership reaches that screening through the walletOwnership coverage dimension: COMPLETE for VERIFIED (either proof) and ATTESTED; PARTIAL for UNVERIFIED, and for DECLARED, which the gate treats exactly like UNVERIFIED unless your admin turns on Accept declared ownership (ownership.acceptDeclared, default off, audited), after which DECLARED reads COMPLETE. The response states it under ownership: { state, method, accepted, acceptDeclared }. A policy rule on walletOwnership decides what PARTIAL means for the payout; the baseline does not block on it.

Micro-transfer on Avalanche is read directly from the public RPC (eth_getLogs scan of the canonical USDT contract for Transfer(wallet → destination) from an estimated start block, ≤ 2,000-block chunks, ≤ 15 chunks per check, newest first); the matching txHash and blockNumber are recorded. BSC has no micro-transfer (no canonical Tether/Circle-native stablecoin in the issuer list); declare or attest there.

GateMonitor: ongoing monitoring of registered walletsRegistered wallets are re-observed after every list change and every 6 hours; you hear about it only when something changed.

Screening at registration is a snapshot. GateMonitor keeps observing every active wallet in your registry (status pending or approved) for the two facts that can change without you doing anything: an official sanctions listing (OFAC, UK, EU) and a USDT/USDC issuer freeze read live from the token contract. Registered wallets are re-observed after every list change and every 6 hours; you get a wallet.state_changed webhook and a case only when something changed: a new listing, a delisting, a freeze or an unfreeze. The first observation is a baseline, not a change, and an unreachable network or contract (UNAVAILABLE) never counts as a change.

On a change the wallet's open case gets a wallet.state_changed timeline event listing the diff (a new case is opened on the registration screening if none is open), the same diff goes out as the webhook { walletId, customerRef, chain, address, changes[], current, caseId, observedAt }, and the console wallet registry shows Last checked / Last change per wallet. Older subjects that were screened but never registered are covered by the 90-day re-screen described under GateMonitor.

Replay: POST /api/v1/replayRe-run the policy over the stored evidence of a screening, with no provider re-queried, optionally under a different policy version.

Body { "screeningId": "…", "policyVersion": "optional" }. KYTGate re-runs the pure policy evaluator over the stored signals, coverage and receipt inputs of that screening, with no provider queried again, and returns original, replayed and reproduced. Pass a different policyVersion to ask what a stricter or looser policy would have decided on the same evidence (what-if). Receipts issued before replay support report inputs.completeness: "partial" rather than guessing missing inputs.

Webhooks (optional)HMAC-SHA256-signed JSON events for screenings, cases, mismatches and conflicts; respond 2xx within 1.5 s (10 s on retries) or we retry with backoff.

Configure an HTTPS destination per tenant and KYTGate pushes screening.completed, case.opened and case.closed, case.sla_breached, reconciliation.mismatch, provider.conflict, case.docs_requested, case.remediation_submitted, wallet.state_changed and wallet.ownership_verified events as JSON. Each delivery carries X-KYTGate-Signature: v1=<hex>, X-KYTGate-Timestamp, X-KYTGate-Event and X-KYTGate-Delivery. Verify with HMAC-SHA256(secret, timestamp + "." + rawBody) over the raw request body, reject timestamps older than five minutes, and treat the delivery id as the idempotency key, since retries reuse it. Respond 2xx. The first attempt is made inline with the screening and gives you 1.5 seconds, short on purpose, so a slow endpoint of yours cannot delay a decision; every retry gives you 10 seconds. Anything other than a 2xx, including a timeout, is retried with backoff (1m, 5m, 30m, 2h, 12h) by a sweep that runs every 10 minutes, and then marked failed, so an endpoint that needs a few seconds still receives every event, on the retry rather than the first try. The live queue is on the status page.

Tenant mode: shadow vs enforceEvery tenant starts in shadow (we decide and record, you keep deciding); your admin flips to enforce when ready.

Every tenant starts in shadow: KYTGate decides and records, you keep deciding. Every screening response, wallet check and webhook carries mode and enforced so your integration can be wired once and switched later. In shadow, cases still open for comparison but SLA breaches are not announced. Your admin flips the tenant to enforce in the console (audited); from then on REVIEW / BLOCK / HOLD mean "hold the funds" and the withdrawal gate is binding. Sandbox tenants are always shadow. Console logins are tenant-scoped: your admin and reviewer accounts see only your tenant (your admin can create further users for it); KYTGate staff accounts are global and act only on your documented instruction.

MCP endpoint (read-only): POST /api/mcpModel Context Protocol over Streamable HTTP with the same tenant key; ten read-only tools, audited and rate-limited; nothing here screens or closes anything.

POST https://kytgate.com/api/mcp serves the Model Context Protocol over Streamable HTTP (JSON-RPC 2.0, protocol 2025-06-18, JSON responses only; GET is 405, DELETE is 204). Authenticate with the same tenant key: Authorization: Bearer kyt_…. Ten read-only tools scoped to your tenant: get_screening, list_cases, get_case, replay_screening, get_receipt_key, rule_stats, list_wallets, trace_transfer, wallet_profile, sar_draft, each returning what the matching REST route returns, as text plus structuredContent. Nothing here screens, closes, registers or re-queries a provider; every call is audited (mcp.tools.call) and limited to 600 calls per tenant per hour (error -32000 rate_limited).

Client configuration (Streamable HTTP):

{"mcpServers":{"kytgate":{"url":"https://kytgate.com/api/mcp","headers":{"Authorization":"Bearer kyt_YOUR_TENANT_KEY"}}}}

For clients that only speak stdio, bridge with mcp-remote:

npx mcp-remote https://kytgate.com/api/mcp --header "Authorization: Bearer kyt_…"
Asking the customer: GateRemediateSend the customer a one-time link with a document checklist from a case; answers land on the case timeline.

From a case, a reviewer creates an information request: a checklist (wallet ownership signature, exchange withdrawal receipt, source-of-funds questions, free explanation, other document), an optional message and a due date. The customer receives a one-time link (/r/<token>) that shows only your company name and the checklist, never the case or the decision.Default is link-only: POST /api/v1/cases/{id}/remediation (or the console) returns the link plus a ready-to-send subject and text, and you deliver it through your own e-mail, CRM or app, so the customer's e-mail address never reaches KYTGate. Optionally pass customerEmail and KYTGate sends it as "<your company> via KYTGate" once, with your compliance inbox as reply-to; the address is used for that send and never stored, so there is no reminder mail. Answers and files (≤ 3 × 2 MB, PDF/PNG/JPG/WEBP, stored base64 with the case and deleted with it under your retention) land on the case timeline; ownership signatures are verified on the spot (EIP-191), and for a Tron or Bitcoin wallet the page shows the micro-transfer challenge instead, with an "I have sent it, check now" button. The case moves to docs_requested and back to open on response; webhooks case.docs_requested and case.remediation_submitted carry the details. Unanswered requests expire on the due date and fire case.sla_breached, so you can follow up through your own channel.

Data retention and offboardingPer-tenant retention (default 7 years) with legal hold; closing a tenant disables keys now and deletes everything 30 days later.

Each tenant sets an evidence retention period (default 7 years, 1–15) and can place a legal hold that suspends all deletion. A daily job deletes screenings, signals, receipts, cases, events, reconciliations and processor signals older than the period, tenant by tenant, and writes what it deleted to the audit log. Sandbox tenants keep 7 days. Closing a tenant disables its keys immediately and deletes everything 30 days later (reversible until then, blocked by legal hold); the console exports everything held for the tenant as one JSON file, signed receipts included, before that.

Onboarding & shadow mode (GateShadow)Send your own decision with each screening and compare agreement, false positives and misses before you enforce.
YOUR PROCESSKYTGATE (shadow)ALLOWALLOW=REVIEWALLOW≠BLOCKBLOCK=REVIEWREVIEW=ALLOWBLOCK≠measure agreement and false positives before you enforce

New tenants run in shadow mode first: send your real traffic and include shadow: { yourDecision, yourReason?, yourProvider? } (ALLOW | REVIEW | BLOCK | REJECT) with each screening, or attach it later with POST /api/v1/screenings/{id}/shadow once your own process concluded. Your decision never influences ours. GET /api/v1/shadow?days=30 (and the console) returns the agreement matrix, where we were stricter or looser, potential false positives (we REVIEW, you ALLOW) and potential misses (we ALLOW, you BLOCK), each disagreement with the rules that fired and the coverage gaps behind it. Tune the policy with backtest until the numbers look right, then enforce.