Sumsub Travel Rule in Go: Kafka AML, 14 Steps [2026]

A licensed crypto casino settling thousands of USDT deposits and withdrawals an hour has a hard deadline on every single one of them: before the funds move, the platform has to decide whether the transfer triggers the FATF Travel Rule, build a compliant originator/beneficiary payload, exchange it with the counterparty Virtual Asset Service Provider (VASP), and screen the whole thing against sanctions lists – in real time. Miss that window at scale and you either block good customers or leak non-compliant transfers. This tutorial builds that decision engine as a horizontally-scalable streaming service using Go 1.26, Apache Kafka 4.3, and the Sumsub Travel Rule API.

By the end you will have a running project – an ingest API, a Kafka-backed screening worker with exactly-once processing, IVMS101 payload construction, Sumsub Know-Your-Transaction (KYT) submission, webhook verification, a dead-letter path, and a deposit/withdrawal gate. Budget about 50 minutes if you already have Docker and Go installed. Every code block is real, runnable Go; the Sumsub payload shapes are drawn from the KYT and Travel Rule reference so you can adapt them to your account’s exact schema.

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 Streaming Beats Batch for Travel Rule AML

Real-time AML monitoring flags anomalies as the transaction happens rather than in an overnight batch, which is the difference between declining a sanctioned transfer and filing a Suspicious Activity Report after the money is gone. The regulatory pressure behind that shift is concrete. In June 2025 the Financial Action Task Force revised Recommendation 16, standardising the data that must accompany cross-border transfers and pushing institutions toward technology that catches fraud and error in the payment path itself. Adoption is accelerating too: 85 jurisdictions had Travel Rule legislation in force by the 2025 FATF survey, up from 65 a year earlier, with roughly 99 counting those still in the legislative pipeline.

The zero-threshold problem

The European Union raised the stakes further. The Transfer of Funds Regulation (Regulation (EU) 2023/1113) and the Markets in Crypto-Assets Regulation (Regulation (EU) 2023/1114) both became applicable on 30 December 2024, and the TFR imposes a zero-threshold rule: every crypto-asset transfer a licensed operator touches must carry verified originator and beneficiary information, no matter how small. There is no “under €1,000 so skip it” shortcut inside the EU. That single design constraint means your monitoring path runs on every transaction, not a sampled subset – exactly the workload a partitioned log is built for.

Where synchronous designs run out of road

A synchronous webhook handler that calls Sumsub inline, waits, and then releases the deposit works fine in a sandbox. In production it couples your payment path to a third-party API’s latency, drops transactions when a pod restarts mid-request, and has no natural way to replay a poisoned message. Redis-backed job queues fix the durability problem but cap out on throughput and give you at-least-once delivery you then have to de-duplicate by hand.

What Go and Kafka buy you

Apache Kafka gives you a durable, partitioned commit log: transactions keyed by user or wallet land on a partition, a consumer group of Go workers scales horizontally across those partitions, and Kafka’s transactional producer lets you achieve exactly-once consume-process-produce semantics. Go’s goroutines and small memory footprint make each worker cheap to run dozens of. Since Kafka 4.0 (March 2025) the broker runs KRaft-only – no ZooKeeper – and the KIP-848 next-generation consumer-group protocol is generally available, cutting rebalance times that used to stall AML pipelines during deploys. The current release line as of this writing is 4.3.1 (25 June 2026), so the Compose file below pins an image you can actually pull today.

Inside the Sumsub Travel Rule API

Sumsub is a London-headquartered verification platform (founded 2015, HQ at 30 St Mary Axe) whose Travel Rule solution advertises access to a network of 2,100+ VASPs and more than $3.4 trillion in secured transactions. It orchestrates counterparty discovery, protocol selection, and the IVMS101 data exchange behind a single API so you are not integrating five wire protocols by hand.

The volume behind that number is why real-time screening matters. Sumsub recognises 14,000+ document types across 220+ countries and, in its 2026 Identity Fraud Report, put the global identity-fraud rate at 2.2% for 2025 against a 180% year-on-year rise in sophisticated, increasingly AI-assisted fraud; in iGaming specifically it flagged suspicious-transaction volume up roughly 4.5x. A monitoring gate that only inspects transfers after settlement cannot keep pace with fraud moving at that speed. That is the entire argument for streaming the decision inline: the compliance verdict has to be produced in the same few hundred milliseconds the payment path is willing to wait, on every transaction, without a batch window in which a sanctioned transfer can slip through. The engineering problem, then, is not “call an API” – it is “call an API reliably, exactly once, under load, while never failing open.”

The authentication model

Every request to https://api.sumsub.com carries three headers: X-App-Token (your app token, prefixed sbx: for sandbox or prod: for live), X-App-Access-Ts (a Unix-seconds timestamp), and X-App-Access-Sig. The signature is a hex-encoded HMAC-SHA256 over the concatenation of the timestamp, the upper-cased HTTP method, the request path including query string, and the raw request body, keyed with your API secret. Get the concatenation order wrong and you get a 401 – this is the single most common integration failure, so we isolate it in one function.

The IVMS101 payload

Under every Travel Rule protocol sits the same schema: IVMS101 (the interVASP Messaging Standard), the joint industry format ratified in 2020, with the 101.2023 revision current. It carries four top-level objects – originator, beneficiary, originatingVasp, and beneficiaryVasp – so that whichever transport moves the message, the payload is portable. Your job is to populate it from the KYC identity you already hold and the counterparty you resolve.

Protocols and counterparty discovery

Sumsub advertises the broadest connectivity on the market – five protocols plus an email fallback – so you can reach a counterparty regardless of which network it runs. One important gap: Sumsub does not natively support TRISA, so a TRISA-only partner is handled manually or through the email tool.

Protocol Backing / network Notes
TRP (Travel Rule Protocol) Standard Chartered, ING, BitGo REST-based, widely adopted by banks and large VASPs
Sumsub Protocol Sumsub network Native, lowest-friction between Sumsub customers
GTR (Global Travel Rule) Binance-originated network Large exchange coverage
Sygna / Sygna BridgePlus CoolBitX Strong APAC coverage
CODE Bithumb, Coinone, Korbit South Korean market
Email fallback – Reaches unlicensed or unconnected counterparties
TRISA – Not natively supported; handle manually

A worker resolves whether the counterparty is a hosted VASP or an unhosted (self-hosted) wallet, decides whether the Travel Rule applies given the jurisdiction and amount, constructs the IVMS101 payload, and sends or requests it over one of these protocols while checking sanctions, watchlists, and adverse media in the same pass.

The 2026 Regulatory Timeline You Are Building For

Engineers sometimes treat “the Travel Rule” as a single switch, but the code you write in the following steps has to satisfy several overlapping regimes that landed – or land – on different dates in 2025 and 2026. Getting the thresholds and effective dates right is not optional polish; it determines the branch your resolver takes for every transfer. Here is the landscape the pipeline targets.

European Union: zero-threshold and a new supervisor

The EU is the strictest environment your code will run in. The Transfer of Funds Regulation (EU) 2023/1113 and MiCA (Regulation (EU) 2023/1114) both became applicable on 30 December 2024, imposing full originator and beneficiary data on every crypto-asset transfer regardless of size. Supervision is centralising: the new Anti-Money Laundering Authority (AMLA) stood up in Frankfurt and became operational on 1 July 2025, and it will directly supervise the highest-risk crypto-asset service providers from 2028. The wider Anti-Money Laundering Regulation (EU) 2024/1624 becomes applicable on 10 July 2027, and the European Banking Authority has issued the Travel Rule guidelines your implementation is measured against. In short: inside the EU, key your logic off “hosted VASP → always exchange,” not off any monetary floor.

United States: FinCEN’s $3,000 line, maybe $250

The U.S. has run a Travel Rule for funds transfers for years, and FinCEN currently sets the threshold at $3,000. A long-standing joint proposal with the Federal Reserve would lower the cross-border coverage threshold to $250 – a change that, if finalised, would pull a large tail of smaller transfers into scope and materially increase the volume flowing through a pipeline like this one. Build the threshold as configuration, not a constant, so you can drop it without a redeploy.

Asia-Pacific and the United Kingdom

Australia modernised its regime through AUSTRAC, with the reformed AML/CTF framework in force on 31 March 2026 and the Travel Rule obligation for virtual-asset transfers applying to existing and newly regulated VASPs from 1 July 2026. The United Kingdom laid the groundwork with a draft statutory instrument in March 2026. Globally, the FATF’s June 2025 Recommendation 16 revision standardised cross-border payment data and is being adopted on a phased basis running to 2030 – which is why 85 jurisdictions already had legislation in force in the 2025 survey, and why your resolver needs a per-jurisdiction table rather than a single global rule.

The Architecture at a Glance

The pipeline decouples ingest from screening so a slow Sumsub call or a broker rebalance never blocks the payment path. Transactions enter over HTTP, land on a partitioned Kafka topic keyed by user, and a consumer group of Go workers screens each one and emits a decision. Failures divert to a dead-letter topic for replay.

   deposit/withdrawal            Kafka (KRaft, 4.3)                 gate
  ┌──────────────────┐        ┌───────────────────┐        ┌──────────────────┐
  │  ingest (HTTP)   │──────>│  topic: txn.raw    │──────>│  screener worker  │
  │  cmd/ingest      │  key=  │  12 partitions     │  group │  cmd/screener     │
  └──────────────────┘  user  └───────────────────┘  aml   └────────┬─────────┘
                                                                     │ Sumsub KYT
                        ┌───────────────────┐                        │ + Travel Rule
   Sumsub webhooks ────>│ topic: tr.status   │<───────────────┐       ▼
   cmd/webhookd         └───────────────────┘   travelRule    ┌──────────────┐
                        ┌───────────────────┐   StatusChanged │  decision    │
                        │ topic: txn.decided │<───────────────┤  allow/hold  │
                        │ topic: txn.dlq     │                │  block       │
                        └───────────────────┘                └──────────────┘

Topics and partition strategy

Keying txn.raw by user ID (or by wallet) guarantees per-user ordering – a withdrawal never overtakes the deposit that funded it – while still spreading load across partitions. Twelve partitions lets you run up to twelve screener instances in one group before you need to expand.

Why exactly-once matters here

Screening a transaction twice can double-file a SAR or double-charge a Sumsub KYT event; dropping one can release a sanctioned transfer. Kafka transactions let the worker read from txn.raw, produce to txn.decided, and commit its consumer offset as one atomic unit. If the worker crashes mid-batch, the whole unit rolls back and is redelivered cleanly.

Where this build differs from its siblings

This is deliberately a different stack from the other editions of this pipeline on the site, each of which is a valid choice for a different team.

Edition Language Streaming backbone Delivery semantics
Node + Express JavaScript Synchronous webhooks Best-effort
Next.js + BullMQ TypeScript Redis queue At-least-once + manual idempotency
FastAPI edition Python 3.12 Redis Streams At-least-once (consumer groups)
This build Go 1.26 Apache Kafka 4.3 Exactly-once (transactions)

If your team runs Python, the FastAPI and Redis Streams edition linked above is the closer fit; if you are already on the JVM-adjacent Kafka ecosystem and want raw throughput with exactly-once guarantees, keep reading.

Prerequisites and Project Bootstrap (Steps 1–2)

Step 1 – install the toolchain. You need Go 1.26 (1.26.4 is current as of June 2026; verify with go version), Docker with Compose for Kafka and Postgres, and a free Sumsub sandbox account to obtain an app token, API secret, and webhook secret. The webhook secret is a separate value from the API secret – mixing them up is the second most common failure after signature ordering.

Step 2 – scaffold the project. Create the module and pull the dependencies. We use the pure-Go Kafka client franz-go (which supports Kafka 0.8 through 4.2+ including the KRaft brokers we run), pgx for Postgres, and the standard library for everything crypto-related.

mkdir travel-rule-aml && cd travel-rule-aml
go mod init github.com/acme/travel-rule-aml

go get github.com/twmb/franz-go/pkg/kgo@latest
go get github.com/twmb/franz-go/pkg/kadm@latest
go get github.com/jackc/pgx/v5@latest
go get github.com/google/uuid@latest

The resulting go.mod pins the versions go get resolved (run go get ...@latest to stay current – franz-go published its most recent kgo release on 24 June 2026):

module github.com/acme/travel-rule-aml

go 1.26

require (
	github.com/google/uuid v1.6.0
	github.com/jackc/pgx/v5 v5.7.6
	github.com/twmb/franz-go v1.19.5
	github.com/twmb/franz-go/pkg/kadm v1.16.0
)

Lay the project out by responsibility so each binary can be scaled independently:

travel-rule-aml/
├── docker-compose.yml
├── go.mod
├── cmd/
│   ├── ingest/main.go      # HTTP API → produces to txn.raw
│   ├── screener/main.go    # consumer group → Sumsub → txn.decided
│   └── webhookd/main.go    # Sumsub webhook receiver
├── internal/
│   ├── sumsub/client.go    # HMAC signer + KYT + Travel Rule calls
│   ├── ivms/ivms101.go     # IVMS101 model + builder
│   ├── screen/rules.go     # threshold + risk scoring
│   └── store/pg.go         # Postgres idempotency + state
└── migrations/001_init.sql

Standing Up Kafka 4.3 in KRaft Mode (Step 3)

Step 3 – run the infrastructure. Kafka 4.x is ZooKeeper-free, so a single-node KRaft broker is a few lines of Compose. This file also brings up Postgres for the idempotency store.

# docker-compose.yml
services:
  kafka:
    image: apache/kafka:4.3.1
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: "broker,controller"
      KAFKA_CONTROLLER_QUORUM_VOTERS: "1@localhost:9093"
      KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
      KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://localhost:9092"
      KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1

  postgres:
    image: postgres:17
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: aml
      POSTGRES_DB: aml

Bring it up and create the topics. Note the transaction-state-log settings above – exactly-once producers refuse to start without them.

docker compose up -d

# create topics (run inside the kafka container)
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --create --topic txn.raw \
  --partitions 12 --replication-factor 1 --config retention.ms=604800000

docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --create --topic txn.decided \
  --partitions 12 --replication-factor 1

docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --create --topic txn.dlq \
  --partitions 3 --replication-factor 1 --config retention.ms=1209600000
Topic Partitions Key Retention Purpose
txn.raw 12 user ID 7 days Incoming deposits/withdrawals
txn.decided 12 user ID default Allow / hold / block decisions
txn.dlq 3 user ID 14 days Poison / failed messages for replay
tr.status 6 txn ID 7 days Sumsub travelRuleStatusChanged events

Signing Sumsub Requests with HMAC-SHA256 (Step 4)

Step 4 – build the signer. Isolating signing in one method means every call site is correct by construction. This is internal/sumsub/client.go. For broader market context, see our coverage of best sweepstakes casino sites.

package sumsub

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type Client struct {
	base    string
	token   string // sbx:... or prod:...
	secret  string // API secret
	http    *http.Client
}

func New(token, secret string) *Client {
	return &Client{
		base:   "https://api.sumsub.com",
		token:  token,
		secret: secret,
		http:   &http.Client{Timeout: 15 * time.Second},
	}
}

// sign returns the timestamp and hex HMAC-SHA256 over
// ts + UPPER(method) + path(with query) + rawBody.
func (c *Client) sign(method, path string, body []byte) (ts, sig string) {
	ts = strconv.FormatInt(time.Now().Unix(), 10)
	mac := hmac.New(sha256.New, []byte(c.secret))
	mac.Write([]byte(ts))
	mac.Write([]byte(strings.ToUpper(method)))
	mac.Write([]byte(path))
	mac.Write(body)
	return ts, hex.EncodeToString(mac.Sum(nil))
}

func (c *Client) newRequest(method, path string, body []byte) (*http.Request, error) {
	ts, sig := c.sign(method, path, body)
	req, err := http.NewRequest(method, c.base+path, strings.NewReader(string(body)))
	if err != nil {
		return nil, err
	}
	req.Header.Set("X-App-Token", c.token)
	req.Header.Set("X-App-Access-Ts", ts)
	req.Header.Set("X-App-Access-Sig", sig)
	req.Header.Set("Content-Type", "application/json")
	return req, nil
}

Because sign hashes the exact bytes it is handed, always sign the body you will actually send – never re-marshal between signing and transmitting, or the signature will drift from the payload.

The Ingest API: Producing Transactions to Kafka (Steps 5–6)

Step 5 – model the transaction. Define a compact internal event that both the ingest API and the screener share.

package model

type Direction string

const (
	Deposit    Direction = "in"
	Withdrawal Direction = "out"
)

type Txn struct {
	TxnID        string    `json:"txnId"`
	ExternalUser string    `json:"externalUserId"`
	Direction    Direction `json:"direction"`
	AmountUSD    float64   `json:"amountUsd"`
	Currency     string    `json:"currency"`   // e.g. USDT
	Chain        string    `json:"chain"`      // e.g. TRON
	Address      string    `json:"address"`    // counterparty address
	Jurisdiction string    `json:"jurisdiction"` // EU, US, AU, ...
	CreatedAt    string    `json:"createdAt"`
}

Step 6 – accept transactions and produce them. The ingest binary validates the request, assigns a transaction ID if missing, and produces to txn.raw keyed by user. It returns 202 Accepted immediately – screening happens asynchronously.

package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"

	"github.com/google/uuid"
	"github.com/twmb/franz-go/pkg/kgo"
	"github.com/acme/travel-rule-aml/internal/model"
)

func main() {
	cl, err := kgo.NewClient(
		kgo.SeedBrokers(os.Getenv("KAFKA_BROKERS")),
		kgo.DefaultProduceTopic("txn.raw"),
		kgo.ProducerLinger(5*1e6), // 5ms batching
		kgo.RequiredAcks(kgo.AllISRAcks()),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer cl.Close()

	http.HandleFunc("/v1/transactions", func(w http.ResponseWriter, r *http.Request) {
		var t model.Txn
		if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		if t.TxnID == "" {
			t.TxnID = uuid.NewString()
		}
		if t.ExternalUser == "" || t.AmountUSD <= 0 {
			http.Error(w, "missing user or amount", http.StatusBadRequest)
			return
		}
		val, _ := json.Marshal(t)
		rec := &kgo.Record{Key: []byte(t.ExternalUser), Value: val}

		// synchronous produce so we only 202 after the log accepts it
		if err := cl.ProduceSync(r.Context(), rec).FirstErr(); err != nil {
			http.Error(w, "enqueue failed", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusAccepted)
		_ = json.NewEncoder(w).Encode(map[string]string{"txnId": t.TxnID, "status": "queued"})
	})

	log.Println("ingest listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Using ProduceSync with AllISRAcks means the HTTP caller only receives a 202 once Kafka has durably accepted the record – the payment service can safely release its own lock knowing the transaction will be screened even if the ingest pod dies a millisecond later.

The Screening Worker: A franz-go Consumer Group (Step 7)

Step 7 – consume as a group. The screener joins the aml-screener consumer group and reads txn.raw. For now we use manual offset commits; Step 11 upgrades this to full exactly-once. franz-go’s classic consumer-group protocol is stable and battle-tested; the newer KIP-848 protocol is available as an opt-in beta if you need its faster rebalances.

package main

import (
	"context"
	"encoding/json"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/twmb/franz-go/pkg/kgo"
	"github.com/acme/travel-rule-aml/internal/model"
	"github.com/acme/travel-rule-aml/internal/screen"
)

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(),
		syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	cl, err := kgo.NewClient(
		kgo.SeedBrokers(os.Getenv("KAFKA_BROKERS")),
		kgo.ConsumerGroup("aml-screener"),
		kgo.ConsumeTopics("txn.raw"),
		kgo.DisableAutoCommit(),
		kgo.FetchMaxBytes(5*1024*1024),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer cl.Close()

	for {
		fetches := cl.PollFetches(ctx)
		if fetches.IsClientClosed() || ctx.Err() != nil {
			return
		}
		fetches.EachError(func(t string, p int32, err error) {
			log.Printf("fetch error %s[%d]: %v", t, p, err)
		})
		fetches.EachRecord(func(r *kgo.Record) {
			var t model.Txn
			if err := json.Unmarshal(r.Value, &t); err != nil {
				log.Printf("skip malformed record: %v", err)
				return
			}
			decision := screen.Evaluate(ctx, t)
			log.Printf("txn=%s user=%s decision=%s reasons=%v",
				t.TxnID, t.ExternalUser, decision.Outcome, decision.Reasons)
		})
		if err := cl.CommitUncommittedOffsets(ctx); err != nil {
			log.Printf("commit failed: %v", err)
		}
	}
}

Counterparty Resolution and IVMS101 Payloads (Step 8)

Step 8 – decide whether the rule applies, then build the payload. Two questions drive Travel Rule handling: is the counterparty a hosted VASP or an unhosted wallet, and does the jurisdiction/amount cross the threshold? In the EU the threshold is zero for VASP-to-VASP transfers; elsewhere the FATF de minimis is USD/EUR 1,000; FinCEN’s current U.S. threshold is $3,000, with a proposal to cut cross-border coverage to $250.

Jurisdiction Threshold In force Regulator
EU (TFR 2023/1113) Zero for CASP-to-CASP; verify unhosted ≥ €1,000 30 Dec 2024 EBA / AMLA
FATF Recommendation 16 USD/EUR 1,000 Revised 18 Jun 2025 (phased to 2030) FATF
United States (FinCEN) $3,000 (proposed $250 cross-border) Current FinCEN
Australia (AUSTRAC) Aligned with FATF Regime 31 Mar 2026; VASP rule 1 Jul 2026 AUSTRAC
package ivms

type Address struct {
	Country      string `json:"country"`
	Town         string `json:"townName,omitempty"`
	AddressLine  string `json:"addressLine,omitempty"`
}

type Person struct {
	NameFirst  string  `json:"nameFirst"`
	NameLast   string  `json:"nameLast"`
	Address    Address `json:"geographicAddress"`
	DOB        string  `json:"dateOfBirth,omitempty"`
	NationalID string  `json:"nationalIdentification,omitempty"`
}

type VASP struct {
	Name string `json:"name"`
	LEI  string `json:"lei,omitempty"`
}

// IVMS101 is the portable payload every Travel Rule protocol carries.
type IVMS101 struct {
	Originator      Person `json:"originator"`
	Beneficiary     Person `json:"beneficiary"`
	OriginatingVASP VASP   `json:"originatingVasp"`
	BeneficiaryVASP VASP   `json:"beneficiaryVasp"`
}

The threshold logic is small but load-bearing. Note the EU branch ignores the amount entirely for hosted counterparties.

package screen

import "github.com/acme/travel-rule-aml/internal/model"

type Counterparty int

const (
	HostedVASP Counterparty = iota
	UnhostedWallet
)

// Applies reports whether the Travel Rule data exchange is required.
func Applies(jurisdiction string, amountUSD float64, cp Counterparty) bool {
	switch jurisdiction {
	case "EU":
		// TFR zero-threshold: full data on every CASP-to-CASP transfer.
		return cp == HostedVASP
	default:
		// FATF de minimis; unhosted still needs control verification above 1000.
		return amountUSD >= 1000
	}
}

Submitting to Sumsub KYT and the Travel Rule Exchange (Steps 9–10)

Step 9 – submit the transaction for scoring. Sumsub’s KYT endpoint accepts the transaction and its parties, returns a risk score, and (when the Travel Rule applies) coordinates the counterparty exchange. Add this method to the sumsub.Client.

package sumsub

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type KYTResult struct {
	ReviewAnswer string   `json:"reviewAnswer"` // GREEN / RED
	Score        float64  `json:"scoringResult"`
	Labels       []string `json:"labels"`
	TravelRule   struct {
		Status string `json:"status"` // pending / sent / received / notApplicable
	} `json:"travelRule"`
}

// SubmitKYT posts a transaction to the Know-Your-Transaction endpoint.
// Confirm the exact body schema against your account's KYT reference.
func (c *Client) SubmitKYT(ctx context.Context, body []byte) (*KYTResult, error) {
	const path = "/resources/kyt/txns/-/data"
	req, err := c.newRequest(http.MethodPost, path, body)
	if err != nil {
		return nil, err
	}
	resp, err := c.http.Do(req.WithContext(ctx))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 300 {
		return nil, fmt.Errorf("sumsub kyt %d: %s", resp.StatusCode, raw)
	}
	var out KYTResult
	if err := json.Unmarshal(raw, &out); err != nil {
		return nil, err
	}
	return &out, nil
}

Step 10 – attach the Travel Rule data. When the rule applies you push the IVMS101 payload for the transaction. Sumsub resolves the counterparty VASP, selects a protocol, and performs the exchange; for an unhosted wallet it instead records proof-of-control rather than exchanging with a peer.

// AttachTravelRule sends the IVMS101 payload for a specific transaction.
func (c *Client) AttachTravelRule(ctx context.Context, txID string, ivms []byte) error {
	path := fmt.Sprintf("/resources/kyt/txns/%s/travelRuleData", txID)
	req, err := c.newRequest(http.MethodPost, path, ivms)
	if err != nil {
		return err
	}
	resp, err := c.http.Do(req.WithContext(ctx))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("sumsub travelRule %d: %s", resp.StatusCode, raw)
	}
	return nil
}

The identity that fills the originator object comes straight from onboarding, which is why a solid Sumsub KYC API integration upstream is a prerequisite: if the verified name, date of birth, and address are missing or stale, the Travel Rule payload you build here is non-compliant before it ever leaves your network. The same is true on the money-movement side – the crypto rails you plug in from your iGaming crypto payment processors are the source of the deposit and withdrawal events streaming into txn.raw, so their metadata (chain, address, memo) must be captured at ingest or you cannot resolve the counterparty later.

Exactly-Once Processing with Kafka Transactions (Step 11)

Step 11 – make the whole loop atomic. Replace the manual-commit worker with a GroupTransactSession. It reads from txn.raw, and the offset commit plus the produce to txn.decided succeed or fail as one Kafka transaction, giving true exactly-once consume-process-produce.

package main

import (
	"context"
	"encoding/json"
	"log"
	"os"

	"github.com/twmb/franz-go/pkg/kgo"
	"github.com/acme/travel-rule-aml/internal/model"
	"github.com/acme/travel-rule-aml/internal/screen"
)

func main() {
	ctx := context.Background()

	sess, err := kgo.NewGroupTransactSession(
		kgo.SeedBrokers(os.Getenv("KAFKA_BROKERS")),
		kgo.TransactionalID("aml-screener-eos"),
		kgo.FetchIsolationLevel(kgo.ReadCommitted()),
		kgo.RequireStableFetchOffsets(),
		kgo.ConsumerGroup("aml-screener"),
		kgo.ConsumeTopics("txn.raw"),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer sess.Close()

	for {
		fetches := sess.PollFetches(ctx)
		if fetches.IsClientClosed() {
			return
		}
		if err := sess.Begin(); err != nil {
			log.Fatalf("begin txn: %v", err) // fatal: producer is broken
		}

		var produceErr error
		fetches.EachRecord(func(r *kgo.Record) {
			var t model.Txn
			if err := json.Unmarshal(r.Value, &t); err != nil {
				return // malformed → drop (Step 13 routes to DLQ instead)
			}
			d := screen.Evaluate(ctx, t)
			out, _ := json.Marshal(d)
			sess.Client().Produce(ctx, &kgo.Record{
				Topic: "txn.decided",
				Key:   r.Key,
				Value: out,
			}, func(_ *kgo.Record, err error) {
				if err != nil {
					produceErr = err
				}
			})
		})

		// commit offsets + produced records atomically
		commit := kgo.TryCommit
		if produceErr != nil {
			commit = kgo.TryAbort
		}
		if _, err := sess.End(ctx, commit); err != nil {
			log.Printf("end txn: %v", err)
		}
	}
}

With FetchIsolationLevel(ReadCommitted()), downstream consumers of txn.decided never see records from a transaction that later aborted. That is what lets the gate in Step 14 trust a decision without re-checking it.

Idempotency and State in PostgreSQL (Step 12)

Step 12 – persist decisions and dedupe. Kafka gives exactly-once within the stream, but Sumsub is an external side effect: a redelivery after a broker failover could re-submit the same KYT event. A Postgres row keyed on the transaction ID makes the Sumsub call idempotent and gives you an auditable state machine. For broader market context, see our coverage of leading online casino sites.

-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS txn_state (
    txn_id        TEXT PRIMARY KEY,
    external_user TEXT NOT NULL,
    outcome       TEXT NOT NULL DEFAULT 'pending', -- pending|allow|hold|block
    sumsub_answer TEXT,
    travel_rule   TEXT,                              -- notApplicable|pending|sent|received
    submitted     BOOLEAN NOT NULL DEFAULT FALSE,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
package store

import (
	"context"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Store struct{ pool *pgxpool.Pool }

// ClaimForSubmit atomically marks a txn as submitted and reports whether
// THIS caller won the claim. Returns false if it was already submitted.
func (s *Store) ClaimForSubmit(ctx context.Context, txnID, user string) (bool, error) {
	var won bool
	err := s.pool.QueryRow(ctx, `
		INSERT INTO txn_state (txn_id, external_user, submitted)
		VALUES ($1, $2, TRUE)
		ON CONFLICT (txn_id) DO UPDATE
		  SET submitted = TRUE, updated_at = now()
		  WHERE txn_state.submitted = FALSE
		RETURNING TRUE`, txnID, user).Scan(&won)
	if err != nil {
		return false, nil // conflict with no row returned → already submitted
	}
	return won, nil
}

The worker calls ClaimForSubmit before SubmitKYT; only the winner talks to Sumsub, so a redelivered record updates local state without a duplicate external charge or a duplicate SAR. This mirrors the correlation-ID dedupe pattern used across the site’s KYC builds and is the belt to Kafka’s braces.

Verifying Sumsub Webhooks (Step 13)

Step 13 – trust webhooks only after verifying them, then route failures. Sumsub posts transactionEvaluated and travelRuleStatusChanged events to your endpoint. Each carries an x-payload-digest header (and x-payload-digest-alg naming the algorithm) computed with your webhook secret over the raw body. Verify in constant time before you act.

package main

import (
	"crypto/hmac"
	"crypto/sha1"
	"crypto/sha256"
	"crypto/sha512"
	"encoding/hex"
	"hash"
	"io"
	"net/http"
	"os"
)

func hasher(alg string) func() hash.Hash {
	switch alg {
	case "HMAC_SHA1_HEX":
		return sha1.New
	case "HMAC_SHA512_HEX":
		return sha512.New
	default: // HMAC_SHA256_HEX
		return sha256.New
	}
}

func verify(secret, alg, digest string, raw []byte) bool {
	mac := hmac.New(hasher(alg), []byte(secret))
	mac.Write(raw)
	want := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(digest))
}

func main() {
	secret := os.Getenv("SUMSUB_WEBHOOK_SECRET") // NOT the API secret
	http.HandleFunc("/webhooks/sumsub", func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		alg := r.Header.Get("x-payload-digest-alg")
		dig := r.Header.Get("x-payload-digest")
		if !verify(secret, alg, dig, raw) {
			http.Error(w, "bad signature", http.StatusUnauthorized)
			return
		}
		// signature good → publish to tr.status for the gate to consume
		w.WriteHeader(http.StatusOK)
	})
	http.ListenAndServe(":8081", nil)
}

Read the body with io.ReadAll and hash those exact bytes – verifying a re-serialised struct is the classic reason a valid webhook fails its check. Layering this transaction-level signal on top of the geolocation and device checks in your gambling geolocation compliance stack gives the risk engine the multi-signal picture Sumsub’s own guidance recommends.

Dead-Letter Queue, Gating, and Go-Live (Step 14)

Step 14 – divert poison, gate the money, ship it. Inside the transactional loop, wrap the per-record work in a recover-and-route helper so a malformed record or an unrecoverable Sumsub error goes to txn.dlq instead of stalling the partition.

func handle(sess *kgo.GroupTransactSession, r *kgo.Record) {
	defer func() {
		if rec := recover(); rec != nil {
			sess.Client().Produce(context.Background(), &kgo.Record{
				Topic:   "txn.dlq",
				Key:     r.Key,
				Value:   r.Value,
				Headers: []kgo.RecordHeader{{Key: "error", Value: []byte("panic")}},
			}, nil)
		}
	}()
	// ... claim, submit KYT, attach Travel Rule, produce decision ...
}

The gate is the consumer of txn.decided that your payment service reads: allow releases funds, hold parks them pending a travelRuleStatusChanged of received, and block freezes the transfer and opens a case. Because decisions were produced inside a committed Kafka transaction, the gate can act on them without re-screening.

Expected output

Post a withdrawal to the ingest API and watch the screener log the full path:

$ curl -s localhost:8080/v1/transactions -d '{
  "externalUserId":"user-88213","direction":"out",
  "amountUsd":2500,"currency":"USDT","chain":"TRON",
  "address":"TXk...9fQ","jurisdiction":"EU"}'
{"txnId":"9f2c1b7e-...","status":"queued"}

# screener stdout
2026/07/04 12:04:11 claimed txn=9f2c1b7e user=user-88213
2026/07/04 12:04:12 sumsub kyt answer=GREEN score=0.18 travelRule=pending
2026/07/04 12:04:12 counterparty=HostedVASP applies=true protocol=TRP
2026/07/04 12:04:13 ivms101 attached txn=9f2c1b7e
2026/07/04 12:04:13 txn=9f2c1b7e decision=hold reasons=[awaiting-tr-received]
2026/07/04 12:04:19 webhook travelRuleStatusChanged status=received txn=9f2c1b7e
2026/07/04 12:04:19 txn=9f2c1b7e decision=allow reasons=[tr-complete]

Putting It Together: A Full Deposit-and-Withdrawal Walkthrough

With all fourteen steps assembled, trace two realistic transfers through the running system to see how the pieces interlock. Start all three binaries – cmd/ingest on :8080, cmd/screener against the aml-screener group, and cmd/webhookd on :8081 behind a tunnel that Sumsub can reach – and point KAFKA_BROKERS at localhost:9092.

A EU deposit from a hosted exchange

A player funds their account with 2,500 USDT on TRON from an account at another licensed exchange. The payment service POSTs the transaction to the ingest API, which produces it to txn.raw keyed by the player’s user ID and returns 202. A screener instance owning that partition polls the record inside a Kafka transaction, wins the Postgres ClaimForSubmit row, and submits the transaction to Sumsub’s KYT endpoint. Because the jurisdiction is EU and the counterparty resolves to a hosted VASP, Applies returns true regardless of amount – the zero-threshold rule – so the worker builds the IVMS101 payload from the player’s verified identity and calls AttachTravelRule. Sumsub selects a protocol (TRP here), exchanges the data with the beneficiary VASP, and the worker emits a hold decision to txn.decided, committing offset and output atomically. Seconds later a verified travelRuleStatusChanged webhook arrives with status received; the webhook daemon publishes it to tr.status, the gate flips the transfer to allow, and the deposit clears. Total human-visible latency: a couple of seconds, none of it on the payment thread.

A withdrawal to a self-hosted wallet

Now the same player withdraws 800 USDT to a wallet they control personally. Ingest and screening proceed identically, but counterparty resolution returns UnhostedWallet. There is no peer VASP to exchange with, so instead of a data exchange the flow records proof-of-control of the destination wallet. Under the EU TFR the €1,000 verification trigger is not crossed at 800 USDT, so the worker attaches the originator data Sumsub requires for the record, receives a GREEN KYT answer with a low score, and emits allow immediately – no hold, because there is no counterparty response to wait on. Had the same withdrawal been 5,000 USDT, the unhosted-wallet verification branch would have engaged and the decision would have parked in hold until control was confirmed. Two transfers, two different regulatory paths, one code path – driven entirely by the jurisdiction-and-counterparty table rather than scattered if statements. That is the payoff of resolving the rule once, in one place, at the head of the stream.

Beyond the Standard Coverage: What the Docs and SERP Leave Out

Search the primary keyword and the front page is Sumsub’s own marketing and documentation, a couple of protocol explainers (21Analytics, Blockchain Council), and a “four-layer stack” architecture note from Finconduit. Every one of them is conceptual: they define IVMS101, list protocols, and diagram a generic flow. None ships an end-to-end, runnable service, and that is precisely the gap this tutorial fills. Here is what the standard coverage omits.

Throughput and back-pressure, not just correctness

Explainers assume one transaction at a time. Nobody shows how per-user ordering survives horizontal scaling – the answer is keying txn.raw by user so a partition owns a user’s whole timeline – or how to keep a slow Sumsub call from stalling a partition. Bound Sumsub concurrency with a semaphore, set an aggressive http.Client timeout, and let the DLQ absorb the overflow rather than blocking the log.

Exactly-once against an external side effect

Kafka transactions give exactly-once within the cluster, but a KYT submission is an external, non-transactional side effect. The docs never address this seam; we close it with the Postgres ClaimForSubmit row so the external call is idempotent even when the stream redelivers. This two-layer model – transactional stream plus a claim table – is the part that actually survives a broker failover in production.

The unhosted-wallet and TRISA reality

Marketing pages gloss over the awkward cases. A self-hosted wallet has no peer VASP to exchange with, so the “exchange” becomes proof-of-control and the EU still expects verification above €1,000. And because Sumsub does not natively support TRISA, a TRISA-only counterparty needs an explicit manual/email branch in your resolver – something no happy-path diagram mentions but every compliance officer will ask about.

Replay and audit

A durable, keyed log plus a DLQ means you can reconstruct exactly what was screened and when, and re-run a fixed screener over historical partitions after a rules change. Redis-queue and synchronous designs discard that history; the commit log is the audit trail.

Common Pitfalls and Troubleshooting

These are the failures that cost the most time when wiring the Sumsub Travel Rule API into a Kafka pipeline.

Five pitfalls to avoid

  1. Signing a different body than you send. The HMAC covers the exact bytes. Marshal once, sign those bytes, send those bytes. Re-marshalling between sign and send changes field ordering and breaks the signature.
  2. Using the API secret for webhooks. The webhook digest uses a distinct webhook secret. If verification always fails with a byte-correct implementation, you are using the wrong key.
  3. Auto-committing offsets with side effects. With auto-commit, a crash after commit but before the Sumsub call silently drops a transaction from screening. Disable auto-commit and use transactions or manual commit after the side effect is durable.
  4. Assuming a €1,000 floor in the EU. The TFR is zero-threshold for CASP-to-CASP transfers. Hard-coding a de minimis check will under-report and fail an audit.
  5. Keying the topic by transaction ID. That destroys per-user ordering, letting a withdrawal be screened before the deposit that funded it. Key by user (or wallet).

Eight troubleshooting items

  • 401 from Sumsub. Check header order and the signature base string: ts + UPPER(method) + path(with query) + rawBody. Confirm the timestamp is Unix seconds and within clock skew tolerance.
  • Producer refuses to start (“transactional”). Set transaction.state.log.replication.factor and min.isr to 1 on a single-node broker; the defaults assume a three-node cluster.
  • Consumers see nothing on txn.decided. A ReadCommitted consumer hides records from open or aborted transactions. Verify sess.End is returning a successful commit.
  • Rebalances stall the pipeline on deploy. Tune session.timeout.ms and consider the KIP-848 next-gen protocol (GA in Kafka 4.0) for faster, incremental rebalances.
  • Duplicate Sumsub charges after a failover. Your external call is not idempotent – add the ClaimForSubmit row before submitting.
  • Webhook valid in Postman, fails in code. You are hashing a parsed/re-serialised body. Hash the raw request bytes with io.ReadAll.
  • Travel Rule status stuck at pending. The counterparty VASP has not responded, or it is TRISA-only (unsupported) and needs the email/manual branch.
  • Partition hot-spotting. One whale user floods a single partition. Consider a composite key (user + day) for that cohort, accepting weaker ordering guarantees for them.

Advanced Tips for Production Scale

Once the happy path runs, these adjustments make it production-grade.

Bound external concurrency

Wrap Sumsub calls in a buffered-channel semaphore sized to your rate limit so a burst on txn.raw cannot open thousands of simultaneous connections. Pair it with a circuit breaker that trips to hold (fail-closed) rather than allow when Sumsub is unreachable – an AML gate must never fail open.

Observability that a regulator will accept

Emit per-decision metrics (outcome counts, KYT latency, DLQ rate) and keep the immutable decision record in txn.decided plus the Postgres state row. Together they answer “why did this transfer clear?” months later, which is exactly the evidentiary standard AUSTRAC and the EBA expect under their 2026 regimes.

Scaling the group

Run one screener replica per partition (up to twelve here) for maximum parallelism; add partitions before you outgrow that ceiling. Because the consumer group and transactional ID are stable, you can roll a deploy without losing exactly-once guarantees.

Frequently Asked Questions

What is the Sumsub Travel Rule API and how does it differ from KYT?

KYT (Know-Your-Transaction) scores a transaction for risk; the Travel Rule API handles the regulatory data exchange – resolving the counterparty VASP, selecting a protocol, and transmitting the IVMS101 originator/beneficiary payload. In practice you submit to KYT and attach Travel Rule data for the same transaction, as Steps 9 and 10 show.

What is the Travel Rule threshold in 2026?

It depends on jurisdiction. The EU’s TFR is zero-threshold for CASP-to-CASP transfers (in force since 30 December 2024). The FATF de minimis is USD/EUR 1,000, reaffirmed in the June 2025 Recommendation 16 revision. FinCEN’s U.S. threshold is $3,000, with a proposal to lower cross-border coverage to $250.

Do I need Kafka, or can I use Redis or RabbitMQ?

For moderate volume, Redis Streams or a job queue is simpler – the FastAPI edition of this pipeline uses Redis Streams. Choose Kafka when you need high throughput, durable replay for audit, and exactly-once consume-process-produce semantics across many partitions.

What is IVMS101 and why does it matter?

IVMS101 (interVASP Messaging Standard, ratified 2020, revision 101.2023) is the common data schema every major Travel Rule protocol carries. Standardising on it means the payload is portable across TRP, Sygna, GTR, and CODE even though the transport differs.

How does Sumsub handle unhosted (self-hosted) wallets?

There is no counterparty VASP to exchange with, so instead of a data exchange the flow records proof-of-control of the wallet. Under the EU TFR, verification is required for unhosted transfers above €1,000.

Does Sumsub support TRISA?

No. Sumsub supports five protocols plus an email fallback but does not natively support TRISA. A TRISA-only counterparty is handled manually or via the email tool, so your resolver needs an explicit branch for it.

How do I guarantee a transaction is screened exactly once?

Two layers. Kafka’s transactional producer (GroupTransactSession) makes the read-process-produce loop atomic within the cluster. A Postgres claim row (ClaimForSubmit) makes the external Sumsub call idempotent, so even a redelivery after a broker failover does not double-submit.

How much does the Sumsub Travel Rule solution cost?

Sumsub does not publish Travel Rule pricing; it is quote-based and depends on volume and the modules you enable. Contact their sales team for a figure – treat any “public price” you see elsewhere with caution.

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