AML Transaction Monitoring: 12-Step FastAPI Pipeline [2026]

AML transaction monitoring stopped being a back-office spreadsheet job the moment crypto-funded iGaming went mainstream. In 2026, a regulated operator has to inspect a deposit or withdrawal, attach FATF Recommendation 16 Travel Rule data, screen the counterparty, and score the transaction for laundering risk – all before the money settles. That is a real-time engineering problem, not a compliance memo.

This tutorial builds a production-shaped real-time AML transaction monitoring pipeline in Python 3.12 using FastAPI, Redis Streams, and the Sumsub Travel Rule and Transaction Monitoring (KYT) APIs. It is the asynchronous-Python counterpart to our Node.js and BullMQ companion build – same Sumsub contract, very different runtime. You will sign requests, model IVMS101.2023 with Pydantic, ingest transactions over HTTP, run a velocity-and-structuring rules engine, verify self-hosted wallet ownership, validate webhooks, and emit SAR-ready case files. Twelve steps, more than a dozen working code blocks, and the regulatory dates that make each line necessary.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Real-Time AML Transaction Monitoring Matters in 2026

Three regulatory clocks struck at once. The EU’s Transfer of Funds Regulation (TFR), Regulation (EU) 2023/1113, has applied the crypto Travel Rule directly in every member state since 30 December 2024. The same day, the Markets in Crypto-Assets Regulation (MiCA), Regulation (EU) 2023/1114, switched on the Crypto-Asset Service Provider (CASP) authorisation regime, with national grandfathering windows that can run as late as 1 July 2026. And the EU’s new Anti-Money Laundering Authority (AMLA), headquartered in Frankfurt, stood up in 2025 ahead of direct supervision of high-risk obliged entities later this decade.

For an iGaming operator that accepts crypto, the practical effect is blunt: every qualifying transfer must carry originator and beneficiary data, and that data has to “travel” to the counterparty VASP in real time. Miss it and you are not merely non-compliant – you are processing an unscreened value transfer. The same expectation now runs from the US, where FinCEN enforces the Bank Secrecy Act Travel Rule, to Australia, where AUSTRAC‘s reformed AML/CTF regime takes effect on 31 March 2026.

The Travel Rule in Plain Terms

FATF Recommendation 16 – the “Travel Rule” – requires that when a Virtual Asset Service Provider (VASP) sends a transfer, identifying information about the sender and recipient travels alongside it to the receiving VASP. It mirrors the decades-old wire-transfer rule for banks, ported onto blockchains. The data format the industry settled on is IVMS 101 (the interVASP Messaging Standard), and the current ratified revision is IVMS 101.2023, published via intervasp.org.

The €1,000 Threshold and the Zero-Threshold Information Rule

Two numbers govern the logic you will hard-code in Step 5. FATF sets a de minimis threshold of USD/EUR 1,000: above it, the receiving VASP must verify the accuracy of beneficiary information. Under the EU TFR, however, the information-collection requirement has no lower threshold – originator and beneficiary data must accompany every crypto transfer, and transfers to or from self-hosted (unhosted) wallets at or above EUR 1,000 trigger additional ownership-and-control verification.

Why Crypto iGaming Is a High-Risk Sector

Pseudonymous wallets, instant cross-border settlement, and high transaction velocity make crypto casinos and sportsbooks a textbook laundering vector. Red flags that your engine must surface include transfers from high-risk jurisdictions, freshly created wallets moving large sums, rapid deposit-then-withdraw “pass-through” behaviour, and amounts deliberately structured just under reporting thresholds. Building the detection logic yourself – rather than trusting a black box – is exactly why operators pair Sumsub with their own scoring layer.

Regime Instrument Key date Travel Rule threshold
EU TFR Regulation (EU) 2023/1113 Applies 30 Dec 2024 Info: zero; verify self-hosted ≥ €1,000
EU MiCA (CASP regime) Regulation (EU) 2023/1114 Applies 30 Dec 2024; transition to 1 Jul 2026 N/A (licensing)
FATF Rec. 16 Global standard In force USD/EUR 1,000 (verification)
UK Travel Rule MLR 2017 (amended) 1 Sep 2023 £/€1,000 (verification)
Australia (AUSTRAC) AML/CTF Act 2006 (amended 2024) New regime in force 31 Mar 2026 Info: zero; verify ≥ A$1,000
US (FinCEN) Bank Secrecy Act recordkeeping Current $3,000; proposal to cut to $250 (cross-border) not finalised USD 3,000 (proposed $250)

Architecture of Our FastAPI Monitoring Pipeline

The design goal is simple to state and hard to do: never block the deposit endpoint on a third-party API. We accept a transaction in milliseconds, persist intent, then fan the heavy work – Travel Rule exchange, KYT submission, rules scoring – onto an asynchronous worker reading from a durable log.

Components

  • FastAPI ingest service – validates the transaction and writes it to a Redis Stream. Returns 202 Accepted immediately.
  • Redis Streams event bus – a durable, replayable log with consumer groups, so a crashed worker resumes exactly where it left off.
  • Async monitoring worker – applies threshold logic, calls Sumsub, runs the velocity rules engine, and computes a risk score.
  • Sumsub Travel Rule + KYT – the regulated data exchange and transaction-monitoring backend.
  • Webhook receiver – a second FastAPI route that ingests Sumsub’s asynchronous verdicts and opens cases.

Data Flow

Deposit request → FastAPI /v1/transactions → XADD to aml:txns stream → worker XREADGROUP → threshold decision → Sumsub KYT submit → local rules engine → risk score → (async) Sumsub webhook → case file. Every hop is idempotent and every external call is retried with backoff. If you have already wired identity onboarding, this slots neatly behind your existing Sumsub KYC onboarding so the same applicant ID flows through verification and monitoring.

Where Sumsub Fits

Sumsub’s Travel Rule network connects 2,100+ VASPs and interoperates with the major messaging protocols, so you do not have to implement TRP, TRISA, or OpenVASP handshakes yourself. Your code constructs the IVMS101 payload; Sumsub routes it to the counterparty and returns the matched data plus an AML verdict. You keep the scoring and case-management logic in-house, which is where regulators expect institution-specific judgement.

Step-by-Step Prerequisites and Environment Setup

Pin your versions. AML code that “worked last quarter” is a audit finding waiting to happen. Everything below is current as of June 2026.

Component Version Purpose
Python 3.12.x Async runtime, native tomllib
FastAPI 0.115+ HTTP ingest + webhook routes
Uvicorn 0.34+ ASGI server
httpx 0.28+ Async Sumsub client
redis (redis-py) 5.2+ Streams event bus + rate windows
pydantic 2.10+ IVMS101 + payload validation
eth-account 0.13+ Self-hosted wallet signature checks

Sumsub Sandbox Credentials

Create a free sandbox account at the Sumsub dashboard and API documentation, then generate an App Token and Secret Key under Dev space → App Tokens. The sandbox base URL is https://api.sumsub.com (the same host as production; environments are scoped by token). Never commit the secret – load it from the environment.

Project Layout and requirements.txt

# requirements.txt – pin exact minors in CI
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
redis==5.2.1
pydantic==2.10.4
eth-account==0.13.4
python-dotenv==1.0.1
aml-pipeline/
├── app/
│   ├── main.py          # FastAPI app + ingest + webhook
│   ├── signing.py       # Sumsub HMAC signer
│   ├── ivms.py          # Pydantic IVMS101.2023 models
│   ├── sumsub.py        # async KYT client
│   ├── rules.py         # velocity + structuring engine
│   ├── worker.py        # Redis Streams consumer
│   └── settings.py      # env config
└── requirements.txt

Step 1: Sign Every Sumsub Request with HMAC-SHA256

Sumsub authenticates each call with three headers: X-App-Token (your app token), X-App-Access-Ts (a Unix timestamp in seconds), and X-App-Access-Sig (a hex HMAC-SHA256 signature). The signed message is the concatenation of timestamp + uppercase HTTP method + request path (including query) + raw body. Get the byte order wrong and every call returns 401.

# app/signing.py
import hashlib
import hmac
import time
from typing import Optional


def sign_request(
    secret: str,
    method: str,
    path: str,
    body: bytes = b"",
    ts: Optional[int] = None,
) -> dict[str, str]:
    """Return the three Sumsub auth headers for one request."""
    ts = ts or int(time.time())
    message = str(ts).encode() + method.upper().encode() + path.encode() + body
    signature = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return {
        "X-App-Access-Ts": str(ts),
        "X-App-Access-Sig": signature,
    }

Why the Raw Body Matters

The signature covers the exact bytes you transmit. If you let an HTTP library re-serialise your JSON (reordering keys, changing spacing) after you sign, the signature breaks. Always serialise once, sign those bytes, and send those same bytes. We enforce this in Step 6 by computing json.dumps(...).encode() a single time.

Clock Skew

Sumsub rejects timestamps that drift too far from its server clock. On long-running workers, generate the timestamp per request, never once at startup, and keep your host on NTP. A 401 with "Timestamp is out of range" is almost always a skewed VM clock, not a bad secret.

Step 2: Model the IVMS101.2023 Payload with Pydantic

IVMS 101 is fussy and deeply nested, which is exactly the kind of structure Pydantic v2 was built to tame. Modelling it as typed classes means a malformed Travel Rule message fails at construction time in your code, not at submission time in Sumsub’s validator.

# app/ivms.py
from pydantic import BaseModel, Field


class GeographicAddress(BaseModel):
    country: str = Field(..., min_length=2, max_length=2)  # ISO 3166-1 alpha-2
    town_name: str
    street_name: str | None = None
    post_code: str | None = None


class NaturalPerson(BaseModel):
    primary_name: str          # legalName per IVMS101.2023
    secondary_name: str | None = None
    date_of_birth: str | None = None      # YYYY-MM-DD
    country_of_residence: str | None = None
    address: GeographicAddress | None = None
    national_id: str | None = None


class Vasp(BaseModel):
    name: str
    lei: str | None = None     # Legal Entity Identifier, if known


class IVMS101Message(BaseModel):
    originator: NaturalPerson
    beneficiary: NaturalPerson
    originating_vasp: Vasp
    beneficiary_vasp: Vasp
    amount: float
    currency_code: str         # e.g. "USDT", "BTC"
    crypto_chain: str          # e.g. "ETH", "TRX", "BTC"
    txn_hash: str | None = None

Mapping to Sumsub’s Wire Format

Sumsub accepts a flattened representation of these fields inside its KYT transaction object. Keeping your own clean IVMS101Message class decoupled from Sumsub’s JSON shape means that when the standard advances to IVMS 101.2025, you change one adapter function instead of hunting field names across the codebase.

Validation You Get for Free

Because country is constrained to two characters, a typo like "USA" raises a ValidationError immediately. That single constraint catches one of the most common Travel Rule rejections – non-ISO country codes – before a single network call leaves your box.

Step 3: Build the FastAPI Transaction-Ingest Endpoint

The ingest route does the absolute minimum on the hot path: validate, assign an idempotency key, append to the stream, return. No Sumsub call happens here – that would couple your customer’s withdrawal latency to a third party.

# app/main.py (ingest)
import json
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from redis.asyncio import Redis
from pydantic import BaseModel

app = FastAPI(title="AML Monitoring Pipeline")
redis = Redis.from_url("redis://localhost:6379", decode_responses=True)

STREAM = "aml:txns"


class TxnIn(BaseModel):
    applicant_id: str
    direction: str            # "deposit" | "withdrawal"
    amount: float
    currency_code: str
    crypto_chain: str
    counterparty_wallet: str
    counterparty_vasp: str | None = None
    self_hosted: bool = False


@app.post("/v1/transactions", status_code=202)
async def ingest(txn: TxnIn):
    txn_id = f"tx_{uuid.uuid4().hex[:16]}"
    payload = txn.model_dump()
    payload["txn_id"] = txn_id
    await redis.xadd(STREAM, {"data": json.dumps(payload)})
    return JSONResponse({"txn_id": txn_id, "status": "accepted"}, status_code=202)

Expected output from a sandbox deposit:

$ curl -s -X POST localhost:8000/v1/transactions \
  -H 'Content-Type: application/json' \
  -d '{"applicant_id":"appl_123","direction":"withdrawal","amount":1850.0,
       "currency_code":"USDT","crypto_chain":"ETH",
       "counterparty_wallet":"0xAbc...","self_hosted":true}'

{"txn_id":"tx_9f1c2a7b4e0d5612","status":"accepted"}

Why 202, Not 200

HTTP 202 Accepted is the honest status: you have accepted the transaction for monitoring, but the AML verdict is not yet known. Your wallet service should treat 202 as “queued” and wait for the case decision (Step 12) before releasing funds on a high-risk withdrawal.

Step 4: Wire Redis Streams as a Durable Event Bus

A consumer group gives you at-least-once delivery and crash recovery. The worker claims an entry, processes it, and acknowledges it; if the worker dies mid-flight, the entry stays in the pending-entries list and is reclaimed.

# app/worker.py
import asyncio
import json
from redis.asyncio import Redis

redis = Redis.from_url("redis://localhost:6379", decode_responses=True)
STREAM, GROUP, CONSUMER = "aml:txns", "aml-workers", "worker-1"


async def ensure_group():
    try:
        await redis.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
    except Exception:
        pass  # group already exists


async def run():
    await ensure_group()
    while True:
        resp = await redis.xreadgroup(
            GROUP, CONSUMER, {STREAM: ">"}, count=10, block=5000
        )
        if not resp:
            continue
        for _stream, entries in resp:
            for entry_id, fields in entries:
                txn = json.loads(fields["data"])
                try:
                    await process(txn)            # Steps 5-8
                    await redis.xack(STREAM, GROUP, entry_id)
                except Exception as exc:
                    await handle_failure(txn, entry_id, exc)  # Step 11


if __name__ == "__main__":
    asyncio.run(run())

Scaling Consumers

Run N worker processes with distinct CONSUMER names in the same GROUP and Redis fans entries across them. Because each entry is acknowledged independently, you can scale horizontally without double-processing a transaction.

Step 5: Apply Travel Rule Threshold Logic

This is where the €1,000 number from Step 0 becomes code. The decision tree: always attach originator/beneficiary data (zero-threshold information rule), but only require ownership verification for self-hosted wallets at or above the threshold.

# app/rules.py (threshold)
THRESHOLD_EUR = 1000.0

# Toy FX table; in production pull live rates and snapshot them per-txn
FX_TO_EUR = {"USDT": 0.92, "USDC": 0.92, "BTC": 58000.0, "ETH": 3200.0}


def to_eur(amount: float, currency: str) -> float:
    return amount * FX_TO_EUR.get(currency.upper(), 1.0)


def travel_rule_decision(txn: dict) -> dict:
    eur = to_eur(txn["amount"], txn["currency_code"])
    requires_ivms = True  # zero-threshold info rule under EU TFR
    requires_ownership_proof = txn.get("self_hosted") and eur >= THRESHOLD_EUR
    return {
        "eur_value": round(eur, 2),
        "requires_ivms": requires_ivms,
        "requires_ownership_proof": requires_ownership_proof,
        "above_threshold": eur >= THRESHOLD_EUR,
    }

Snapshot Your FX Rate

Regulators care about the value at the time of transfer. Persist the FX rate and EUR value you computed alongside the transaction, so an auditor in 2027 sees the same number you acted on, not today’s price.

Self-Hosted vs Hosted Counterparties

If counterparty_vasp is set, Sumsub can route the IVMS101 message to that VASP. If the wallet is self-hosted, there is no counterparty VASP to message – instead you must prove the customer controls the address (Step 9). The same distinction drives how geolocation and device checks are layered on top; operators frequently combine this with a GeoComply geolocation setup to confirm the user is transacting from a permitted jurisdiction.

Step 6: Submit Transactions to the Sumsub KYT API

Now the async client. We build the body once, sign those exact bytes, and POST to the Sumsub transaction-monitoring endpoint /resources/kyt/txns/-/data. Reusing a single httpx.AsyncClient across calls keeps the connection pool warm.

# app/sumsub.py
import json
import httpx
from app.signing import sign_request
from app.settings import APP_TOKEN, SECRET, BASE_URL

_client = httpx.AsyncClient(base_url=BASE_URL, timeout=20.0)


async def submit_txn(txn: dict, ivms: dict, decision: dict) -> dict:
    path = "/resources/kyt/txns/-/data"
    body_obj = {
        "txnId": txn["txn_id"],
        "type": "finishedTransaction",
        "applicant": {"externalUserId": txn["applicant_id"]},
        "info": {
            "direction": txn["direction"],
            "amount": txn["amount"],
            "currencyCode": txn["currency_code"],
            "cryptoChain": txn["crypto_chain"],
            "paymentTxnId": txn.get("txn_hash"),
            "counterparty": {
                "walletAddress": txn["counterparty_wallet"],
                "vaspName": txn.get("counterparty_vasp"),
            },
            "travelRule": ivms if decision["requires_ivms"] else None,
        },
    }
    body = json.dumps(body_obj, separators=(",", ":")).encode()
    headers = {"X-App-Token": APP_TOKEN, "Content-Type": "application/json"}
    headers.update(sign_request(SECRET, "POST", path, body))
    resp = await _client.post(path, content=body, headers=headers)
    resp.raise_for_status()
    return resp.json()

Expected sandbox response (trimmed): For broader market context, see our coverage of best sweepstakes casino sites.

{
  "id": "664f...e21",
  "txnId": "tx_9f1c2a7b4e0d5612",
  "score": 18,
  "review": {"reviewResult": {"reviewAnswer": "GREEN"}},
  "travelRule": {"status": "pendingCounterparty", "vaspMatched": false}
}

Interpreting the Verdict

A reviewAnswer of GREEN means Sumsub’s checks passed; RED means it flagged the transaction. The numeric score is Sumsub’s own risk estimate. You will combine it with your in-house score in Step 8 rather than trusting either number alone.

Step 7: Build a Real-Time Rules Engine for Velocity and Structuring

This is the layer the explainer articles never show. Sumsub scores the transaction in isolation; you have to catch patterns across transactions – the same customer making ten €900 withdrawals in an hour to dodge the €1,000 threshold. A Redis sorted set keyed by applicant, scored by timestamp, gives you a sliding window in O(log n).

# app/rules.py (velocity + structuring)
import time

WINDOW_SECONDS = 3600          # 1 hour
STRUCTURING_BAND = (800, 1000) # just under the €1,000 line


async def velocity_check(redis, applicant_id: str, eur: float) -> dict:
    now = time.time()
    key = f"vel:{applicant_id}"
    await redis.zadd(key, {f"{now}:{eur}": now})
    await redis.zremrangebyscore(key, 0, now - WINDOW_SECONDS)
    await redis.expire(key, WINDOW_SECONDS)

    members = await redis.zrange(key, 0, -1)
    amounts = [float(m.split(":")[1]) for m in members]
    count = len(amounts)
    total = sum(amounts)
    near_threshold = sum(
        1 for a in amounts if STRUCTURING_BAND[0] <= a < STRUCTURING_BAND[1]
    )
    return {
        "window_count": count,
        "window_total_eur": round(total, 2),
        "structuring_hits": near_threshold,
        "velocity_breach": count >= 10 or total >= 9000,
        "structuring_suspected": near_threshold >= 3,
    }

Why Structuring Detection Is Non-Negotiable

“Smurfing” – splitting one large transfer into many sub-threshold ones – is the single most common typology in crypto iGaming. A standalone monitor sees ten compliant €900 transactions; the sliding window sees a €9,000 laundering attempt. Tuning STRUCTURING_BAND and the count trigger to your own customer base is where a compliance analyst earns their seat.

Keep Windows in Redis, Not Your Database

Velocity math runs on the hot path of every transaction. Pushing it into Postgres with COUNT(*) queries will not survive peak load on a busy Saturday-night sportsbook. Redis sorted sets keep the operation sub-millisecond.

Step 8: Score Transactions and Tame the False-Positive Problem

Industry-wide, AML transaction monitoring produces 90–95% false positives – the overwhelming majority of alerts are noise that still costs an analyst’s time. A weighted, explainable score lets you auto-clear the obvious and escalate only what matters.

# app/rules.py (scoring)
def composite_score(sumsub_score: int, decision: dict, velocity: dict) -> dict:
    score = sumsub_score
    reasons = []

    if decision["requires_ownership_proof"]:
        score += 10
        reasons.append("self_hosted_above_threshold")
    if velocity["velocity_breach"]:
        score += 30
        reasons.append("velocity_breach")
    if velocity["structuring_suspected"]:
        score += 40
        reasons.append("structuring_suspected")

    if score >= 70:
        tier = "ESCALATE"
    elif score >= 40:
        tier = "REVIEW"
    else:
        tier = "AUTO_CLEAR"

    return {"score": min(score, 100), "tier": tier, "reasons": reasons}

Make Every Score Explainable

Never emit a bare number. The reasons list is what an analyst – and later an AMLA examiner – needs to understand why a transaction escalated. Explainability is no longer optional under the EU’s incoming AML package; a model that cannot justify its output is a liability.

Tune With Real Outcomes

Feed back the disposition of every escalated case (true hit vs false positive) and adjust weights monthly. The goal is not zero alerts – it is a precision rate high enough that analysts trust the queue.

Step 9: Verify Self-Hosted Wallet Ownership (Proof of Control)

For self-hosted wallets above €1,000, the TFR wants evidence the customer actually controls the address. The robust method is cryptographic message signing: you issue a random nonce, the user signs it with their wallet’s private key, and you recover the signer address and compare. Sumsub offers this in its SDK, but here is the underlying check so you understand what you are trusting.

# app/wallet.py
import secrets
from eth_account import Account
from eth_account.messages import encode_defunct


def new_challenge(applicant_id: str) -> str:
    return f"AML-proof:{applicant_id}:{secrets.token_hex(16)}"


def verify_ownership(challenge: str, signature: str, claimed: str) -> bool:
    """Return True if `signature` over `challenge` was made by `claimed`."""
    message = encode_defunct(text=challenge)
    recovered = Account.recover_message(message, signature=signature)
    return recovered.lower() == claimed.lower()

Signature Method vs the Satoshi Test

The alternative is the Satoshi Test – asking the user to send a tiny, specified micro-amount from the wallet to prove control. It works on chains without easy off-chain signing but adds an on-chain fee and a settlement delay. Signature-based proof is instant and free, which is why Sumsub’s wallet verification defaults to it. Persist the challenge, signature, and recovered address as evidence.

Never Reuse a Nonce

A replayed signature proves nothing. Bind each challenge to the applicant and a single transaction, store it with a short TTL in Redis, and reject any signature for a challenge you have already consumed.

Step 10: Receive and Verify Sumsub Webhooks

Sumsub delivers asynchronous verdicts and Travel Rule status changes by webhook. It signs the payload with an HMAC-SHA256 digest in the x-payload-digest header, computed over the raw request body. You must verify against the unparsed bytes – re-serialising the JSON first will change the byte order and break the comparison.

# app/main.py (webhook)
import hashlib
import hmac
from fastapi import Request, HTTPException
from app.settings import WEBHOOK_SECRET


@app.post("/v1/webhooks/sumsub")
async def sumsub_webhook(request: Request):
    raw = await request.body()                      # raw bytes, do NOT re-encode
    sent = request.headers.get("x-payload-digest", "")
    expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sent, expected):     # constant-time compare
        raise HTTPException(status_code=401, detail="bad signature")

    event = await request.json()
    etype = event.get("type")
    if etype == "transaction.risk_flagged":
        await open_case(event)                       # Step 12
    elif etype == "transaction.updated":
        await update_case(event)
    return {"ok": True}

Always Use a Constant-Time Compare

hmac.compare_digest resists timing attacks; a naive == on the hex strings can leak information about the secret. This is a one-line change with a real security payoff.

Respond Fast, Process Later

Return 200 quickly and push heavy work back onto a stream. Sumsub retries on non-2xx responses, so a slow handler can cause duplicate deliveries – which is exactly why Step 11 exists. For broader market context, see our coverage of leading online casino sites.

Step 11: Idempotency, Retries, and a Dead-Letter Stream

At-least-once delivery means you will see duplicates: a retried webhook, a reclaimed stream entry, a client that POSTs twice. Idempotency keys make re-processing a no-op, and a dead-letter stream stops one poison message from blocking the queue forever.

# app/worker.py (resilience)
DLQ = "aml:txns:dead"
MAX_RETRIES = 5


async def already_done(redis, txn_id: str) -> bool:
    # SET NX returns True only the first time we see this id
    created = await redis.set(f"idem:{txn_id}", "1", nx=True, ex=86400)
    return not created


async def handle_failure(redis, txn: dict, entry_id: str, exc: Exception):
    key = f"retry:{txn['txn_id']}"
    attempts = await redis.incr(key)
    await redis.expire(key, 3600)
    if attempts >= MAX_RETRIES:
        await redis.xadd(DLQ, {"data": json.dumps(txn), "error": str(exc)})
        await redis.xack("aml:txns", "aml-workers", entry_id)
    # else: leave unacked so it is redelivered with backoff

Idempotency at the Edge and the Core

Guard both the ingest endpoint (dedupe on a client-supplied txn_id) and the worker (the SET NX check above). Defence in depth means a duplicate from either side is harmless.

Monitor the Dead-Letter Stream

A growing DLQ is a paging-worthy signal. Every entry there is a transaction that never received an AML verdict – a compliance gap, not just an engineering one. Alert on XLEN aml:txns:dead > 0.

Step 12: Generate SAR-Ready Case Files

When a transaction escalates, your system must assemble everything an investigator and a regulator will ask for: the IVMS101 data, the Sumsub verdict, your composite score with reasons, the velocity window, and the ownership proof. This is the artefact that becomes a Suspicious Activity Report (SAR) or, in the EU, a Suspicious Transaction Report (STR).

# app/cases.py
import json
import time


async def open_case(redis, txn: dict, sumsub: dict, score: dict,
                    velocity: dict, ownership: dict | None) -> str:
    case_id = f"case_{txn['txn_id']}"
    case = {
        "case_id": case_id,
        "opened_at": int(time.time()),
        "applicant_id": txn["applicant_id"],
        "transaction": txn,
        "sumsub_review": sumsub.get("review"),
        "sumsub_score": sumsub.get("score"),
        "composite_score": score["score"],
        "tier": score["tier"],
        "reasons": score["reasons"],
        "velocity_window": velocity,
        "ownership_proof": ownership,
        "status": "OPEN",
    }
    await redis.set(f"case:{case_id}", json.dumps(case))
    return case_id

Expected case file for our structuring example:

{
  "case_id": "case_tx_9f1c2a7b4e0d5612",
  "tier": "ESCALATE",
  "composite_score": 88,
  "reasons": ["structuring_suspected", "self_hosted_above_threshold"],
  "velocity_window": {"window_count": 11, "window_total_eur": 9460.0,
                      "structuring_hits": 4},
  "status": "OPEN"
}

Retention and Immutability

SAR-supporting records must typically be retained for five years. Write the finalised case to append-only, immutable storage (object storage with a legal hold or a WORM bucket), not just Redis – the cache is your working set, not your system of record.

Complete Project Example: A Crypto Casino Withdrawal Flow

Putting all twelve steps together, here is the lifecycle of a single high-risk withdrawal – a player cashing out 1,850 USDT to a self-hosted wallet.

  1. Player requests withdrawal; wallet service POSTs to /v1/transactions and receives 202 with txn_id.
  2. Ingest appends to aml:txns; the wallet service holds the payout in a PENDING_AML state.
  3. Worker reads the entry, computes EUR value (≈ €1,702), flags above_threshold and requires_ownership_proof.
  4. Worker requests a signing challenge; the player signs it in-wallet; verify_ownership returns True.
  5. Worker builds the IVMS101 message and submits to Sumsub KYT; Sumsub returns score: 18, GREEN.
  6. Velocity check finds 11 transfers in the hour, four in the €800–1,000 band → structuring_suspected.
  7. Composite score = 88 → tier ESCALATE; a case file opens automatically.
  8. Sumsub later fires a transaction.risk_flagged webhook; the verified handler enriches the same case.
  9. The payout stays frozen pending analyst disposition; an STR is drafted from the case file.

That end-to-end chain – accept fast, decide asynchronously, hold the money on risk – is the pattern every regulated operator converges on. If your platform also handles instant crypto cash-outs, the same case gate sits naturally in front of a Lightning Network payout endpoint, so no value leaves until the AML tier clears.

Beyond the Standard Coverage: What the Top AML Guides Skip

Search “AML transaction monitoring” and the first page is wall-to-wall explainers from IBM, Napier, LexisNexis, SAS, Quantexa, Feedzai, and Deloitte. They are competent at the what and the why – rules vs. machine learning, the alert lifecycle, the cost of false positives. Not one of them shows you how to ship it. Here is the gap inventory this tutorial fills.

No One Shows the IVMS101 Wire Format or Signing Code

The explainers mention “the Travel Rule” but never print an IVMS 101.2023 payload, never show HMAC request signing, and never explain why the signature must cover the raw body. A developer leaves those articles knowing a rule exists and having no idea how to satisfy it. Steps 1, 2, and 6 are the missing implementation.

Velocity and Structuring Detection Is Hand-Waved

“We detect structuring” appears on every vendor page; none show the sliding-window data structure that actually does it. Step 7’s Redis sorted-set window is the part nobody publishes because it is where the real engineering lives.

The Operational Backbone Is Invisible

Idempotency, dead-letter handling, webhook signature verification, clock-skew failures, FX snapshotting for audit – the unglamorous machinery that decides whether your pipeline survives contact with production – is entirely absent from the marketing-led content. Choosing the right vendor matters too, which is why a head-to-head KYC and AML vendor comparison is worth reading before you commit, but the integration discipline in Steps 10–12 is what keeps you compliant after you have chosen.

Travel Rule Protocol Support and the VASP Network

You do not implement Travel Rule messaging protocols yourself when you use Sumsub – but you should understand what it is brokering on your behalf, because counterparty coverage directly affects whether a transfer can complete.

Protocol Type Notes
Sumsub protocol Proprietary API Direct data delivery across the 2,100+ VASP network
TRP (Travel Rule Protocol) Open standard REST-based, bank-and-VASP friendly
TRISA Open standard Encrypted peer-to-peer with asymmetric keys
OpenVASP Open standard Open-source reference implementation of IVMS 101
Notabene Proprietary network Compliance-automation focus; interoperates broadly

When the Counterparty Is Unreachable

If the beneficiary VASP is not on any reachable network, the Travel Rule data cannot travel. Your decision tree needs a branch for “counterparty unreachable” – typically hold the transfer, attempt the data exchange, and apply enhanced due diligence rather than silently letting it through.

5 Common Pitfalls (and How to Avoid Them)

  • Signing serialised-then-reserialised JSON. The most common 401. Serialise once, sign those bytes, send those bytes – in both request signing (Step 1) and webhook verification (Step 10).
  • Blocking the deposit endpoint on Sumsub. Coupling customer latency to a third-party API is both a UX disaster and a reliability one. Always go through the stream.
  • Trusting the per-transaction score alone. Sumsub scores in isolation; structuring only appears across transactions. Without Step 7 you will pass a €9,000 laundering attempt as ten clean transfers.
  • Computing FX at audit time. Snapshot the rate and EUR value at the moment of transfer. Recomputing later produces numbers that do not match your decisions.
  • Treating Redis as the system of record. Redis is your hot path; SAR evidence belongs in immutable, retained storage for five years.

Troubleshooting: 8 Failure Modes and Fixes

Symptom Likely cause Fix
401 invalid signature Body re-serialised after signing Sign the exact bytes you send; use content=body
401 Timestamp out of range VM clock skew NTP-sync the host; generate ts per request
Webhook 401 from your service Verifying parsed JSON, not raw body HMAC the raw bytes from await request.body()
Duplicate cases Webhook retries + no idempotency Add SET NX idempotency keys (Step 11)
Stream backlog grows Too few consumers / slow Sumsub calls Scale consumers; raise httpx pool; check timeouts
ValidationError on country Non-ISO country code (e.g. “USA”) Map to ISO 3166-1 alpha-2 before constructing IVMS
Ownership proof fails Wrong message encoding / checksum address Use encode_defunct; lowercase-compare recovered address
travelRule: pendingCounterparty stuck Beneficiary VASP unreachable Hold transfer, apply EDD, escalate per policy

Advanced Tips for High-Scale iGaming Deployments

Once the happy path works, scale and audit-readiness become the hard problems.

Shard the Stream by Applicant

A single Redis Stream becomes a hot key at very high throughput. Hash the applicant ID into one of N streams (aml:txns:{0..15}) and pin a consumer group per shard. Velocity windows stay correct because all of one customer’s transactions land on the same shard.

Snapshot Everything for AMLA

With AMLA moving toward direct supervision of high-risk entities, “show me your decision and the inputs” will be a routine request. Persist, per transaction: the FX snapshot, the rules version, the model weights, the Sumsub response, and the analyst disposition. Version your rule sets so you can reconstruct exactly which logic ran on any historical date.

Tune Precision, Not Just Recall

With baseline false-positive rates at 90–95%, the highest-leverage work is raising precision so analysts trust the queue. Run shadow rules in parallel against production traffic, measure their hit rate against confirmed dispositions, and promote only the ones that improve precision without dropping true positives.

Test Against the Sumsub Sandbox in CI

Wire a smoke test that submits a known sandbox transaction and asserts on the verdict shape on every deploy. AML code that silently stops working is far worse than a failing build.

Frequently Asked Questions

Is this Python pipeline a replacement for the Node.js version?

No – it is a parallel implementation of the same Sumsub contract. The Node.js and BullMQ companion targets teams on a JavaScript stack; this FastAPI and Redis Streams build targets Python shops. Both submit to the same /resources/kyt/txns/-/data endpoint with identical HMAC signing.

What is the difference between the Travel Rule threshold and the information rule?

Under the EU TFR there is no threshold for collecting and transmitting originator/beneficiary information – it applies to every transfer. The €1,000 figure is the point at which you must additionally verify the data and, for self-hosted wallets, prove ownership and control.

Does Sumsub publish transaction-monitoring pricing?

Sumsub does not list public pricing for transaction monitoring or the Travel Rule; it is quote-based and depends on volume and modules. Identity-verification pricing is similarly negotiated, so budget from a sales quote, not a public rate card.

Which IVMS standard version should I target in 2026?

IVMS 101.2023 is the current ratified revision and what Sumsub’s network expects. Keep your Pydantic models decoupled from the wire format so a future revision is a one-adapter change.

How do I handle transfers to wallets on unsupported chains?

If neither the chain nor the counterparty VASP is reachable for Travel Rule exchange, treat it as enhanced due diligence: collect what you can, hold the transfer, and escalate per your risk policy rather than processing blind.

Why are false-positive rates so high in AML transaction monitoring?

Rules tuned for recall (catching everything) inevitably over-fire. The 90–95% industry figure reflects conservative thresholds plus thin context. Cross-transaction features like velocity and structuring, plus explainable scoring, are the practical levers for raising precision.

Do I still need my own rules engine if Sumsub scores transactions?

Yes. Sumsub scores each transaction; regulators expect institution-specific monitoring that reflects your customer base and product risk. The velocity and structuring logic in Step 7 is precisely the judgement a supervisor expects you to own.

How long must I retain case and SAR data?

Retention is typically five years from the transaction or relationship end, depending on jurisdiction. Store finalised cases in immutable, append-only storage with a legal hold, and keep the rule version and inputs alongside each decision for audit reconstruction.

Casey Mitchell

Casey Mitchell

Crypto Casino Editor

Casey Mitchell tracks crypto casinos, on-chain gaming, provably fair RNG, and the regulatory gray zones around US-facing crypto gambling platforms. Reviews wallets, deposit flows, and KYC policies across BTC, ETH, and stablecoin operators.

View all articles