examples/crypto-in.json
{
"_applicantId": "REPLACE_WITH_APPLICANT_ID",
"txnId": "crypto-in-eth-0001",
"txnDate": "2026-05-21 14:35:00+0000",
"type": "finance",
"amount": 1250.75,
"currency": "USDC",
"currencyType": "crypto",
"direction": "in",
"crypto": {
"chain": "ETH",
"contract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"paymentTxnId": "0x1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef90",
"outputIndex": 0
},
"applicant": {
"externalUserId": "user-001",
"type": "individual",
"fullName": "John Smith"
},
"counterparty": {
"externalUserId": "wallet:0xabcd0000000000000000000000000000000000ef",
"type": "individual",
"fullName": "Unknown originator",
"paymentMethod": {
"type": "unhostedWallet",
"accountId": "0xabcd0000000000000000000000000000000000ef"
}
}
}
examples/fiat-out.json
{
"_applicantId": "REPLACE_WITH_APPLICANT_ID",
"txnId": "finance-fiat-out-0001",
"txnDate": "2026-05-21 14:30:00+0000",
"type": "finance",
"amount": 1500.50,
"currency": "EUR",
"currencyType": "fiat",
"direction": "out",
"paymentDetails": "Invoice #INV-2026-001",
"mcc": 5411,
"applicant": {
"externalUserId": "user-001",
"type": "individual",
"fullName": "John Smith",
"dob": "1990-05-01",
"email": "john@example.com",
"address": { "country": "USA", "town": "New York" },
"paymentMethod": {
"type": "bankCard",
"accountId": "4111********1111",
"issuingCountry": "USA",
"3dsUsed": true,
"2faUsed": false
}
},
"counterparty": {
"externalUserId": "merchant-XYZ",
"type": "company",
"fullName": "Acme Inc.",
"registrationNumber": "12345678",
"address": { "country": "QAT" }
},
"props": {
"internalNote": "test-payment-from-skill",
"channel": "web"
}
}
examples/login-event.json
{
"_applicantId": "REPLACE_WITH_APPLICANT_ID",
"txnId": "evt-login-0001",
"txnDate": "2026-05-21 14:45:00+0000",
"type": "userPlatformEvent",
"applicant": {
"externalUserId": "user-001",
"type": "individual",
"device": {
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"fingerprint": "abc123fp",
"ipInfo": { "ip": "203.0.113.42" }
}
},
"userPlatformEvent": {
"type": "login",
"twoFaUsed": true
}
}
examples/new-applicant.json
{
"_levelName": "Default",
"txnId": "first-ever-txn-0001",
"txnDate": "2026-05-21 14:50:00+0000",
"type": "finance",
"amount": 250.00,
"currency": "USD",
"currencyType": "fiat",
"direction": "in",
"paymentDetails": "Initial deposit",
"applicant": {
"externalUserId": "brand-new-user-002",
"type": "individual",
"fullName": "Jane Doe",
"dob": "1992-07-15",
"email": "jane@example.com",
"address": { "country": "GBR", "town": "London" }
},
"counterparty": {
"externalUserId": "bank:hsbc-uk",
"type": "company",
"fullName": "HSBC UK",
"address": { "country": "GBR" }
}
}
examples/travel-rule.json
{
"_applicantId": "REPLACE_WITH_APPLICANT_ID",
"txnId": "tr-btc-out-0001",
"txnDate": "2026-05-21 14:40:00+0000",
"type": "travelRule",
"amount": 0.075,
"currency": "BTC",
"currencyType": "crypto",
"direction": "out",
"amountInDefaultCurrency": 4985.20,
"defaultCurrencyCode": "USD",
"crypto": {
"chain": "BTC",
"paymentTxnId": "abcd1234ef56...will-be-filled-after-broadcast"
},
"applicant": {
"externalUserId": "user-001",
"type": "individual",
"fullName": "John Smith",
"dob": "1990-05-01",
"residenceCountry": "USA",
"address": { "country": "USA", "town": "New York" },
"paymentMethod": { "type": "cryptoWallet", "accountId": "bc1qmywallet..." }
},
"counterparty": {
"externalUserId": "vasp:acme-bank",
"type": "company",
"fullName": "Acme Crypto Exchange",
"registrationNumber": "BE-1234567",
"leiCode": "529900XXXX0000XXXX00",
"residenceCountry": "BEL",
"address": { "country": "BEL", "town": "Brussels" },
"institution": {
"code": "ACME-BANK-EU",
"name": "Acme Crypto Exchange",
"internalId": "REPLACE_WITH_VASP_ID_FROM_DIRECTORY"
},
"paymentMethod": { "type": "cryptoWallet", "accountId": "bc1qrecipientaddress..." }
}
}
references/transaction-schema.md
# KytTxnData — schema reference
Sources: Sumsub OpenAPI (`sumsub-openapi.json` → `components.schemas.KytTxnData` and the related `KytTxnInfo`, `KytTxnParticipant`, `KytTxnPaymentMethod`, `KytTxnCryptoParams`, `UserPlatformEventInfo`, etc.) plus the [official docs](https://docs.sumsub.com/reference/submit-transaction-for-existing-applicant.md).
## Endpoint routing
| Use case | Method + Path | Required query |
|---|---|---|
| Submit for an existing applicant | `POST /resources/applicants/{applicantId}/kyt/txns/-/data` | — |
| Submit and create the applicant on the fly | `POST /resources/applicants/-/kyt/txns/-/data` | `levelName=<name>` (and optionally `accessToken=<token>` for Device Intelligence) |
Both are marked `deprecated: true` in the OpenAPI but remain the canonical entry points in the Sumsub docs — no replacement exists.
The post script picks between them based on the spec's `_applicantId` or `_levelName` sidecar keys.
## Top-level `KytTxnData`
| Field | Type | Required | Notes |
|---|---|---|---|
| `txnId` | string | ✓ | Alphanumeric, unique on your side. Collision returns `409 Entity already exists` — use bulk-import to update. |
| `type` | enum | — (default `finance`) | `finance`, `travelRule`, `kyc`, `auditTrailEvent`, `userPlatformEvent`, `scheduledEvent`, `iGamingSession`. |
| `txnDate` | string | — | Format `yyyy-MM-dd HH:mm:ss+XXXX`, e.g. `2026-05-21 14:30:00+0000`. |
| `zoneId` | string | — | Time-zone name (e.g. `UTC+01:00`). |
| `info` | `KytTxnInfo` | ✓ for `finance`/`travelRule` | Amount, currency, direction, crypto params. See below. |
| `applicant` | `KytTxnParticipant` | ✓ | The person/entity acting on your platform. Must have `externalUserId`. |
| `counterparty` | `KytTxnParticipant` | — | Other side of the transaction. |
| `userPlatformEventInfo` | `UserPlatformEventInfo` | ✓ for `userPlatformEvent` | Event type + optional 2FA flag. |
| `sourceKey` | string | — | Source-key segregation. |
| `props` | `Map<string, string>` | — | Custom keys; values are stringified. |
| `orderId` | string | — | Optional. |
| `gamblingBonusChangeInfo` / `gamblingLimitChangeInfo` / `iGamingSessionInfo` / `auditTrailEventInfo` / `kytTxnKycInfo` / `triggerInfo` | objects | — | Specialized sub-objects for less-common transaction types; pass-through. |
## `KytTxnInfo`
| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | number | ✓ | |
| `currencyCode` | string | ✓ | ISO-4217 (`USD`, `EUR`, ...) or crypto ticker (`BTC`, `ETH`, ...). |
| `direction` | enum | ✓ | `in` (deposit/receive) or `out` (withdrawal/send). |
| `currencyType` | enum | — | `fiat` (default) or `crypto`. |
| `amountInDefaultCurrency` | number | — | Pre-converted amount in your client's default currency. |
| `defaultCurrencyCode` | string | — | The currency code that pairs with `amountInDefaultCurrency`. |
| `paymentDetails` | string | — | Remittance memo / reference text. |
| `paymentTxnId` | string | — | Blockchain tx hash for crypto. |
| `fingerprint` | string | — | Alternative blockchain identifier. |
| `mcc` | integer | — | 4-digit Merchant Category Code (fiat card payments). |
| `type` | string | — | Free-form sub-categorization (e.g. `payroll`, `bonus`). The skill exposes this as `infoType:` in the compact spec to avoid colliding with the top-level `type`. |
| `cryptoParams` | `KytTxnCryptoParams` | — | See below. |
### `KytTxnCryptoParams`
| Field | Type | Notes |
|---|---|---|
| `cryptoChain` | string | Mandatory for tokens, empty for native (BTC/ETH). See [chain codes](https://docs.sumsub.com/reference/crypto-chain-codes.md). |
| `contractAddress` | string | Smart-contract address (ERC-20, etc.). |
| `attemptId` | string | Identifier of a retry attempt. |
| `outputIndex` | integer | UTXO transactions. |
## `KytTxnParticipant` (used for both `applicant` and `counterparty`)
| Field | Type | Notes |
|---|---|---|
| `externalUserId` | string | **Effectively required** — `applicant.externalUserId` is mandatory; `counterparty.externalUserId` is recommended. Stable on your side; reuse across all txns for the same person. |
| `type` | enum | `individual` (default) or `company`. |
| `fullName` / `fullNameEn` | string | Required for companies; required by the "non-existing applicant" flow. |
| `firstName` / `firstNameEn` / `lastName` / `lastNameEn` | string | Individuals. |
| `nameType` | enum | `aliasName`, `birthName`, `maidenName`, `legalName`, `shortName`, `tradingName`, `other`. |
| `dob` | string | `YYYY-MM-DD`. |
| `placeOfBirth` | string | |
| `email` / `phone` | string | |
| `address` | `Address` | See below. |
| `device` | `KytTxnDevice` | `userAgent`, `sessionId`, `acceptLang`, `fingerprint`, `address`, `coords`, `ipInfo`. |
| `paymentMethod` | `KytTxnPaymentMethod` | See below. |
| `institutionInfo` / `institutionExtractedInfo` | `KytTxnInstitutionInfo` | `code`, `name`, `address`, `internalId` (VASP id from directory). |
| `idDoc` | `KytTxnParticipantIdDoc` | `number`, `country`, `idDocType`, `registrationAuthority`. |
| `companyType` / `registrationNumber` / `licenseNumber` / `leiCode` / `residenceCountry` | string | Company-specific. |
| `ceo` | `KytTxnParticipantName` | Names of CEO; used in Travel-Rule company counterparties. |
### `Address`
`country`, `postCode`, `town`, `street`, `subStreet`, `state`, `buildingName`, `flatNumber`, `buildingNumber`, `formattedAddress` (the builder accepts `formatted:` as a friendly alias).
### `KytTxnPaymentMethod`
| Field | Type | Notes |
|---|---|---|
| `type` | string | Free-form: `bankCard`, `bankAccount`, `crypto`, `cryptoWallet`, `eWallet`, `unhostedWallet`, `card`, `other`, etc. The builder forwards whatever you supply — the docs and live data accept far more values than any narrow enum would. |
| `accountId` | string | IBAN / card-last4 hash / wallet address / DC hash. |
| `accountIdHash` | string | Pre-computed hash if you don't want to send raw. |
| `issuingCountry` | string | ISO-3 alpha-3 (`USA`, `DEU`). |
| `subType` | string | Sub-classification. |
| `memo` | string | Free-form. |
| `fingerprint` | string | Device/payment-method fingerprint. |
| `3dsUsed` / `2faUsed` | boolean | Authentication evidence. |
## `UserPlatformEventInfo` (for `type: userPlatformEvent`)
| Field | Type | Required | Notes |
|---|---|---|---|
| `type` | enum | ✓ | `login`, `failedLogin`, `signup`, `passwordReset`, `twoFaReset`, `general`. |
| `twoFaUsed` | boolean | — | |
| `passwordHash` | string | — | Hashed password (do NOT send raw). |
## Common gotchas
- **`txnId` collisions** — Sumsub will reject with `409 Entity already exists`. For updates, use the [bulk-import](https://docs.sumsub.com/reference/import-transaction-history.md) endpoint, not this one.
- **`levelName` URL-encoding** — names with `@`, `+`, or spaces must be URL-encoded in the query string. `post_transaction.sh` handles this.
- **Deprecated endpoints, still canonical** — both POST routes are marked deprecated but remain the only ways to submit a transaction. Don't rely on the deprecation marker.
- **`info.amount` precision** — use `amountInDefaultCurrency` for the client's default currency to avoid Sumsub re-converting at the wrong rate.
- **`crypto.chain` for tokens** — required for ERC-20 / TRC-20 / etc. Omit for native chains (BTC, ETH).
- **`institution.internalId`** — when set on a counterparty, Sumsub treats the named VASP as "expected counterparty" and folds it into Travel-Rule scoring.
- **`applicant.externalUserId` reuse** — Sumsub uses this id to link transactions to an applicant. The same user must always carry the same `externalUserId`; mismatches cause silent split profiles.
- **Response shape** — Sumsub returns the full `KytTxn` envelope including `score`, `reviewAnswer` (`GREEN`/`YELLOW`/`RED`), `riskLabels`, and any matched rules. `4xx` errors return `{description, code, errorName}`.
scripts/build_transaction.py
#!/usr/bin/env python3
"""
Expand a compact KYT-transaction spec (JSON on stdin) into a full Sumsub
`KytTxnData` payload (JSON on stdout).
Spec format: see SKILL.md. The two underscore-prefixed keys `_applicantId` and
`_levelName` control routing in post_transaction.sh and are stripped from the
emitted payload — they're stored in a sidecar file (path supplied via env
`SUMSUB_TXN_ROUTE_FILE`) so the post script can pick the right URL.
"""
import json
import os
import sys
TOP_TYPES = {"finance", "travelRule", "kyc", "auditTrailEvent",
"userPlatformEvent", "scheduledEvent", "iGamingSession"}
DIRECTIONS = {"in", "out"}
CURRENCY_TYPES = {"crypto", "fiat"}
APPLICANT_TYPES = {"individual", "company"}
NAME_TYPES = {"aliasName", "birthName", "maidenName", "legalName", "shortName", "tradingName", "other"}
USER_PLATFORM_EVENT_TYPES = {"login", "failedLogin", "signup", "passwordReset", "twoFaReset", "general"}
def _enum(value, allowed, label):
if value is None:
return None
if value not in allowed:
raise ValueError(f"{label}: {value!r} not in {sorted(allowed)}")
return value
def _build_address(addr):
if addr is None:
return None
if not isinstance(addr, dict):
raise ValueError(f"address must be an object; got {type(addr).__name__}")
out = {}
for k in ("country", "postCode", "town", "street", "subStreet", "state",
"buildingName", "flatNumber", "buildingNumber"):
if addr.get(k) is not None:
out[k] = addr[k]
if addr.get("formatted") is not None:
out["formattedAddress"] = addr["formatted"]
elif addr.get("formattedAddress") is not None:
out["formattedAddress"] = addr["formattedAddress"]
return out or None
def _build_payment_method(pm):
if pm is None:
return None
if not isinstance(pm, dict):
raise ValueError(f"paymentMethod must be an object; got {type(pm).__name__}")
out = {}
for k in ("type", "accountId", "accountIdHash", "issuingCountry",
"subType", "memo", "fingerprint", "3dsUsed", "2faUsed"):
if pm.get(k) is not None:
out[k] = pm[k]
return out or None
def _build_device(dev):
if dev is None:
return None
if not isinstance(dev, dict):
raise ValueError(f"device must be an object; got {type(dev).__name__}")
out = {}
for k in ("userAgent", "sessionId", "sessionAgeMs", "acceptLang",
"fingerprint", "deviceBindingId"):
if dev.get(k) is not None:
out[k] = dev[k]
addr = _build_address(dev.get("address"))
if addr:
out["address"] = addr
if dev.get("coords") is not None:
out["coords"] = dev["coords"]
if dev.get("ipInfo") is not None:
out["ipInfo"] = dev["ipInfo"]
return out or None
def _build_id_doc(doc):
if doc is None:
return None
if not isinstance(doc, dict):
raise ValueError(f"idDoc must be an object; got {type(doc).__name__}")
out = {}
for k in ("number", "country", "idDocType", "registrationAuthority"):
if doc.get(k) is not None:
out[k] = doc[k]
return out or None
def _build_institution(ins):
if ins is None:
return None
if not isinstance(ins, dict):
raise ValueError(f"institution must be an object; got {type(ins).__name__}")
out = {}
for k in ("code", "name", "internalId"):
if ins.get(k) is not None:
out[k] = ins[k]
addr = _build_address(ins.get("address"))
if addr:
out["address"] = addr
return out or None
def _build_name_obj(n, label):
if n is None:
return None
if not isinstance(n, dict):
raise ValueError(f"{label} must be an object; got {type(n).__name__}")
out = {}
for k in ("firstName", "firstNameEn", "lastName", "lastNameEn"):
if n.get(k) is not None:
out[k] = n[k]
if n.get("nameType") is not None:
out["nameType"] = _enum(n["nameType"], NAME_TYPES, f"{label}.nameType")
return out or None
def _build_participant(spec, *, label, require_external_id):
if spec is None:
return None
if not isinstance(spec, dict):
raise ValueError(f"{label} must be an object; got {type(spec).__name__}")
out = {}
ext = spec.get("externalUserId")
if require_external_id and not ext:
raise ValueError(f"{label}.externalUserId is required")
if ext is not None:
out["externalUserId"] = ext
if spec.get("type") is not None:
out["type"] = _enum(spec["type"], APPLICANT_TYPES, f"{label}.type")
for k in ("fullName", "fullNameEn", "firstName", "firstNameEn",
"lastName", "lastNameEn", "dob", "email", "phone",
"placeOfBirth", "registrationNumber", "leiCode",
"licenseNumber", "residenceCountry", "companyType"):
if spec.get(k) is not None:
out[k] = spec[k]
if spec.get("nameType") is not None:
out["nameType"] = _enum(spec["nameType"], NAME_TYPES, f"{label}.nameType")
addr = _build_address(spec.get("address"))
if addr:
out["address"] = addr
pm = _build_payment_method(spec.get("paymentMethod"))
if pm:
out["paymentMethod"] = pm
dev = _build_device(spec.get("device"))
if dev:
out["device"] = dev
doc = _build_id_doc(spec.get("idDoc"))
if doc:
out["idDoc"] = doc
ins = _build_institution(spec.get("institution") or spec.get("institutionInfo"))
if ins:
out["institutionInfo"] = ins
ceo = _build_name_obj(spec.get("ceo"), f"{label}.ceo")
if ceo:
out["ceo"] = ceo
return out
def _build_info(spec):
"""Map top-level amount/currency/direction/etc + crypto.* into KytTxnInfo."""
out = {}
if spec.get("amount") is not None:
out["amount"] = float(spec["amount"])
if spec.get("amountInDefaultCurrency") is not None:
out["amountInDefaultCurrency"] = float(spec["amountInDefaultCurrency"])
if spec.get("currency") is not None:
out["currencyCode"] = spec["currency"]
elif spec.get("currencyCode") is not None:
out["currencyCode"] = spec["currencyCode"]
if spec.get("defaultCurrencyCode") is not None:
out["defaultCurrencyCode"] = spec["defaultCurrencyCode"]
if spec.get("currencyType") is not None:
out["currencyType"] = _enum(spec["currencyType"], CURRENCY_TYPES, "currencyType")
if spec.get("direction") is not None:
out["direction"] = _enum(spec["direction"], DIRECTIONS, "direction")
if spec.get("paymentDetails") is not None:
out["paymentDetails"] = spec["paymentDetails"]
if spec.get("paymentTxnId") is not None:
out["paymentTxnId"] = spec["paymentTxnId"]
if spec.get("mcc") is not None:
out["mcc"] = int(spec["mcc"])
if spec.get("infoType") is not None:
# caller-supplied free-form txn categorization ("payroll", "bonus", etc.)
out["type"] = spec["infoType"]
crypto = spec.get("crypto")
if crypto is not None:
if not isinstance(crypto, dict):
raise ValueError(f"crypto must be an object; got {type(crypto).__name__}")
cp = {}
for k_in, k_out in (("chain", "cryptoChain"), ("contract", "contractAddress"),
("attemptId", "attemptId"), ("outputIndex", "outputIndex")):
if crypto.get(k_in) is not None:
cp[k_out] = crypto[k_in]
if cp:
out["cryptoParams"] = cp
if crypto.get("paymentTxnId") is not None:
out["paymentTxnId"] = crypto["paymentTxnId"]
if crypto.get("fingerprint") is not None:
out["fingerprint"] = crypto["fingerprint"]
return out or None
def _build_user_platform_event(spec):
if spec is None:
return None
if not isinstance(spec, dict):
raise ValueError(f"userPlatformEvent must be an object; got {type(spec).__name__}")
out = {}
if "type" not in spec:
raise ValueError("userPlatformEvent.type is required when type=userPlatformEvent")
out["type"] = _enum(spec["type"], USER_PLATFORM_EVENT_TYPES, "userPlatformEvent.type")
if spec.get("twoFaUsed") is not None:
out["twoFaUsed"] = bool(spec["twoFaUsed"])
if spec.get("passwordHash") is not None:
out["passwordHash"] = spec["passwordHash"]
return out
def build_transaction(spec):
if not isinstance(spec, dict):
raise ValueError(f"transaction spec must be an object; got {type(spec).__name__}")
txn_id = spec.get("txnId")
if not txn_id or not isinstance(txn_id, str):
raise ValueError("txnId is required and must be a non-empty string")
txn_type = _enum(spec.get("type", "finance"), TOP_TYPES, "type")
# Routing keys — stored in sidecar, stripped from payload
applicant_id = spec.get("_applicantId")
level_name = spec.get("_levelName")
if applicant_id and level_name:
raise ValueError("set only ONE of _applicantId / _levelName, not both")
if not applicant_id and not level_name and txn_type == "finance":
# finance txns can target an existing applicant via the spec's applicant.externalUserId,
# but the URL still needs an applicantId or a levelName. We let the post script enforce.
pass
payload = {
"txnId": txn_id,
"type": txn_type,
}
if spec.get("txnDate") is not None:
payload["txnDate"] = spec["txnDate"]
if spec.get("zoneId") is not None:
payload["zoneId"] = spec["zoneId"]
if spec.get("orderId") is not None:
payload["orderId"] = spec["orderId"]
if spec.get("sourceKey") is not None:
payload["sourceKey"] = spec["sourceKey"]
if spec.get("props") is not None:
if not isinstance(spec["props"], dict):
raise ValueError("props must be an object (string->string)")
payload["props"] = {k: str(v) for k, v in spec["props"].items()}
# info — flatten top-level amount/currency/... + crypto.* into KytTxnInfo
info = _build_info(spec)
if info:
payload["info"] = info
# Per-type required-field checks
if txn_type in ("finance", "travelRule"):
for k in ("amount", "currencyCode", "direction"):
if info is None or info.get(k) is None:
raise ValueError(f"type={txn_type} requires info.{k} (set top-level "
f"{'amount' if k=='amount' else ('currency' if k=='currencyCode' else 'direction')})")
# applicant — REQUIRED on every txn
if "applicant" not in spec:
raise ValueError("applicant is required")
payload["applicant"] = _build_participant(
spec["applicant"], label="applicant", require_external_id=True,
)
if spec.get("counterparty") is not None:
payload["counterparty"] = _build_participant(
spec["counterparty"], label="counterparty", require_external_id=False,
)
if txn_type == "userPlatformEvent":
upe = spec["userPlatformEvent"] if "userPlatformEvent" in spec else spec.get("userPlatformEventInfo")
if upe is None:
raise ValueError(
"type=userPlatformEvent requires a `userPlatformEvent` block "
"(with at least `type:` set)"
)
payload["userPlatformEventInfo"] = _build_user_platform_event(upe)
# Sidecar route file: lets post_transaction.sh pick the URL without re-parsing
route_path = os.environ.get("SUMSUB_TXN_ROUTE_FILE")
if route_path:
with open(route_path, "w") as f:
json.dump({
"applicantId": applicant_id,
"levelName": level_name,
"txnId": txn_id,
}, f)
return payload
def main():
spec = json.load(sys.stdin)
payload = build_transaction(spec)
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
scripts/post_transaction.sh
#!/usr/bin/env bash
# POST a KytTxnData payload to the Sumsub transaction-monitoring endpoint.
#
# Authenticates via App Token + secret (HMAC-SHA256) per
# https://docs.sumsub.com/reference/authentication.
#
# Usage (two-step: build then post; the build emits a sidecar route file):
#
# SUMSUB_TXN_ROUTE_FILE=/tmp/route.json \
# python3 build_transaction.py < spec.json > /tmp/txn.json
#
# SUMSUB_APP_TOKEN=sbx:... \
# SUMSUB_SECRET_KEY=... \
# SUMSUB_TXN_ROUTE_FILE=/tmp/route.json \
# ./post_transaction.sh /tmp/txn.json
#
# Route selection (read from $SUMSUB_TXN_ROUTE_FILE):
# applicantId set -> POST /resources/applicants/{applicantId}/kyt/txns/-/data
# levelName set -> POST /resources/applicants/-/kyt/txns/-/data?levelName=<levelName>
# neither -> error (you must pick a routing mode in the spec)
#
# Refuses non-sandbox tokens unless SUMSUB_ALLOW_PROD=1.
# Override SUMSUB_BASE only for testing; default is https://api.sumsub.com.
#
# Prints the response body followed by a final line: HTTP <code>
set -euo pipefail
: "${SUMSUB_APP_TOKEN:?SUMSUB_APP_TOKEN is required (sandbox App Token, 'sbx:' prefix)}"
: "${SUMSUB_SECRET_KEY:?SUMSUB_SECRET_KEY is required (paired secret key)}"
BASE="${SUMSUB_BASE:-https://api.sumsub.com}"
if [[ "${SUMSUB_APP_TOKEN}" != sbx:* && "${SUMSUB_ALLOW_PROD:-0}" != "1" ]]; then
echo "error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' prefix)." >&2
echo " Production credentials must not be shared with this skill." >&2
exit 3
fi
# Payload comes from $1 (file path), stdin if no arg, or "-" for explicit stdin.
PAYLOAD="${1:--}"
if [[ "${PAYLOAD}" == "-" ]]; then
PAYLOAD="$(mktemp)"
trap 'rm -f "${PAYLOAD}"' EXIT
cat > "${PAYLOAD}"
elif [[ ! -f "${PAYLOAD}" ]]; then
echo "payload file not found: ${PAYLOAD}" >&2
exit 2
fi
if [[ ! -s "${PAYLOAD}" ]]; then
echo "error: payload is empty (no file content, or stdin closed without data)" >&2
exit 2
fi
ROUTE_FILE="${SUMSUB_TXN_ROUTE_FILE:-}"
if [[ -z "${ROUTE_FILE}" || ! -f "${ROUTE_FILE}" ]]; then
echo "SUMSUB_TXN_ROUTE_FILE must point to the sidecar route file written by build_transaction.py" >&2
exit 2
fi
# Parse the route file with python (no jq dependency).
APPLICANT_ID="$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('applicantId') or '')" "${ROUTE_FILE}")"
LEVEL_NAME="$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('levelName') or '')" "${ROUTE_FILE}")"
if [[ -n "${APPLICANT_ID}" ]]; then
# URL-encode the applicantId in case it contains awkward chars; in practice
# it's a Mongo ObjectId hex string, but encode anyway so signing matches the
# wire form exactly.
ENCODED_APP_ID="$(python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "${APPLICANT_ID}")"
PATH_Q="/resources/applicants/${ENCODED_APP_ID}/kyt/txns/-/data"
elif [[ -n "${LEVEL_NAME}" ]]; then
ENCODED_LEVEL="$(python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "${LEVEL_NAME}")"
PATH_Q="/resources/applicants/-/kyt/txns/-/data?levelName=${ENCODED_LEVEL}"
else
echo "route file has neither applicantId nor levelName — set _applicantId or _levelName in the spec" >&2
exit 2
fi
METHOD="POST"
TS="$(date -u +%s)"
# Signing string = ts + METHOD + path?query + body (raw payload bytes).
SIG="$(
{ printf '%s%s%s' "${TS}" "${METHOD}" "${PATH_Q}"; cat "${PAYLOAD}"; } \
| openssl dgst -sha256 -hmac "${SUMSUB_SECRET_KEY}" -hex \
| awk '{print $NF}'
)"
curl --compressed -sS -X "${METHOD}" \
-H "X-App-Token: ${SUMSUB_APP_TOKEN}" \
-H "X-App-Access-Ts: ${TS}" \
-H "X-App-Access-Sig: ${SIG}" \
-H "X-Agent-Source: sumsub-skills" \
-H "X-Agent-Source-Ver: 1.6.0" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data-binary "@${PAYLOAD}" \
-w '\nHTTP %{http_code}\n' \
"${BASE%/}${PATH_Q}"
SKILL.md
---
name: sumsub-create-transaction
description: Submit a transaction to Sumsub Transaction Monitoring (KYT) via the ApplicantResource API. TRIGGER when the user asks to "submit / create / send / record a transaction", "test a KYT rule with a transaction", post a fiat or crypto payment for monitoring, attach Travel Rule data to a transfer, log a user-platform event (signup / login / password-reset / 2FA-reset) for monitoring, or submit a transaction for an applicant that does not exist yet (the request creates them). SKIP for editing arbitrary fields on an existing transaction, for bulk import (`/kyt/misc/txns/import`), for fetching / approving / rejecting transactions, for KYT rule management, or for Travel Rule data-exchange flows that are not transaction creation.
allowed-tools: Read, Write, Bash
---
# Sumsub — Create Transaction (KYT)
Builds a `KytTxnData` JSON payload from a compact spec, POSTs it to the Sumsub KYT endpoint, and reports the resulting `txnId` / `score` / `reviewAnswer`.
## Endpoint
Two variants, picked automatically by the post script:
| Case | Method + Path |
|---|---|
| Applicant exists | `POST https://api.sumsub.com/resources/applicants/{applicantId}/kyt/txns/-/data` |
| Applicant does **not** exist (Sumsub creates one from `applicant.externalUserId`) | `POST https://api.sumsub.com/resources/applicants/-/kyt/txns/-/data?levelName=<levelName>` |
Body: [`KytTxnData`](references/transaction-schema.md). Returns the persisted `KytTxn` with monitoring scores attached.
Both endpoints are marked deprecated, but they remain the canonical "submit transaction" entry points in the [official docs](https://docs.sumsub.com/reference/submit-transaction-for-existing-applicant.md). No v2/v3 replacement exists.
## Auth — App Token + secret (sandbox only)
This skill talks to the public Sumsub API and signs each request per
[the authentication reference](https://docs.sumsub.com/reference/authentication).
The full how-it-works writeup lives in the [`sumsub-api-auth`](../sumsub-api-auth/SKILL.md)
skill — read it if you hit `401 Invalid signature`.
> **⚠️ Sandbox tokens only.** Do **not** accept or use a production App Token
> here — transaction monitoring acts on real applicant data and can fire
> real KYT alerts. If the user offers a prod token, refuse and ask them to
> generate a sandbox pair at <https://cockpit.sumsub.com/checkus/home?sbx=true>
> (**Connect Sumsub to your AI agent** -> **Build & configure** ->
> **Generate token**). Token + secret are shown once — copy both before
> closing the dialog. The helper
> script enforces this — it rejects tokens that don't start with `sbx:`.
| Var | Example |
|---|---|
| `SUMSUB_APP_TOKEN` | `sbx:...` — sandbox App Token from the dashboard. |
| `SUMSUB_SECRET_KEY` | The paired secret shown once at token creation. |
| `SUMSUB_BASE` | Optional. Defaults to `https://api.sumsub.com`. |
### Signing the resolved path
This is the one routing wrinkle — the path **with query string** must be
signed. The post script handles it: it reads the sidecar route file the
builder emits, picks the URI (`/resources/applicants/{id}/…` or
`/resources/applicants/-/…?levelName=…`), URL-encodes the dynamic segments,
and signs the same bytes it sends. If you bypass the script, remember:
- Sign the path you put on the wire — encoded form, query string included.
- Body bytes signed must equal the bytes sent (no whitespace re-flow).
If the user has already supplied credentials in conversation, reuse them;
otherwise ask once before running. Never echo the secret back.
## Procedure
1. **Map the user's intent** to the compact spec below. The vast majority of transactions are `type: finance` (a payment) — for those, the user is really telling you *amount + currency + direction + applicant + counterparty*.
2. **Validate**: `txnId` non-empty, `applicant.externalUserId` non-empty, and per `type`:
- `finance` / `travelRule` → `info.amount`, `info.currencyCode`, `info.direction` required (the OpenAPI marks all three required on `KytTxnInfo`).
- `userPlatformEvent` → `userPlatformEvent.type` required.
- Enums (`direction`, `currencyType`, `applicant.type`, `nameType`, etc.) checked upfront with full allowed-values list on failure.
3. **Generate** the full payload with `${CLAUDE_SKILL_DIR}/scripts/build_transaction.py` (compact spec on stdin → full `KytTxnData` payload on stdout).
4. **POST** via `${CLAUDE_SKILL_DIR}/scripts/post_transaction.sh` — auto-routes to existing-applicant vs non-existing-applicant URL based on whether `_applicantId` was set in the *spec* (NOT in the payload — see below).
5. **Build the dashboard link.** Read `id` (the **server-assigned identifier**, not the `txnId` you supplied) and `clientId` from the response body and format:
```
https://cockpit.sumsub.com/checkus/kyt/txns/<id>?clientId=<clientId>&sbx=true
```
The user-supplied `txnId` in the spec (e.g. `finance-2026-05-21-0001`) is **not** what goes in the URL — Sumsub assigns a separate identifier on persistence. The `sbx=true` query param targets the **Sandbox** workspace — it is the canonical sandbox link param shared across all skills.
6. **Report**: `txnId` (yours), the server `id`, applicant `externalUserId`, direction + amount + currency, counterparty (if any), the response's `score` / `reviewAnswer` / `riskLabels` if present, and the **dashboard link as a clickable markdown link**.
## Compact spec format (JSON or YAML on stdin)
```yaml
# Top-level
txnId: "finance-2026-05-21-0001" # REQUIRED, unique alphanumeric in your system
txnDate: "2026-05-21 14:30:00+0000" # optional; format: yyyy-MM-dd HH:mm:ss+XXXX
zoneId: "UTC+01:00" # optional, time zone string
type: finance # finance | travelRule | kyc | userPlatformEvent | iGamingSession (default: finance)
# Routing — pick ONE
_applicantId: "67abc..." # post to existing applicant (path id, NOT a payload field)
# - OR -
_levelName: "Default" # post to non-existing-applicant URL with ?levelName=...
# (Sumsub creates the applicant from applicant.externalUserId)
# Finance / Travel Rule info (required for those types)
amount: 1500.50
currency: USD # ISO-4217 fiat or crypto ticker (BTC, ETH, …)
currencyType: fiat # fiat | crypto (default: fiat)
direction: out # in | out
amountInDefaultCurrency: 1500.50 # optional, converted to client default
defaultCurrencyCode: USD
paymentDetails: "Invoice #INV-2026-001"
mcc: 5411 # optional, 4-digit Merchant Category Code
# Crypto-only block (used when currencyType=crypto)
crypto:
chain: ETH # ETH | BTC | TRX | … (mandatory for tokens; empty for native)
contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
paymentTxnId: "0x1234abcd..." # on-chain hash
fingerprint: "0x1234abcd..." # alternative blockchain identifier
attemptId: "attempt-01" # optional, when retrying
outputIndex: 0 # for UTXO chains
# Applicant (REQUIRED) — the person/entity acting on your platform
applicant:
externalUserId: "user-001" # REQUIRED — your stable user id
type: individual # individual | company (default: individual)
fullName: "John Smith" # required for company; recommended for non-existing-applicant flow
dob: "1990-05-01"
email: "john@example.com"
phone: "+1234567890"
placeOfBirth: "London"
address:
country: USA
town: "New York"
street: "5th Avenue"
formatted: "5th Avenue, New York, USA"
paymentMethod:
type: bankCard # bankCard | bankAccount | crypto | cryptoWallet |
# eWallet | unhostedWallet | card | other
accountId: "4111********1111" # IBAN, last4-hash, wallet address, etc.
issuingCountry: USA
"3dsUsed": true
"2faUsed": false
memo: "optional memo"
idDoc:
number: "A12345678"
country: USA
idDocType: PASSPORT
device:
fingerprint: "abc123"
userAgent: "Mozilla/5.0 ..."
ipInfo: { ip: "1.2.3.4" }
# Counterparty (typical for finance + travelRule; same shape as applicant + a few extras)
counterparty:
externalUserId: "merchant-XYZ"
type: company
fullName: "Acme Inc."
registrationNumber: "12345678"
leiCode: "529900XXXX0000XXXX00"
residenceCountry: QAT
address: { country: QAT }
institution: # → institutionInfo
code: "ACME-CODE"
name: "Acme Bank"
internalId: "<VASP id from directory>"
ceo: # only for company counterparties (Travel Rule)
firstName: "Jane"
lastName: "Roe"
# user-platform-event only (when type=userPlatformEvent)
userPlatformEvent:
type: login # login | failedLogin | signup | passwordReset | twoFaReset | general
twoFaUsed: true
passwordHash: "..."
# Optional escape hatches
sourceKey: "<segregation key>"
props: # custom string-string map
customField: "value"
dailyOutLimit: "10000"
```
### Enums (validated upfront)
| Field | Allowed values |
|---|---|
| `type` (top-level) | `finance`, `travelRule`, `kyc`, `auditTrailEvent`, `userPlatformEvent`, `scheduledEvent`, `iGamingSession` |
| `direction` | `in`, `out` |
| `currencyType` | `crypto`, `fiat` |
| `applicant.type` / `counterparty.type` | `individual`, `company` |
| `applicant.nameType` | `aliasName`, `birthName`, `maidenName`, `legalName`, `shortName`, `tradingName`, `other` |
| `userPlatformEvent.type` | `login`, `failedLogin`, `signup`, `passwordReset`, `twoFaReset`, `general` |
`paymentMethod.type` is intentionally **not** enum-checked — the OpenAPI lists `KytTxnPaymentMethodType` (only `smartContract`, `bankCard`, `bankAccount`) but the docs and live data accept many more (`crypto`, `eWallet`, `unhostedWallet`, etc.). The builder forwards whatever the caller supplies.
## Outputs
On success, report all of:
- `txnId` (yours) **and** `id` (server-assigned, used in the dashboard link).
- Applicant `externalUserId`.
- `direction amount currency`, counterparty (if any).
- The response's `score` / `reviewAnswer` / `riskLabels` if returned.
- **Dashboard link**: `https://cockpit.sumsub.com/checkus/kyt/txns/<id>?clientId=<clientId>&sbx=true`. Render as a clickable markdown link. `<id>` is the **server-assigned identifier** (not the `txnId` you sent); both it and `clientId` are in the POST response body; `sbx=true` targets the Sandbox workspace.
On failure: HTTP status + Sumsub's `description`/`errorName`. Most likely 4xx cases:
- `409 Entity already exists` — `txnId` collision (use a fresh id or the bulk-import method to update).
- `400` — required field missing (typical: `info.amount`, `info.currencyCode`, `info.direction`, or `applicant.externalUserId`).
- `400` — `levelName` unknown (when using non-existing-applicant flow).
## Worked examples
- [`examples/fiat-out.json`](examples/fiat-out.json) — outbound EUR card payment to a foreign counterparty (the docs' canonical example).
- [`examples/crypto-in.json`](examples/crypto-in.json) — inbound ETH deposit with `cryptoParams.cryptoChain=ETH`, contract address, on-chain `paymentTxnId`.
- [`examples/travel-rule.json`](examples/travel-rule.json) — `type: travelRule` outbound crypto with `institution.internalId` (VASP id) on the counterparty.
- [`examples/login-event.json`](examples/login-event.json) — `type: userPlatformEvent` for a successful login with 2FA.
- [`examples/new-applicant.json`](examples/new-applicant.json) — non-existing-applicant flow: `_levelName` is set so the POST goes to `/-/kyt/txns/-/data` with `?levelName=...`.
## See also
- [references/transaction-schema.md](references/transaction-schema.md) — full `KytTxnData` schema, all sub-objects, all enums, the existing-vs-new-applicant routing, scoring response fields, common gotchas.
- [Sumsub docs — Submit transaction](https://docs.sumsub.com/reference/submit-transaction-for-existing-applicant.md)
- [Sumsub docs — Submit transaction for non-existing applicant](https://docs.sumsub.com/reference/submit-transaction-for-non-existing-applicant.md)
- [Sumsub docs — Submit transactions and review results](https://docs.sumsub.com/docs/submit-transactions-and-review-results.md)