API documentation

One call per funding event. Base URL is your KYTGate deployment. All requests and responses are JSON. During early access the API surface below is stable; anything else is subject to change.

Machine-readable spec: /api/openapi.json (OpenAPI 3.1). Try everything without talking to anyone: create a sandbox tenant — isolated, 50 screenings a month.

Authentication

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

Authorization: Bearer kyt_XXXXXXXXXXXXXXXXXXXXXXXX

POST /api/v1/screenings

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 semantics — read this once

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

Case outcome — GET /api/v1/screenings/{id} and /api/v1/cases

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-signals

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

{
  "processor": "gcex",
  "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.

Receipt verification — GET /api/v1/receipt-key

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). Every evidence pack ships these instructions inline, and the in-browser verifier runs the same check without uploading anything.

Funding events — /api/v1/events (Behaviour Engine input)

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). Under the baseline policy, behaviour ≥ 60 → REVIEW; behaviour never blocks on its own. Coverage customerBehaviour is COMPLETE only when a customerRef was given.

Customer network — GET /api/v1/customers/{ref}/graph (GateGraph)

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 and meta.bankRef 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 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.

Settlement reconciliation — POST /api/v1/reconcile

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

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 — nothing is recomputed at download time.

Monthly compliance report — GET /api/v1/reports/monthly

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

Wallet whitelist — /api/v1/wallets

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 — 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).

Replay — POST /api/v1/replay

Body { "screeningId": "…", "policyVersion": "optional" }. KYTGate re-runs the pure policy evaluator over the stored signals, coverage and receipt inputs of that screening — no provider is 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)

Configure an HTTPS destination per tenant and KYTGate pushes screening.completed, case.opened and case.closed, case.sla_breached, reconciliation.mismatch and provider.conflict 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 — retries reuse it. Respond 2xx within 5 seconds; anything else is retried with backoff (1m, 5m, 30m, 2h, 12h — retries piggyback on subsequent events and a daily sweep) and then marked failed.

Tenant mode — shadow vs enforce

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.

Onboarding & shadow mode (GateShadow)

YOUR PROCESSKYTGATE (shadow)ALLOWALLOW=REVIEWALLOWBLOCKBLOCK=REVIEWREVIEW=ALLOWBLOCKmeasure 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.

Decision-support software, not legal advice.