If your iGaming platform settles crypto deposits and withdrawals, the Sumsub Travel Rule stack is no longer optional plumbing – it is the difference between processing a payout in under two seconds and freezing it inside a regulator-mandated review queue. Since the EU Transfer of Funds Regulation (TFR) went fully applicable on 30 December 2024 with a zero-threshold data requirement, every single crypto-asset transfer a licensed operator touches must carry verified originator and beneficiary information, screened in real time against sanctions, behavioral typologies, and counterparty-VASP risk.
This tutorial builds a production-grade, event-driven real-time AML transaction monitoring pipeline on top of the Sumsub Transaction Monitoring and Travel Rule APIs. Instead of the synchronous request/response pattern most integration guides stop at, you will wire a queue-backed architecture in TypeScript: a Next.js 16 route handler ingests each transaction, a BullMQ worker scores it against Sumsub rule bundles, an IVMS101 payload is exchanged with the counterparty VASP, and HMAC-verified webhooks drive automatic Suspicious Activity Report (SAR) case creation. Expect roughly 40 minutes to a working sandbox and 14 concrete steps.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What a Real-Time AML Pipeline with Sumsub Travel Rule Looks Like in 2026
The regulatory ground shifted hard in 2025–2026, and the architecture has to follow. Three forces now define how an iGaming operator monitors crypto flows: the EU’s zero-threshold TFR, the FATF Travel Rule’s continued $1,000/€1,000 de minimis line in non-EU markets, and the new EU Anti-Money Laundering Authority (AMLA), which took up operations in Frankfurt’s Messeturm on 1 July 2025 and will begin direct supervision of high-risk crypto-asset service providers (CASPs) from 2028.
The practical takeaway is that “monitoring” can no longer mean a nightly batch job. Sumsub’s own guidance distinguishes real-time evaluation – scoring each transaction as it occurs – from batch review of daily groups, and recommends real-time for high-risk activity and batch only for lower-risk flows. For a regulated sportsbook or crypto casino, deposits and withdrawals are high-risk by definition, so they belong in the real-time lane.
Why event-driven, not synchronous
A naïve integration calls the Sumsub API inline during the deposit request and blocks the player until a verdict comes back. That couples your payment latency to a third party and collapses under spikes. The pattern in this guide decouples ingestion from evaluation: the HTTP handler does the minimum – validate, persist, enqueue – and returns immediately, while a worker pool performs the screening, Travel Rule exchange, and case logic asynchronously. This is what lets you hold sub-second p95 ingestion latency even when Sumsub’s scoring round-trip varies.
The six signal categories you are scoring against
Sumsub’s 2026 monitoring engine evaluates each transaction across six signal categories: transaction amount and velocity, geolocation, device type, counterparty risk, wallet history, and behavioral typologies. Your pipeline’s job is to feed those signals cleanly, attach Travel Rule data where required, and act on the resulting alerts. Below is the regulatory landscape your rules must satisfy.
| Regime | Travel Rule trigger | Key 2025–2026 date | Threshold |
|---|---|---|---|
| EU TFR (Reg. 2023/1113) | All CASP-to-CASP transfers | Fully applicable 30 Dec 2024 | Zero – full data on every transfer |
| FATF Recommendation 16 | VASP-to-VASP value transfers | 85/117 jurisdictions adopted (73%) | $1,000 / €1,000 de minimis |
| EU AMLR (Reg. 2024/1624) | Harmonised CDD + monitoring | Applies 10 Jul 2027 | Risk-based, EUR 10,000 cash cap |
| AUSTRAC (Australia) | VA transfer obligations | VASP obligations live 1 Jul 2026 | AUD 1,000 ($1,000 equiv.) |
| UK MLRs 2017 (amended) | Cryptoasset transfers | Draft SI published 26 Mar 2026 | £1,000 / EUR 1,000 |
Architecture: An Event-Driven Monitoring Pipeline
Before any code, pin the shape of the system. The pipeline has five logical stages, each isolated so it can fail and retry independently. This isolation is the single most important design choice – it is also what almost every vendor marketing page and most integration tutorials skip entirely.
The five stages
- Ingest – a Next.js route handler accepts a transaction event from your wallet/ledger service, validates it with Zod, persists an idempotency record, and enqueues a job.
- Enrich – a worker resolves the counterparty (hosted VASP vs. unhosted wallet), determines whether the Travel Rule applies, and constructs an IVMS101 payload.
- Score – the same job submits the transaction to Sumsub’s monitoring engine, which evaluates it against your configured rule bundles and returns a verdict.
- Exchange – if the Travel Rule applies, the IVMS101 data is sent to (or requested from) the counterparty VASP over one of Sumsub’s supported protocols.
- Act – webhook callbacks update transaction state; alerts above your risk threshold spawn a SAR-ready case for a compliance analyst.
Components and data stores
You need three runtime components: a Next.js app (HTTP ingress + webhook receiver), a Redis instance (BullMQ queue + idempotency keys), and a Postgres database (transaction ledger + case records). In production you would run the worker as a separate process from the web tier so a scoring backlog never starves your HTTP threads. The Sumsub sandbox handles all external calls during development, so you can build the whole thing without a single real crypto transaction.
Prerequisites and Environment Setup
Lock your versions. The code in this guide is written and tested against the following stack as of June 2026. Mismatched major versions of BullMQ or Zod will break the type signatures shown later.
| Dependency | Version | Role |
|---|---|---|
| Node.js | 22.x LTS | Runtime (native fetch, stable crypto) |
| Next.js | 16.2.x | Route handlers for ingress + webhooks |
| TypeScript | 5.7.x | Type-safe IVMS101 + API models |
| BullMQ | 5.x | Redis-backed job queue + retries |
| ioredis | 5.x | Redis client for BullMQ + idempotency |
| Zod | 3.24.x | Runtime payload validation |
| Redis | 7.4.x | Queue + idempotency store |
| PostgreSQL | 16.x | Transaction ledger + SAR cases |
Create the project and install dependencies:
npx create-next-app@latest aml-pipeline --typescript --app --no-tailwind
cd aml-pipeline
npm install bullmq ioredis zod
npm install -D vitest @types/node
# .env.local – never commit this file
SUMSUB_APP_TOKEN=sbx:your-app-token
SUMSUB_SECRET_KEY=your-secret-key
SUMSUB_BASE_URL=https://api.sumsub.com
SUMSUB_WEBHOOK_SECRET=your-webhook-secret
REDIS_URL=redis://127.0.0.1:6379
OPERATOR_VASP_ID=did:vasp:your-operator-id
You obtain the SUMSUB_APP_TOKEN and SUMSUB_SECRET_KEY from the Sumsub dashboard under Dev Space → App Tokens. Sandbox tokens are prefixed sbx:; the same code path serves production once you swap the prefix. Keep the secret key server-side only – it signs every request and must never reach a browser bundle.
Step 1: Sign Every Request to the Sumsub API
Sumsub authenticates each call with an HMAC-SHA256 signature over the request timestamp, method, path, and body. Get this wrong and every call returns 401. Build a single signed-client helper so the signing logic lives in exactly one place.
// lib/sumsub.ts
import { createHmac } from "node:crypto";
const APP_TOKEN = process.env.SUMSUB_APP_TOKEN!;
const SECRET = process.env.SUMSUB_SECRET_KEY!;
const BASE = process.env.SUMSUB_BASE_URL!;
export async function sumsub(
method: "GET" | "POST" | "PATCH",
path: string,
body?: unknown
): Promise<Response> {
const ts = Math.floor(Date.now() / 1000).toString();
const payload = body ? JSON.stringify(body) : "";
// Signature = HMAC-SHA256( ts + METHOD + path + body )
const sig = createHmac("sha256", SECRET)
.update(ts + method + path + payload)
.digest("hex");
return fetch(BASE + path, {
method,
headers: {
"X-App-Token": APP_TOKEN,
"X-App-Access-Sig": sig,
"X-App-Access-Ts": ts,
"Content-Type": "application/json",
},
body: payload || undefined,
});
}
Why the timestamp matters
Sumsub rejects requests whose X-App-Access-Ts drifts more than a few minutes from server time, which defeats replay attacks. If your container clock is unsynced you will see intermittent 401s that look random. Run NTP in production and never cache a timestamp across retries – regenerate the signature on every attempt.
Step 2: Create an Applicant and Attach a Crypto Wallet
Every transaction must map to a verified applicant – the player whose identity you already ran through onboarding. If you have not built that layer yet, our walkthrough of a production Sumsub KYC API setup for iGaming covers applicant creation and document checks end to end; this pipeline assumes a verified applicantId exists. Here we register the player’s source-of-funds wallet so the monitoring engine can build wallet history.
// lib/applicant.ts
import { sumsub } from "./sumsub";
export async function attachWallet(applicantId: string, address: string, chain: string) {
const res = await sumsub(
"POST",
`/resources/applicants/${applicantId}/addresses`,
{ address, network: chain, tags: ["source-of-funds"] }
);
if (!res.ok) throw new Error(`attachWallet failed: ${res.status}`);
return res.json();
}
Linking onboarding to monitoring
The applicant’s KYC verification level and risk tier flow straight into transaction scoring. A player who cleared enhanced due diligence will trip fewer velocity rules than a freshly registered account, because Sumsub segments customers by risk tier before applying rules. Keep your onboarding risk labels in sync, or your false-positive rate balloons.
Step 3: Build a Transaction-Ingest Route Handler
Now the ingress. This Next.js route handler is the front door for every deposit and withdrawal event your ledger emits. It does four things and nothing more: validate, deduplicate, persist, enqueue. All heavy lifting is deferred to the worker.
// app/api/transactions/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { txQueue } from "@/lib/queue";
import { redis } from "@/lib/redis";
const TxnSchema = z.object({
txId: z.string().min(8),
applicantId: z.string(),
direction: z.enum(["deposit", "withdrawal"]),
amount: z.number().positive(),
currency: z.string(), // BTC, ETH, USDT...
counterpartyAddress: z.string(),
counterpartyVaspId: z.string().nullable(),
});
export async function POST(req: NextRequest) {
const parsed = TxnSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues }, { status: 422 });
}
const tx = parsed.data;
// Idempotency: SET NX returns null if the key already exists
const fresh = await redis.set(`idemp:${tx.txId}`, "1", "EX", 86400, "NX");
if (!fresh) {
return NextResponse.json({ status: "duplicate", txId: tx.txId }, { status: 200 });
}
await txQueue.add("evaluate", tx, {
jobId: tx.txId, // dedupe at the queue layer too
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
});
return NextResponse.json({ status: "accepted", txId: tx.txId }, { status: 202 });
}
Expected output
A well-formed POST returns 202 Accepted in single-digit milliseconds – the worker has not run yet. A replayed txId returns 200 with status: "duplicate", so your ledger can safely retry network failures without double-screening. A malformed body returns 422 with the exact Zod issues.
Step 4: Wire the BullMQ Queue and Worker Skeleton
The queue is the backbone. BullMQ gives you per-job retries, exponential backoff, and a dead-letter pattern out of the box. Define the queue once and share it between the route handler and the worker.
// lib/queue.ts
import { Queue } from "bullmq";
import { redis } from "./redis";
export const txQueue = new Queue("aml-transactions", { connection: redis });
// lib/redis.ts
import IORedis from "ioredis";
export const redis = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null, // required by BullMQ
});
// worker.ts – run as a SEPARATE process: `node --import tsx worker.ts`
import { Worker } from "bullmq";
import { redis } from "./lib/redis";
import { evaluateTransaction } from "./lib/evaluate";
new Worker("aml-transactions", async (job) => {
return evaluateTransaction(job.data);
}, { connection: redis, concurrency: 16 });
Concurrency and isolation
Setting concurrency: 16 lets one worker process handle sixteen evaluations in flight. Because the worker runs separately from the Next.js web tier, a Sumsub latency spike queues jobs rather than blocking deposits. This separation is precisely what marketing pages mean by “scalable” but never show you how to build.
Step 5: Construct an IVMS101 Travel Rule Payload
The interVASP Messaging Standard (IVMS101) is the industry-standard data format every Travel Rule protocol speaks, and Sumsub supports it natively. Model it as a typed structure so you cannot ship a malformed payload. Below is a pragmatic subset covering a natural-person originator and beneficiary.
// lib/ivms101.ts
export interface IVMS101Person {
primaryIdentifier: string; // family name
secondaryIdentifier: string; // given name
nameType: "LEGL"; // legal name
countryOfResidence: string; // ISO 3166-1 alpha-2
nationalId?: string;
dateOfBirth?: string; // YYYY-MM-DD
}
export interface IVMS101Message {
originator: IVMS101Person & { accountNumber: string };
beneficiary: IVMS101Person & { accountNumber: string };
originatingVasp: { vaspId: string; name: string };
beneficiaryVasp: { vaspId: string; name: string } | null;
}
export function buildIVMS101(params: {
player: IVMS101Person;
playerWallet: string;
counterpartyName: string;
counterpartyWallet: string;
counterpartyVaspId: string | null;
operatorVaspId: string;
operatorName: string;
}): IVMS101Message {
return {
originator: { ...params.player, accountNumber: params.playerWallet },
beneficiary: {
primaryIdentifier: params.counterpartyName,
secondaryIdentifier: "",
nameType: "LEGL",
countryOfResidence: "XX",
accountNumber: params.counterpartyWallet,
},
originatingVasp: { vaspId: params.operatorVaspId, name: params.operatorName },
beneficiaryVasp: params.counterpartyVaspId
? { vaspId: params.counterpartyVaspId, name: params.counterpartyName }
: null,
};
}
Originator vs. beneficiary by direction
On a withdrawal, your operator is the originating VASP and the player is the originator; on a deposit, you are the beneficiary VASP. Swap the roles based on tx.direction before building the message, or you will send the counterparty the wrong party’s data – a reportable data-quality failure under the EBA’s Travel Rule guidelines.
Step 6: Resolve the Counterparty and Apply the Threshold Logic
Not every transfer triggers a Travel Rule exchange, and the rules differ sharply between the EU and FATF jurisdictions. Encode the decision explicitly so an auditor can read it.
// lib/travel-rule-policy.ts
type Jurisdiction = "EU" | "FATF";
export function travelRuleApplies(
amountUsd: number,
counterpartyVaspId: string | null,
jurisdiction: Jurisdiction
): { exchange: boolean; reason: string } {
// EU TFR: zero threshold for CASP-to-CASP transfers
if (jurisdiction === "EU" && counterpartyVaspId) {
return { exchange: true, reason: "EU TFR zero-threshold CASP-to-CASP" };
}
// Unhosted wallet: verify control above EUR 1,000 (no peer to message)
if (!counterpartyVaspId) {
return {
exchange: false,
reason: amountUsd >= 1000 ? "unhosted >= 1000: verify control" : "unhosted below threshold",
};
}
// FATF de minimis: $1,000 / EUR 1,000
return {
exchange: amountUsd >= 1000,
reason: amountUsd >= 1000 ? "FATF de minimis met" : "below FATF de minimis",
};
}
Hosted vs. unhosted wallets
If counterpartyVaspId is null, the funds are moving to or from an unhosted (self-custody) wallet – there is no peer VASP to message. Under the EU TFR you must still collect originator and beneficiary data and, above €1,000, verify that your customer controls the wallet. That control check (a micro-deposit or signed message) is a separate workflow from the peer data exchange, and conflating the two is one of the most common compliance gaps.
Step 7: Submit the Transaction and Exchange Travel Rule Data
With policy decided, submit the transaction to Sumsub’s monitoring engine and, where required, attach the IVMS101 message for protocol exchange. Sumsub routes the exchange over whichever protocol the counterparty supports – TRP, GTR, CODE, its own protocol, or the email fallback.
// lib/submit.ts
import { sumsub } from "./sumsub";
import { IVMS101Message } from "./ivms101";
export async function submitTransaction(tx: {
txId: string; applicantId: string; direction: string;
amount: number; currency: string; counterpartyAddress: string;
}) {
const res = await sumsub("POST",
`/resources/applicants/${tx.applicantId}/kyt/txns/-/data`, {
txnId: tx.txId,
type: tx.direction === "deposit" ? "in" : "out",
info: {
amount: tx.amount, currencyCode: tx.currency,
counterparty: { walletAddress: tx.counterpartyAddress },
transactionDate: new Date().toISOString(),
},
});
if (!res.ok) throw new Error(`submit failed: ${res.status}`);
return res.json(); // { reviewResult, scoringResult, riskScore }
}
export async function exchangeTravelRule(txId: string, msg: IVMS101Message) {
const res = await sumsub("POST", `/resources/kyt/txns/${txId}/travelRuleData`, {
ivms101: msg,
});
if (!res.ok) throw new Error(`travel-rule exchange failed: ${res.status}`);
return res.json(); // { protocol, exchangeStatus, beneficiaryVaspName }
}
The submit call returns a scoring result synchronously for fast rules, but the leading verdict and any Travel Rule exchange status arrive later by webhook – which is exactly why the architecture is event-driven. Treat the inline response as provisional. For broader market context, see our coverage of best sweepstakes casino sites.
Step 8: Apply AML Rule Bundles in the Scoring Worker
Sumsub ships configurable rule bundles for “every scenario,” and the monitoring engine evaluates them server-side. Your worker’s job is to assemble the evaluation, read the verdict, and branch. This is where the whole pipeline comes together.
// lib/evaluate.ts
import { submitTransaction, exchangeTravelRule } from "./submit";
import { buildIVMS101 } from "./ivms101";
import { travelRuleApplies } from "./travel-rule-policy";
import { openCase } from "./cases";
import { getApplicantPerson } from "./applicant";
export async function evaluateTransaction(tx: any) {
const scoring = await submitTransaction(tx);
const risk = scoring.scoringResult?.score ?? 0;
const policy = travelRuleApplies(tx.amount, tx.counterpartyVaspId, "EU");
if (policy.exchange) {
const person = await getApplicantPerson(tx.applicantId);
const msg = buildIVMS101({
player: person, playerWallet: tx.counterpartyAddress,
counterpartyName: "counterparty", counterpartyWallet: tx.counterpartyAddress,
counterpartyVaspId: tx.counterpartyVaspId,
operatorVaspId: process.env.OPERATOR_VASP_ID!, operatorName: "ACME Casino Ltd",
});
await exchangeTravelRule(tx.txId, msg);
}
// Branch on the AML verdict
if (scoring.reviewResult?.reviewAnswer === "RED" || risk >= 70) {
await openCase(tx, scoring, policy.reason);
return { txId: tx.txId, action: "HOLD", risk };
}
return { txId: tx.txId, action: "ALLOW", risk };
}
Rule tuning to cut false positives
Sumsub recommends segmenting customers by risk tier before applying rules and tuning velocity rules to reflect seasonal business cycles – a casino sees legitimate deposit spikes on major sporting weekends. Start strict, watch the alert-to-SAR ratio, and relax thresholds where the analyst dispositions are overwhelmingly “no action.” For operators also juggling crypto rails, our comparison of the best iGaming crypto payment processors shows how settlement choice changes which velocity patterns you will see.
Step 9: Compute a Transaction Risk-Score Overlay
Sumsub returns a base score, but most operators layer their own overlay to encode house policy – for example, weighting first withdrawals after a large deposit, or flagging chain-hopping between mixers. Keep the overlay deterministic and logged so every hold is explainable to a regulator.
// lib/overlay.ts
export function riskOverlay(base: number, tx: any, walletHistory: {
knownMixer: boolean; firstSeenDays: number; sanctionsHit: boolean;
}): { score: number; factors: string[] } {
let score = base;
const factors: string[] = [];
if (walletHistory.sanctionsHit) { score = 100; factors.push("sanctions:+absolute"); }
if (walletHistory.knownMixer) { score += 25; factors.push("mixer:+25"); }
if (walletHistory.firstSeenDays < 7 && tx.amount > 5000) {
score += 15; factors.push("fresh-wallet-large:+15");
}
if (tx.direction === "withdrawal" && tx.amount > 10000) {
score += 10; factors.push("large-withdrawal:+10");
}
return { score: Math.min(score, 100), factors };
}
Behavioral typologies that matter for crypto
Sumsub’s typology library flags layering, chain-hopping, mixing services, and high-velocity micro-transactions structured to stay below thresholds. The last pattern – “smurfing” deposits at €990 to dodge a €1,000 line – is precisely why the EU’s zero-threshold rule exists, and why your overlay should treat clusters of just-below-threshold transfers as a single aggregated event.
Step 10: Receive Webhook Alerts and Verify the HMAC Signature
The authoritative verdict arrives by webhook. Never trust an unsigned webhook – Sumsub signs each payload, and you must verify it against the raw request body before acting. Parsing the JSON first and re-serializing it will change byte order and break the signature, so read the raw body.
// app/api/webhooks/sumsub/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
import { txQueue } from "@/lib/queue";
const SECRET = process.env.SUMSUB_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const raw = await req.text(); // RAW body, not req.json()
const sigHeader = req.headers.get("x-payload-digest") ?? "";
const expected = createHmac("sha256", SECRET).update(raw).digest("hex");
const a = Buffer.from(sigHeader);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return NextResponse.json({ error: "bad signature" }, { status: 401 });
}
const event = JSON.parse(raw);
// transactionEvaluated | travelRuleStatusChanged
if (event.type === "transactionEvaluated") {
await txQueue.add("reconcile", event, { jobId: `wh:${event.txnId}:${event.ts}` });
}
return NextResponse.json({ ok: true }, { status: 200 });
}
Always return 200 fast
Sumsub retries webhooks that do not get a prompt 200, so do the minimum inline – verify, enqueue, acknowledge – and let a worker do the reconciliation. If you run heavy logic in the handler and time out, you will get duplicate deliveries, which is exactly why the enqueue uses a deterministic jobId built from the event timestamp.
Step 11: Enforce Idempotency, Retries, and a Dead-Letter Path
Real-time AML at scale is a distributed-systems problem before it is a compliance problem. Three guarantees keep it honest: every transaction is screened exactly once, transient failures retry automatically, and permanent failures land somewhere a human will see them.
// worker.ts – failure handling
import { Worker } from "bullmq";
import { redis } from "./lib/redis";
import { evaluateTransaction } from "./lib/evaluate";
import { deadLetter } from "./lib/cases";
const worker = new Worker("aml-transactions", async (job) => {
if (job.name === "evaluate") return evaluateTransaction(job.data);
}, { connection: redis, concurrency: 16 });
worker.on("failed", async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
// Exhausted retries: park for manual review, NEVER silently drop
await deadLetter(job.data, err.message);
}
});
Why dropping a failed screen is a regulatory event
If a transaction fails screening five times and you let it through unscreened, you have processed an unmonitored transfer – a direct breach of the risk-based monitoring obligation under FATF Recommendation 1 and the EU AMLR. The dead-letter path converts a silent failure into a visible case, which is the difference between a documented control gap and an undocumented one. Auditors care enormously about that distinction.
Step 12: Auto-Generate SAR-Ready Case Reports
When a rule matches or your overlay crosses the threshold, Sumsub’s workflow generates an alert for a compliance analyst, who either closes it or escalates to a Suspicious Activity Report filed with the relevant Financial Intelligence Unit. Automate the case scaffolding so the analyst starts with a complete dossier, not a blank form.
// lib/cases.ts
import { db } from "./db";
export async function openCase(tx: any, scoring: any, trReason: string) {
await db.case.create({
data: {
txId: tx.txId,
applicantId: tx.applicantId,
status: "OPEN",
riskScore: scoring.scoringResult?.score ?? 0,
matchedRules: JSON.stringify(scoring.scoringResult?.matchedRules ?? []),
travelRuleReason: trReason,
snapshot: JSON.stringify({ tx, scoring }), // immutable evidence
openedAt: new Date().toISOString(),
},
});
}
export async function deadLetter(tx: any, error: string) {
await db.case.create({
data: { txId: tx.txId, applicantId: tx.applicantId, status: "DLQ",
riskScore: 0, travelRuleReason: `screen-failed: ${error}`,
snapshot: JSON.stringify({ tx }), openedAt: new Date().toISOString() },
});
}
The immutable snapshot
Persist a frozen snapshot of the transaction and the scoring result at case-open time. Wallet balances and risk scores change; your SAR must reflect what was known when the decision was made. An immutable evidence record is what turns “we think we screened it” into “here is the exact data and verdict, time-stamped.” That audit trail is non-negotiable under AMLA’s expectation of documented, reproducible decisions.
Step 13: Run End-to-End Tests in the Sumsub Sandbox
The sandbox lets you exercise the entire pipeline without real money or a real counterparty. Seed deterministic scenarios: a clean deposit, a sanctions hit, an unhosted-wallet withdrawal above €1,000, and a structured-deposit cluster. Assert on the action, not the wording of the verdict.
// tests/pipeline.test.ts
import { describe, it, expect } from "vitest";
import { evaluateTransaction } from "../lib/evaluate";
describe("AML pipeline", () => {
it("holds a high-risk withdrawal to an unhosted wallet", async () => {
const result = await evaluateTransaction({
txId: "sbx-test-001", applicantId: "sbx-applicant-1",
direction: "withdrawal", amount: 12000, currency: "USDT",
counterpartyAddress: "TXmixerKnownBad000000000000000000",
counterpartyVaspId: null,
});
expect(result.action).toBe("HOLD");
expect(result.risk).toBeGreaterThanOrEqual(70);
});
it("allows a small clean deposit", async () => {
const result = await evaluateTransaction({
txId: "sbx-test-002", applicantId: "sbx-applicant-1",
direction: "deposit", amount: 50, currency: "BTC",
counterpartyAddress: "bc1qcleanexchange00000000000000000000",
counterpartyVaspId: "did:vasp:partner-exchange",
});
expect(result.action).toBe("ALLOW");
});
});
Run npx vitest run. The sandbox returns canned scoring results for known test wallet addresses, so these assertions are stable across runs. Add a smoke test that POSTs to /api/transactions and asserts a 202 to cover the ingress path too. For broader market context, see our coverage of leading online casino sites.
Step 14: Production Hardening – Tuning, Observability, and Audit Readiness
Shipping is the start, not the finish. Across the EU, US, and APAC, annual rule reviews, documented threshold rationales, and feedback loops into rule calibration are the baseline 2026 compliance expectation. Bake the operational discipline in from day one.
Observability that satisfies an examiner
Emit a structured log line for every transaction carrying txId, action, risk score, matched rules, and Travel Rule exchange status. Track three SLIs: ingestion p95 latency, queue depth, and the alert-to-SAR conversion ratio. A climbing queue depth means your worker concurrency or Sumsub plan needs scaling; a collapsing SAR ratio means your rules are too loud.
The quarterly tuning ritual
Once a quarter, export dispositioned alerts, measure precision per rule, and adjust thresholds with a written rationale committed to version control. Sumsub uses AI to refine monitoring rules and thresholds against your specific customer profiles, transaction types, and geographies – but the human sign-off and the documented “why” are what an AMLA or AUSTRAC examiner will ask to see. Pair the AML stack with strong geofencing too; our guide to a production GeoComply gambling geolocation setup closes the jurisdictional gap that AML monitoring alone cannot.
Complete Project Example: A Crypto Casino Deposit and Withdrawal Flow
Here is how the pieces compose for a real iGaming scenario: a player deposits 0.4 BTC from a partner exchange, plays, then requests a 9,500 USDT withdrawal to a self-custody wallet. The deposit is a CASP-to-CASP transfer (Travel Rule exchange required under the EU TFR); the withdrawal is to an unhosted wallet above €1,000 (data collection plus control verification, no peer exchange).
// scripts/demo-flow.ts – orchestration sketch
async function depositFlow() {
// 1) Ledger emits the deposit -> POST /api/transactions (202)
await fetch("http://localhost:3000/api/transactions", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
txId: "dep-7741", applicantId: "appl-55", direction: "deposit",
amount: 0.4, currency: "BTC",
counterpartyAddress: "bc1qpartnerexchange0000000000000000",
counterpartyVaspId: "did:vasp:partner-exchange", // hosted -> exchange
}),
});
// worker: submit -> score -> buildIVMS101 -> exchangeTravelRule(TRP) -> ALLOW
}
async function withdrawalFlow() {
await fetch("http://localhost:3000/api/transactions", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
txId: "wd-9920", applicantId: "appl-55", direction: "withdrawal",
amount: 9500, currency: "USDT",
counterpartyAddress: "TUnhostedSelfCustody00000000000000",
counterpartyVaspId: null, // unhosted -> control check
}),
});
// worker: submit -> score -> policy: collect+verify control, no peer exchange
}
What you should observe end to end
The deposit produces a transactionEvaluated webhook with a TRP exchange status of “completed” and an ALLOW action. The withdrawal produces a verdict plus a flag that wallet-control verification is pending; if the player cannot sign the control challenge, the worker opens a case rather than releasing funds. Every step leaves a logged, time-stamped, reproducible record – the entire point of the architecture.
Sumsub Travel Rule Protocol Support (June 2026)
Sumsub advertises the broadest connectivity on the market, supporting five protocols plus an email fallback so you can reach a counterparty VASP regardless of which network it runs. One gap matters: Sumsub does not support TRISA, so if your principal trading partner is TRISA-only, you will handle those transfers manually or via the email tool.
| Protocol | Backed by | Notes for integrators |
|---|---|---|
| TRP | Standard Chartered, ING, BitGo et al. | Lean, open-sourced; widest bank/VASP adoption |
| Sumsub Protocol | Sumsub | Fastest path when both parties are Sumsub clients |
| GTR (Global Travel Rule) | GTR consortium | Secure VASP-to-VASP exchange |
| CODE | Bithumb, Coinone, Korbit (Korea) | Essential for Korean counterparties |
| Email notification tool | Sumsub | Fallback for VASPs with no protocol support |
| TRISA | – | Not supported – handle manually |
All five protocols carry the same IVMS101 payload underneath, which is why building your message as a typed IVMS101 structure (Step 5) future-proofs you against protocol churn – Sumsub picks the wire format; you only ever produce the standard data model.
Beyond the Standard Coverage: What Sumsub’s Docs and Marketing Pages Miss
The top-ranking pages for this topic – Sumsub’s own AML monitoring landing page (~4,300 words) and its developer docs – are excellent at what they do, but they leave four concrete gaps an integrator hits on day one. This section fills them.
Gap 1: No end-to-end production architecture
The marketing page sells features (rule bundles, AI thresholds, case management) and the docs document endpoints in isolation. Neither shows the decoupled ingest-enqueue-score-exchange-act pipeline that keeps payment latency independent of screening latency. That separation – the entire premise of this tutorial – is the single biggest determinant of whether your integration survives a Saturday-night deposit spike.
Gap 2: Idempotency and failure semantics
Vendor docs assume the happy path. They do not tell you that a dropped retry equals an unscreened transfer, or how to build the dead-letter path that turns a silent failure into a documented case. Our pipeline treats every failure mode as a first-class, logged event – the posture an examiner actually rewards.
Gap 3: Unhosted-wallet control verification vs. peer exchange
Most guides blur “collect Travel Rule data” with “exchange it with a peer.” For self-custody wallets there is no peer – you collect data and, above €1,000, verify wallet control. Treating these as one workflow is a real and common compliance gap, and the explicit policy function in Step 6 is how you avoid it. Operators choosing their broader KYC vendor will also want the head-to-head in our review of the leading iGaming KYC providers, since the same applicant data feeds both layers.
Gap 4: Protocol reality, including the TRISA gap
The glossy pages imply universal connectivity. In practice, Sumsub’s lack of TRISA support is a planning constraint you need to know before you sign a counterparty. We state it plainly above; the docs bury it.
Common Pitfalls (6 to Avoid)
- Parsing the webhook body before verifying the signature. Re-serializing JSON changes byte order and invalidates the HMAC. Always verify against the raw text body first.
- Reusing a timestamp across signature retries. Sumsub’s clock-drift window rejects stale timestamps; regenerate the signature on every attempt.
- Screening synchronously inside the deposit request. This couples player-facing latency to Sumsub’s round-trip and collapses under load. Enqueue and return
202. - Treating an unhosted wallet like a hosted VASP. There is no peer to message; you owe data collection and control verification instead. Encode the distinction explicitly.
- Letting a failed screen pass. Five failed attempts must dead-letter to a case, never release unscreened funds. Silent drops are reportable control failures.
- Ignoring just-below-threshold clustering. Under the EU’s zero-threshold TFR, structured €990 deposits are a textbook typology – aggregate them rather than scoring each in isolation.
Troubleshooting: 8 Failure Modes and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Every call returns 401 |
Signature base string wrong | Confirm order is ts + METHOD + path + body, body un-mutated |
Intermittent 401s |
Container clock drift | Enable NTP; regenerate timestamp per request |
Webhook 401 on valid events |
Verifying parsed JSON, not raw body | Read req.text() before JSON.parse |
| Duplicate screenings | No queue-level idempotency | Set deterministic jobId = txId |
| Travel Rule exchange stuck “pending” | Counterparty on unsupported protocol (e.g. TRISA) | Use email fallback or manual handling |
| Verdict never arrives | Webhook URL unreachable / non-200 | Return 200 fast; expose a public HTTPS endpoint |
| High false-positive rate | No risk-tier segmentation | Sync onboarding risk labels; tune velocity rules |
| Queue depth climbing | Worker concurrency too low | Raise concurrency; scale worker processes |
Advanced Tips for High-Scale iGaming Deployments
Aggregate before you score
For velocity and structuring typologies, maintain a rolling per-applicant counter in Redis (sorted set keyed by timestamp) and pass the aggregate into your overlay. Scoring a single €990 deposit in isolation tells Sumsub nothing; scoring “the eleventh €990 deposit in an hour” tells it everything. This pre-aggregation is what catches smurfing that per-transaction rules miss.
Partition the queue by direction
Withdrawals are riskier and more latency-sensitive (players are watching) than deposits. Run separate queues with independent concurrency and priority so a deposit backlog never delays a withdrawal hold decision. BullMQ’s per-queue settings make this a two-line change.
Pre-resolve VASP directories
Counterparty VASP resolution can be slow if done per transaction. Cache the VASP directory (mapping wallet-address clusters and VASP DIDs) in Redis with a short TTL, refreshed by a background job. This keeps the enrich stage off the critical path and shaves real milliseconds off every Travel Rule exchange.
Shadow-deploy rule changes
Before promoting a tuned rule bundle, run it in shadow mode against live traffic and compare its alerts to production for a week. You get the precision delta with zero player impact, and a documented basis for the change – exactly the evidence AMLA’s reproducible-decision standard expects.
Frequently Asked Questions
Does the EU Travel Rule really have no minimum threshold?
Yes. The EU Transfer of Funds Regulation (Reg. 2023/1113), fully applicable since 30 December 2024, requires full originator and beneficiary data on every CASP-to-CASP crypto transfer, including amounts below €1,000. It is the first zero-threshold implementation of the FATF Travel Rule globally. Non-EU FATF jurisdictions generally retain the $1,000/€1,000 de minimis line.
Which Travel Rule protocols does Sumsub support in 2026?
Five: TRP, Sumsub’s own protocol, GTR, CODE, plus an email notification fallback. Sumsub does not support TRISA, so TRISA-only counterparties must be handled manually. All supported protocols transport the same IVMS101 data model.
What is IVMS101 and why model it as a typed structure?
IVMS101 (interVASP Messaging Standard) is the industry-standard data format every major Travel Rule protocol carries. Building your payload as a typed IVMS101 object means you produce one standard structure and let Sumsub choose the wire protocol, so protocol churn never forces a rewrite.
How fast does the pipeline screen a transaction?
Ingestion returns 202 in single-digit milliseconds because screening is deferred to the worker. The end-to-end verdict depends on Sumsub’s scoring round-trip and any Travel Rule exchange, which is why the architecture is event-driven and the inline response is treated as provisional until the webhook confirms.
Do I need to message a peer VASP for self-custody wallet transfers?
No. Unhosted (self-custody) wallets have no peer VASP to exchange data with. Under the EU TFR you still collect originator and beneficiary data, and above €1,000 you must verify that your customer controls the wallet – a separate control-verification workflow, not a peer exchange.
What happens when a transaction fails screening repeatedly?
The worker retries with exponential backoff up to five attempts; if all fail, the job dead-letters into a case for manual review. It must never release unscreened funds – an unscreened transfer is a reportable control failure under FATF Recommendation 1 and the EU AMLR.
When does the EU AMLR and AMLA supervision take effect?
The EU Anti-Money Laundering Regulation (Reg. 2024/1624) applies directly across all member states from 10 July 2027. AMLA began operations in Frankfurt on 1 July 2025 and is expected to start direct supervision of high-risk obliged entities, including CASPs, from 2028.
Can I build and test this without real crypto transactions?
Yes. The Sumsub sandbox (tokens prefixed sbx:) returns deterministic scoring for known test wallet addresses, so you can run the entire ingest-score-exchange-case flow – including Travel Rule exchange and webhook verification – without a single real transfer. Swap the token prefix to go live.







