The Financial Action Task Force (FATF) Recommendation 16 -- the Travel Rule -- requires entities that transfer value on behalf of another person to pass originator and beneficiary identifying data alongside every qualifying transaction. Banks have lived with this since 1996. In 2019, FATF explicitly extended it to Virtual Asset Service Providers (VASPs). In 2026, regulators in the EU, UK, Singapore, Switzerland, Canada, and the US treat it as a baseline compliance requirement.
This post is for engineers building payment gateways: exact data format (IVMS101), wire protocols, a TypeScript integration pattern, and the hard cases FATF guidance glosses over.
Does the Travel Rule apply to your gateway?#
Before writing any code, confirm you are in scope. FATF defines a VASP as any entity that exchanges, transfers, safeguards, or administers virtual assets on behalf of another person. A payment gateway that temporarily holds USDC in a float account before settling to a merchant is a VASP. So is a custodial on-ramp, a fiat off-ramp, and any service that generates and controls deposit addresses on behalf of customers.
Non-custodial infrastructure -- RPC nodes, open-source wallet software, indexers -- falls outside scope. The dividing line is custody: if funds flow through an account you control, you are a VASP.
Apply a four-question test per transfer:
- Is your platform a regulated VASP/CASP in the relevant jurisdiction?
- Is the originating address custodied by a known counterparty VASP?
- Is the destination address custodied by a known counterparty VASP?
- Does the transfer amount exceed the applicable threshold?
Questions 2 and 3 drive whether you transmit, request, or simply retain Travel Rule data. Question 4 determines when obligations kick in.
Thresholds by jurisdiction#
FATF's standard de minimis is $1,000 USD (or equivalent). Several key regimes differ:
| Jurisdiction | VASP-to-VASP threshold | Self-hosted wallet |
|---|---|---|
| FATF standard | $1,000 | $1,000 |
| EU (TFR, Reg 2023/1113) | €0 — no floor | €1,000 ownership verification |
| Switzerland (FINMA) | CHF 1,000 | CHF 1,000 |
| Singapore (MAS PSN02) | SGD 1,500 | SGD 1,500 |
| UK (FCA MLR 2017) | £1,000 | £1,000 |
The EU rule is the most demanding: for CASP-to-CASP transfers, Travel Rule data must accompany every transfer with no floor at all. The €1,000 figure applies only to the enhanced obligation for self-hosted wallets (ownership verification), not to the general inter-VASP info requirement.
If you serve EU customers, code for the zero-threshold path.
The data you must send: IVMS101#
IVMS101 (Interoperability and Virtual Assets Messaging Standard 101) is the
widely adopted common data model for Travel Rule messages, published by the
Joint Working Group on interVASP Messaging Standards. It defines four
top-level objects: originatingVASP, beneficiaryVASP, originator, and
beneficiary. Transport metadata -- amount, asset, transaction hash -- sits
above IVMS101 in the wire protocol layer, not inside it.
A canonical IVMS101 payload for a natural-person originator:
{
"originatingVASP": {
"originatingVASP": {
"legalPerson": {
"name": [
{
"nameIdentifier": [
{
"legalPersonName": "BchainPay Inc.",
"legalPersonNameIdentifierType": "LEGL"
}
]
}
],
"nationalIdentification": {
"nationalIdentifier": "5493001KJTIIGC8Y1R12",
"nationalIdentifierType": "LEIX"
},
"geographicAddress": [
{ "addressType": "BIZZ", "country": "US" }
]
}
}
},
"originator": {
"originatorPersons": [
{
"naturalPerson": {
"name": [
{
"nameIdentifier": [
{
"primaryIdentifier": "Smith",
"secondaryIdentifier": "Alice",
"nameIdentifierType": "LEGL"
}
]
}
]
}
}
],
"accountNumber": ["0xA1b2C3d4E5f6c9d8E7f6a5b4c3d2e1f0"]
},
"beneficiary": {
"beneficiaryPersons": [
{
"naturalPerson": {
"name": [
{
"nameIdentifier": [
{
"primaryIdentifier": "Jones",
"secondaryIdentifier": "Bob",
"nameIdentifierType": "LEGL"
}
]
}
]
}
}
],
"accountNumber": ["0xB9c8D7e6F5a4b3c2D1e0f9a8B7c6d5e4"]
}
}A few field semantics worth knowing:
legalPersonNameIdentifierType: "LEGL"= registered legal name;SHRT= short name;TRAD= trading name.nationalIdentifierType: "LEIX"= LEI (ISO 17442 20-character code). Also valid:RAID,DUNS,MISC. LEI is not strictly mandatory by the IVMS101 spec but is expected in practice -- every regulated VASP should have one.accountNumberis an array; a customer can legitimately have multiple deposit addresses on file.- For a legal-entity originator, replace
naturalPersonwithlegalPersonand supplynameandnationalIdentificationat that level.
The beneficiaryVASP block mirrors originatingVASP and identifies the
receiving platform. You populate it after counterparty VASP discovery.
Wire protocols: TRISA, TRP, and hosted platforms#
FATF mandates information exchange but not the mechanism. Three approaches cover most real flows in 2026:
| Approach | Model | Typical latency | Coverage |
|---|---|---|---|
| TRISA (protocol) | mTLS peer-to-peer; each VASP runs a node | 200 ms – 2 s | ~100+ VASPs; directory via TRISA Global Trust Directory |
| TRP (protocol) | REST + W3C DIDs; Fireblocks-backed | 50 ms – 1 s | Strong among institutional CeFi |
| Notabene / Sygna / 21A (SaaS platforms) | Hosted intermediary; implement TRISA/TRP on your behalf | 200 ms – 500 ms | Largest combined directory; 70-80% counterparty reach |
TRISA and TRP are open protocols you can implement yourself. Notabene, Sygna, and 21Analytics are commercial platforms that implement one or more of those protocols and manage counterparty directories for you. For a new gateway the practical path is: start with a SaaS platform for broad coverage, add a direct TRISA node connection if you have high volume with a specific counterparty.
Integration pattern (TypeScript, REST)#
interface TravelRuleTransfer {
asset: string;
amountMinorUnits: bigint;
fromAddress: string;
toAddress: string;
txHash: string;
senderKyc: KycRecord;
recipientKyc: KycRecord;
}
interface TrResult {
id: string;
status: "ACCEPTED" | "REJECTED" | "PENDING" | "WAITING";
}
async function sendTravelRuleMessage(
transfer: TravelRuleTransfer
): Promise<TrResult> {
const counterpartyDid = await resolveBeneficiaryVasp(transfer.toAddress);
const payload = {
transactionAsset: transfer.asset,
transactionAmount: transfer.amountMinorUnits.toString(),
transactionBlockchainInfo: {
origin: transfer.fromAddress,
destination: transfer.toAddress,
txHash: transfer.txHash,
},
originatorVaspDid: process.env.OWN_VASP_DID!,
beneficiaryVaspDid: counterpartyDid,
ivms101: buildIvms101(transfer), // canonical payload above
};
const res = await fetch(`${process.env.TR_PLATFORM_BASE}/v1/transfers`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TR_PLATFORM_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new TravelRuleError(await res.text(), res.status);
}
return res.json();
}
async function resolveBeneficiaryVasp(address: string): Promise<string> {
// Most platforms expose a directory lookup endpoint
const res = await fetch(
`${process.env.TR_PLATFORM_BASE}/v1/vasps/lookup?address=${encodeURIComponent(address)}`,
{ headers: { Authorization: `Bearer ${process.env.TR_PLATFORM_TOKEN}` } }
);
if (!res.ok) return "UNKNOWN";
const { did } = await res.json();
return did ?? "UNKNOWN";
}Plug the check into the payment intent pipeline after address screening (OFAC post) and before chain submission:
const TRAVEL_RULE_THRESHOLD_USD = Number(process.env.TR_THRESHOLD_USD ?? "1000");
async function handlePaymentIntent(intent: PaymentIntent) {
const usdValue = await toUSD(intent.amount, intent.asset);
// EU CASPs: use 0 for VASP-to-VASP; others: 1000
const threshold = isEuJurisdiction(intent) ? 0 : TRAVEL_RULE_THRESHOLD_USD;
if (usdValue >= threshold) {
const sender = await requireKyc(intent.senderId);
const recipient = await requireKyc(intent.recipientId);
const tr = await sendTravelRuleMessage(
buildTransfer(intent, sender, recipient)
);
await db.intents.update(intent.id, {
travelRuleRef: tr.id,
travelRuleStatus: tr.status,
});
if (tr.status === "REJECTED") {
return { status: "blocked", reason: "travel_rule_counterparty_rejected" };
}
}
return submitToChain(intent);
}Un-hosted wallets: the hard case#
When the destination address belongs to a self-hosted (non-custodial) wallet, there is no counterparty VASP inbox to receive your IVMS101 message. FATF's 2021 updated guidance and national implementations diverge here:
- FATF baseline: collect and retain originator data; apply enhanced due diligence above the threshold.
- EU TFR (Art. 14-15): for transfers to or from a self-hosted address above €1,000, take adequate measures to verify the address is controlled by the originator or beneficiary. A signed-message challenge is the standard implementation.
async function requestOwnershipProof(
walletAddress: string,
userId: string
): Promise<OwnershipChallenge> {
const nonce = `bchainpay-tr-${crypto.randomUUID()}`;
await db.ownershipChallenges.insert({
userId,
walletAddress,
nonce,
expiresAt: new Date(Date.now() + 600_000), // 10 min
});
// Front-end prompts: eth_sign(nonce) or Solana signMessage(nonce)
// POST /v1/wallets/verify validates the returned signature on-chain
return { nonce, expiresInSeconds: 600 };
}Store the signed challenge and the verified wallet-address association in your compliance record; regulators will ask for it on audits.
Structuring detection#
Linked transactions that individually fall below the threshold but are clearly part of a single economic event must be treated as one. Build a sliding-window aggregator: sum all transfers from the same originator to the same destination within a 24-hour window, and trigger the Travel Rule check when the aggregate crosses the threshold.
Sunrise issue and fallback policy#
Many VASPs have not yet built Travel Rule receiving infrastructure. When
counterparty VASP lookup returns "UNKNOWN":
- Hold and retry: pause the transaction for up to 10 minutes while retrying the directory and the TRISA Global Trust Directory.
- Out-of-band attempt: look up the counterparty's published Travel Rule contact address. Send via a secure, encrypted channel only -- never transmit PII over plain email, Slack, or general support tickets.
- Reject above your ceiling: for amounts above $10,000 with no counterparty resolution after the hold window, decline the transfer and ask the sender to use an alternative destination.
Encode the policy in config so compliance can adjust thresholds without a deployment:
{
"travelRule": {
"thresholdUSD": 1000,
"euCasp2CaspNoFloor": true,
"unknownVasp": {
"holdMs": 600000,
"maxAmountAutoRelease": 10000,
"secureEmailFallback": true
}
}
}Audit log#
Regulators require a durable record of every Travel Rule message sent and received. Minimum retention is 5 years under FATF guidance and major national regimes; confirm local law, which can be longer.
CREATE TABLE travel_rule_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_intent_id UUID NOT NULL REFERENCES payment_intents(id),
direction TEXT NOT NULL CHECK (direction IN ('OUTBOUND','INBOUND')),
counterparty_vasp_did TEXT,
ivms101_encrypted BYTEA NOT NULL, -- AES-256-GCM, KMS-managed key
protocol TEXT NOT NULL CHECK (
protocol IN ('TRISA','TRP','NOTABENE','SYGNA','EMAIL','HELD')
),
tr_status TEXT NOT NULL CHECK (
tr_status IN ('ACCEPTED','REJECTED','PENDING','WAITING','UNRESOLVED')
),
amount_usd NUMERIC(18,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON travel_rule_records (payment_intent_id);
CREATE INDEX ON travel_rule_records (created_at);Store ivms101_encrypted rather than plaintext -- this object contains full
PII. Encrypt with the same KMS key used for your hot-wallet secrets (see the
key management post).
Key takeaways#
- A gateway that holds funds in transit is a VASP; the Travel Rule applies.
- EU CASPs must send IVMS101 on all CASP-to-CASP transfers with no floor; most other jurisdictions use the $1,000 or equivalent threshold.
- IVMS101 is the canonical data model:
originatingVASP,beneficiaryVASP,originator,beneficiary. Amount and asset live in the wire protocol layer above it, not inside the IVMS101 object. - SaaS platforms (Notabene, Sygna) offer the widest counterparty coverage fastest; TRISA and TRP are the underlying open protocols.
- Un-hosted wallets require originator data retention; EU TFR Art. 14-15 mandates ownership verification above €1,000.
- Build a hold / retry / reject policy for sunrise cases; never transmit PII over unencrypted channels.
- Audit-log every message for at least 5 years, with PII encrypted at rest using your KMS-managed key.