Skip to main content
Didit runs the FATF/EU Transfer of Funds Regulation (TFR) originator-beneficiary exchange for virtual asset transfers: it looks for a rail that reaches the counterparty VASP, negotiates the required data over it, tracks the exchange through a 13-state lifecycle, and tells you when the exchange has concluded so you can release funds on-chain.

Overview

The FATF Travel Rule (Recommendation 16), implemented in the EU as the Transfer of Funds Regulation, requires a sending Virtual Asset Service Provider (the originator VASP) to collect and transmit originator and beneficiary information to the receiving VASP (the beneficiary VASP) before a virtual asset transfer settles. Unlike most jurisdictions, the EU TFR applies with no minimum amount - every crypto transfer is in scope, not just transfers above a reporting threshold. Didit’s Travel Rule product is a managed exchange, not just a data field:
  • You submit a transfer with transaction_category: "travelRule".
  • Didit tries the rails in priority order to reach the counterparty VASP, runs the exchange over the first one that reaches it, and, where the other side returns a beneficiary name, matches it against the one you sent.
  • Only once the exchange reaches COMPLETED should you broadcast the transfer on-chain.
  • You then report the on-chain transaction hash back to Didit to close the exchange out as FINISHED.
Seven rails ship in this release, tried in strict priority order - the first one that can reach the counterparty wins: The network rails (GTR, TRUST, VERIFYVASP, SYGNA) need a network membership on the sending side, and today that membership has to be your own. Connect your membership credentials in the console Marketplace (Travel Rule networks category), and your transfers travel under your own network identity. Didit holds no platform membership for any of the four networks today, and is onboarding as a provider on them. Until that onboarding completes, platform_available is false for all four, the settings didit mode (routing over Didit’s platform membership) is not available for any network, and disabled_networks has nothing to switch off. A membership you connect yourself always takes precedence over a platform membership. No network membership is included by default, including GTR. Enabling Travel Rule does not activate GTR, TRUST, VerifyVASP, or Sygna. Check Get Travel Rule Settings for your application’s effective networks modes. If all four report unavailable, none can route an exchange: connect your own membership in Marketplace, or use a reachable INTERNAL, TRP, or EMAIL counterparty. Even with your own membership, a network rail routes a transfer only when its beneficiary_data carries the counterparty’s id on that network (counterparty_gtr_vasp_code, counterparty_trust_membership_id, counterparty_verifyvasp_id, or counterparty_sygna_vasp_code), or the counterparty’s directory entry lists it on that network (see Routes). platform_available reports whether platform membership is configured, not a live network health check. Similarly, connected means your membership credentials are saved and have passed local validation; the first live exchange verifies them against the network. A didit or own mode does not guarantee counterparty reachability or successful delivery. Even with your own membership, GTR, TRUST, and VERIFYVASP only send today: Didit delivers your message over the network, but it does not process the counterparty’s reply yet. A transfer sent over one of them stays in AWAITING_COUNTERPARTY until confirmation_timeout_hours elapses, then moves to EXPIRED and your timeout_outcome applies. Among the network rails, only SYGNA processes the counterparty’s answer today, through Didit’s Sygna callback. Further networks - CODE (the Korean alliance), TRISA, Notabene, and Veriscope - are listed in the console Marketplace as request-access tiles. Didit has no integration with them yet, so no transfer can route over them today. When none of the seven rails can resolve a counterparty, Didit runs one more resolution step before giving up: blockchain analytics attribution. The destination wallet is looked up against blockchain analytics data; when the wallet belongs to a known exchange or custodian, Didit records the attributed VASP on the transfer, adds it to the directory, and re-runs the rails once with the new name. See Blockchain analytics attribution for the full behavior and pricing. A transfer whose destination is declared as a self-hosted wallet skips all seven rails - there is no counterparty VASP to exchange with - and goes straight to end-user wallet-ownership proof.
Didit also operates the counterparty-facing side of the exchange: public TRP identity and inquiry endpoints other VASPs call to reach you, callback endpoints, and the email pickup flow. You never call those yourself - they are managed infrastructure - but the Travel Rule interoperability page documents every one of them so counterparty VASPs can connect to you. You interact with Travel Rule only through the /v3/ endpoints described on this page.

What executes when you submit a transfer

Everything below runs synchronously inside the one POST /v3/transactions/ call - by the time you receive the response, screening has run, routing has been attempted, rules have been evaluated, and the transfer already has a definitive status. There is no “pending pipeline” to poll for; the only asynchronous parts are the counterparty’s answer and any end-user action. Step by step:
  1. Validation and subject binding. The payload is validated. A transaction_id already used in your application is rejected with 400, and when two requests with the same id race, one of them gets 409. The subject is linked to your user or business profile by vendor_data, so the transfer shows up on that profile for monitoring. A blocklisted subject or counterparty vendor_data is rejected with 400, and no transaction is created. Blocklisted wallets, bank accounts, IPs, and devices are checked in step 6 and decline the transaction there, after routing has run.
  2. Device and network enrichment. Party IPs are geo-enriched, and any device context (automatic on SDK submission) is normalized into the device graph - the same graph KYC sessions feed, so “related by device” works across onboarding and transactions.
  3. Crypto screening. For crypto transfers with monitoring enabled, the relevant wallet (or on-chain hash) is screened against blockchain analytics before anything is exchanged - sanctions exposure, mixer proximity, stolen funds. See Crypto & wallet screening.
  4. Travel Rule routing. The exchange engine checks the threshold, honors a self-hosted wallet declaration, then walks the seven rails in priority order; if none resolve, blockchain analytics attribution identifies the counterparty and the rails re-run once. Whatever the outcome, the transfer leaves this step with a concrete status: AWAITING_COUNTERPARTY, NOT_APPLICABLE (below threshold), UNCONFIRMED_OWNERSHIP, COMPLETED or COUNTERPARTY_MISMATCHED_DATA (INTERNAL rail), COUNTERPARTY_VASP_NOT_REACHABLE, or COUNTERPARTY_VASP_NOT_FOUND. Each status change queues a travel_rule.status.updated event before transaction.created is queued, so these events usually arrive first, but delivery order is not guaranteed.
  5. Wallet-ownership minting. If the transfer needs end-user proof of control and auto_wallet_verification is on, a wallet-ownership widget session is minted and returned in action_required.
  6. Rules evaluation. Every active rule runs - the EU TFR bundle reacts to the transfer status set in step 4, your custom rules see the screening output of step 3, and a rule can hold the transaction (IN_REVIEW), decline it, or demand step-up verification (AWAITING_USER).
  7. Billing and notification. For an outbound transfer, the $0.02 Travel Rule line is recorded, whatever the routing outcome. The response returns with the full picture, and transaction.created fires to your webhook destinations.

How each step maps to your obligations

Testing note. Sandbox applications skip rule evaluation, webhooks, and billing entirely - sandbox transactions stay APPROVED (until a transfer expires, see below) and nothing is charged. The Travel Rule exchange engine itself still runs on sandbox applications when enabled - the transfer is created and moves through its statuses - but outbound dispatch is simulated. No rail is selected and no compliance message ever reaches a real counterparty VASP, so an outbound transfer that reaches routing parks at AWAITING_COUNTERPARTY and then expires after its confirmation deadline, which applies your timeout_outcome (by default the transaction moves to IN_REVIEW). Inbound registration (POST /v3/travel-rule/inbound/) never dispatches, so sandbox resolves it against your wallet address book and settles it for real - ON_HOLD, UNCONFIRMED_OWNERSHIP, COUNTERPARTY_MISMATCHED_DATA, or COMPLETED then FINISHED. See Transaction Monitoring in sandbox.

Lifecycle

Every transfer has a status drawn from 13 values. The exchange starts at AWAITING_COUNTERPARTY in the normal case, at NOT_APPLICABLE when the amount is below your threshold_amount, or at UNCONFIRMED_OWNERSHIP when the destination was declared a self-hosted wallet, and ends in one of the terminal states: FINISHED, CANCELLED, or EXPIRED. The transfer runs its own state machine, separate from the underlying transaction’s review status. An exchange status never changes the transaction status by itself, with one exception: an expiry applies your configured timeout_outcome (or a sunrise or non-responsive policy when one applies). Your rules re-run on every transition instead, so the EU TFR rules or any rule keyed on travel_rule_status can move the transaction, for example to IN_REVIEW. A transfer that needs end-user proof of wallet control adds an action_required block to the transaction, but its status stays as your rules left it. Read the exchange state from travel_rule.status; read the allow / hold / decline verdict from the transaction’s own status. Every transition (and every passively reported status) is also mirrored onto the transaction’s custom values so rules can key on it: custom_values.travel_rule_status (canonical status), custom_values.travel_rule_rail (the rail that carried the exchange, empty until one is selected), and custom_values.travel_rule_regime (derived from your settings jurisdiction: SG → singapore, GB → uk, and any EU or EEA member code such as DE or FR → eu; unmapped codes stamp lowercased, e.g. US → us. A props.travel_rule_regime you submit yourself always wins over the derived value).
Never broadcast before COMPLETED. Releasing funds on-chain while a transfer is AWAITING_COUNTERPARTY, UNCONFIRMED_OWNERSHIP, or in any mismatch/decline state defeats the purpose of the exchange. Gate your broadcast step on status == "COMPLETED", then call PATCH .../travel-rule/ with the resulting payment_txn_id to move to FINISHED.
The transitions at a glance (CANCELLED is additionally reachable from every non-terminal state with action: "cancel"):

Legacy status values

If you were already sending travel_rule_details.status before this release, those values still work and are mapped automatically. camelCase spellings of any status (for example counterpartyVaspNotFound) are accepted too, both on submission and in rule condition values, and normalize to the canonical form:

Setup

Configure your VASP profile and negotiation policy once per application with GET/PUT /v3/travel-rule/settings/.
PUT is a partial update - send only the fields you want to change.

Name matching strictness

Ownership validation modes

ownership_validation_mode decides how much proof a wallet’s ownership takes before a transfer relies on it: A wallet is remembered per end user once it is verified. In FULL and SIMPLIFIED, a later transfer reuses it only when a proof the mode accepts for that transfer backs it. When inbound_auto_accept has no address-book wallet to match, a crypto wallet you recorded on exactly one end user’s transactions identifies the holder. Didit adds that wallet to the end user’s address book as your self-declaration, verified unless the mode is FULL.

Data sharing

data_sharing_fields chooses which fields Didit sends to the counterparty about your own users, separately for individuals and companies:
  • Individuals can share name, wallet_address, address, national_id, customer_identification, date_of_birth, and place_of_birth. Companies use the first five.
  • name and wallet_address are always sent.
  • An entity type you never chose shares every field. An empty list is a choice too: it shares the name and the wallet only.
  • A PUT merges per entity type, so sending individual leaves the stored company choice alone.
  • A choice that cannot meet the originator obligations of your jurisdiction regime (EU TFR or FATF Recommendation 16), an empty list included, is refused with 400. The errors sit under data_sharing_fields, keyed by entity type.

Per-field matching

matching_rules sets how strictly each field of the counterparty’s data is compared with what you hold:
Each value is NONE, STRICT, CONTAINS, DEFAULT, or FUZZY. Defaults: name follows name_matching_strictness, date_of_birth and address are NONE, and account is STRICT. A field left out keeps its default, and a field the entity type does not compare (a company has no date_of_birth) is refused with 400.

Thresholds by jurisdiction

threshold_amount exists because Travel Rule scope varies by regulator. Didit defaults to 0.00 - the EU position - so every transfer is exchanged unless you raise it deliberately: Set threshold_amount to your regulator’s figure; transfers below it resolve to NOT_APPLICABLE without exchanging any identity data. Thresholds evolve - confirm the current figure with your compliance counsel rather than treating this table as legal advice.

IVMS 101 data model

The industry standard for the data exchanged under the Travel Rule is IVMS 101 (interVASP Messaging Standard). travel_rule_details.originator_data and travel_rule_details.beneficiary_data are IVMS 101-style person payloads: Didit wraps them in each rail’s native envelope - the TRP rail exchanges IVMS101 blocks (ivms101.2023, reported in the transfer’s ivms_version field), the Sygna rail its ECIES-encrypted IVMS variant, and the GTR, TRUST, and VERIFYVASP rails their own encrypted envelopes - so one payload shape reaches every counterparty in the format their protocol expects. Didit reads these keys from each person payload when it builds the IVMS 101 message: first_name and last_name (or a single name or full_name), date_of_birth, place_of_birth, residence_country (or country), vendor_data (or customer_identification), and wallet_address. Two practical rules:
  • Latin script. IVMS 101 requires Latin-script values; transliterate names that are natively written in another script. Didit’s name matching compares normalized token sets, so ordering and casing differences don’t fail an exchange - but different scripts on the two sides will.
  • Send data you have verified. On the rails that carry person data (INTERNAL, TRP, and the network rails), Didit transmits exactly the originator_data you send; it does not fill it in from the KYC profile behind vendor_data. Build the payload from the identity you verified at onboarding (for example the document-extracted name, date of birth, and address of a Didit-verified user), not from an unverified form field. That is what the FATF’s “required and accurate originator information” wording demands. On the INTERNAL rail, the beneficiary’s application also receives the subject you sent (name, vendor_data, date of birth, country, and address) as the counterparty party of its inbound transaction.

Travel addresses

A travel address is a compact, encoded string that points at a VASP’s inbound TRP inquiry endpoint - it never exposes a raw URL or any wallet address. Once Travel Rule is enabled, Didit derives your own travel_address (returned read-only in settings) so you can share it with other VASPs that reach you on the TRP rail. When you know a counterparty’s travel address, store it on the destination wallet address book entry so Didit routes that beneficiary’s transfers straight over TRP instead of falling back to email. Didit validates the travel addresses you store on a wallet address book entry or send in originating_vasp: each must start with ta and decode to an inquiry URL carrying t=i, so a placeholder such as ta... returns 400. A beneficiary_data.travel_address on a transaction is not validated: if it does not decode, the TRP rail is skipped. The encoding is Base58Check with a ta prefix: the endpoint host and path (which always carries the query pair t=i) plus a four-byte double-SHA-256 checksum, base58-encoded. A real address therefore looks like this, and runs to roughly 100 characters:
Where Didit validates a travel address, a value that is not a well-formed, checksum-valid address returns 400 with "Invalid travel address.". Take a real address from your own settings response, or from the counterparty itself.

Testing without network credentials

You do not need a GTR, TRUST, VerifyVASP, or Sygna membership to test the membership-free paths. You still need a Didit API key for the application under test.
  • Sandbox inbound: enable Travel Rule with a legal name, register a destination wallet in the address book, then call Register Inbound Travel Rule Transfer. Test an unknown wallet, an unverified wallet, a beneficiary-name mismatch, and a verified matching wallet. These exercise real inbound decisions without contacting a network.
  • Sandbox outbound: submit a travelRule transaction. After threshold and self-hosted-wallet checks, dispatch is simulated and ordinarily waits at AWAITING_COUNTERPARTY. No network, email, or internal counterparty receives it; this does not prove delivery or completion of a two-party exchange.
  • Two controlled live applications: use the INTERNAL rail between your own applications, or a controlled TRP counterparty, to test the exchange lifecycle without commercial network credentials. Live applications can incur normal charges and notifications; use test identities and counterparties you control.
See Transaction Monitoring in sandbox for the exact boundaries. Successful sandbox or local protocol tests do not validate a commercial network’s credentials, membership, or production interoperability. Those require an authorized network sandbox or a controlled live exchange with valid membership credentials.

Sending a transfer

Include the transfer on POST /v3/transactions/ by setting transaction_category to travelRule (or travel_rule) and attaching a travel_rule_details object (alias: travelRule). The travel_rule_details block itself is optional, but once you attach it, travel_rule_details.status is required: a block without it returns 400. When Travel Rule is enabled for your application, Didit ignores the status value you send and drives the exchange itself:
  1. If the amount is below your configured threshold_amount, the transfer resolves immediately to NOT_APPLICABLE and nothing is negotiated.
  2. If the counterparty’s payment method declares the destination as a self-hosted wallet (payment_method.method_type: "unhosted_wallet"), no rail is tried - the transfer goes straight to UNCONFIRMED_OWNERSHIP and the wallet-ownership widget is auto-minted (see Declared self-hosted wallets).
  3. Otherwise the rails run in priority order, and the first one that can reach the counterparty carries the exchange:
    • INTERNAL - beneficiary_data.wallet_address is registered in the wallet address book of another Didit customer that is a member of the Didit network.
    • TRP - the transfer’s asset has a Digital Token Identifier mapping (BTC and ETH today), and the payload carries beneficiary_data.travel_address, the destination’s own address-book entry stores one, or the counterparty resolves in the directory (by counterparty_vasp_lei or counterparty_vasp_name) with a TRP endpoint.
    • GTR, TRUST, VERIFYVASP, SYGNA - you connected your own membership for that network in Marketplace, and the counterparty resolves to a network id (explicit field or directory entry - see Counterparty resolution fields). Didit holds no platform membership on these networks yet (it is onboarding as a provider), so disabled_networks does not change this step today.
    • EMAIL - beneficiary_data.counterparty_vasp_email was supplied, or counterparty_vasp_name resolves to a directory entry with a compliance email.
  4. If no rail can resolve a counterparty and vasp_attribution_enabled is on, Didit runs blockchain analytics attribution: the destination wallet is resolved to its owning VASP, the attributed name is written to beneficiary_data.counterparty_vasp_name (when empty) and to the directory, and the rails re-run once - so a wallet attributed to a VASP whose directory entry carries a TRP endpoint, network id, or compliance email routes on this second pass.
  5. If the counterparty is still unresolved, the transfer is created in COUNTERPARTY_VASP_NOT_FOUND. On outbound transfers Didit auto-mints the wallet-ownership widget (when auto_wallet_verification is on) so the transfer can still complete as a self-hosted-wallet transfer, and you can re-run routing later with action: "resend" once you have better counterparty data.

Counterparty resolution fields

Everything Didit knows about the counterparty comes from travel_rule_details.beneficiary_data. These fields drive rail resolution; all are optional:

Blockchain analytics attribution

When you pass nothing but a wallet address, Didit can still find out who is on the other side. If no rail can route the transfer, Didit resolves the destination wallet against blockchain analytics attribution data - the same analysis that powers crypto transaction screening knows which wallets belong to which exchanges and custodians. Attribution runs only on outbound transfers that carry both a destination wallet and a chain (see Counterparty resolution fields); without a chain it is skipped. When the wallet is attributed to a known entity, Didit:
  1. Records the attribution on the transfer: beneficiary_data.counterparty_vasp_name is filled (when you did not supply one), and the transfer’s metadata carries a vasp_attribution block with the entity name, entity type, country, and source: "blockchain_analytics".
  2. Adds the entity to the VASP directory (or matches the existing entry), so the attribution is stable across transfers.
  3. Re-runs the rails once with the attributed name - a directory entry that already carries a TRP endpoint makes the transfer route immediately instead of landing in COUNTERPARTY_VASP_NOT_FOUND.
The attribution call doubles as a full crypto screening: the transaction receives the complete screening output - risk score, wallet risk category, network snapshot - exactly as if crypto monitoring had run, and the transaction’s custom_values.aml_screening_trigger is set to travel_rule_attribution so you can see the screening ran because Travel Rule routing required it. When crypto monitoring already screened the same wallet on this transaction, the stored result is reused - no second analytics call, no second charge. Attribution never blocks transaction creation: if the analytics lookup fails or your balance cannot cover it, the transfer simply proceeds to COUNTERPARTY_VASP_NOT_FOUND and nothing is charged for the attempt. On action: "resend", a previously recorded attribution is reused instead of re-queried. Disable the whole step with vasp_attribution_enabled: false in settings. See Pricing for how the attribution call is billed. When Travel Rule is disabled, travel_rule_details (including a caller-supplied status, legacy or current) is still recorded on the transaction, but passively - no rail is dispatched. Use this to backfill compliance data you already tracked with your own process. An outbound passive transfer is still billed the Travel Rule line. With Travel Rule enabled, this request routes over the EMAIL rail, because it names the counterparty’s compliance email and no earlier rail can reach the beneficiary:
The response, abridged to the fields that matter here:
  • originator_data is stored exactly as you sent it; Didit does not fill it from the KYC profile. The EMAIL rail does not show it to the recipient: the pickup endpoint returns only the transfer summary.
  • beneficiary_data.chain is ETH because the request named no chain, so Didit took it from the currency.
  • status is APPROVED because no rule acted on the transaction; your own rules may set another status.
  • The compliance contact receives the pickup link, and the transfer completes, fails the name match, or declines when they answer, or expires if they do not (see Email pickup).
If the beneficiary wallet is already a verified entry in another Didit customer’s address book (with the same chain or no chain) and its holder_name matches, the INTERNAL rail resolves the whole exchange synchronously instead, and the response shows "status": "COMPLETED" with "protocol": "INTERNAL". Without counterparty_vasp_email, and when neither the rails nor attribution can reach the beneficiary, the same request ends in COUNTERPARTY_VASP_NOT_FOUND with a wallet_ownership action.
The transaction’s embedded travel_rule object is intentionally short (uuid, status, protocol, required, obligations_count, originator_data, beneficiary_data, metadata). The dedicated transfer endpoints below return a fuller object that also includes direction, rail, ivms_version, ownership_confirmed, deadline_at, timeline, counterparty_vasp, and created_at. That object carries no obligations_count or metadata.
To close out a COMPLETED outbound transfer once you’ve broadcast it on-chain:
This requires the transfer to be an outbound transfer in COMPLETED status - otherwise the API returns 409. On success both the outbound transfer and its INTERNAL-rail counterpart move to FINISHED, and the transaction’s payment_txn_id is set to the hash you provided. On the TRP rail, Didit also posts the hash to the counterparty’s confirmation callback. To cancel a transfer that hasn’t reached a terminal state yet, send {"action": "cancel"} to the same endpoint instead. The INTERNAL-rail counterpart is cancelled too, and on the TRP rail, once the counterparty has approved the inquiry, Didit posts the cancellation to its confirmation callback. A transfer already in FINISHED, CANCELLED, or EXPIRED returns 409. A request with neither payment_txn_id nor a supported action returns 400. To retry an outbound transfer that could not reach its counterparty - one currently in COUNTERPARTY_VASP_NOT_FOUND, COUNTERPARTY_VASP_NOT_REACHABLE, or NOT_ENOUGH_COUNTERPARTY_DATA - first improve the counterparty data, for example by adding a travel_address to the destination wallet entry. Then send {"action": "resend"} to the same endpoint. Didit restarts the confirmation deadline and re-runs the routing ladder from the start, using the data stored on the transfer. Any other status, or an inbound transfer, returns 409.

Declared self-hosted wallets

When your customer tells you the destination is their own self-hosted (unhosted) wallet, declare it on the transaction instead of letting routing discover there is no VASP behind it: set the counterparty’s payment method type to unhosted_wallet.
The declaration short-circuits routing entirely:
  • No rail is tried - there is no counterparty VASP to exchange with - and the transfer is created directly in UNCONFIRMED_OWNERSHIP.
  • When auto_wallet_verification is on (the default), Didit auto-mints a wallet-ownership widget session bound to the transaction and returns it in the transaction’s action_required block; the SDKs launch it automatically on client-side submission.
  • Once the customer proves control of the wallet, the transfer runs the beneficiary name match and advances to COMPLETED (or COUNTERPARTY_MISMATCHED_DATA on a failed match).
This is the EU TFR self-hosted wallet obligation in API form: verify ownership of the unhosted wallet instead of exchanging data with a counterparty VASP. The declaration is read from the payment method on the destination side of an outbound transfer and the source side of an inbound one - in both cases, the counterparty participant. A declared-unhosted transfer is already on its final rail-free path, so UNCONFIRMED_OWNERSHIP is not a resendable state - complete the ownership proof (or cancel) instead of calling action: "resend".

Receiving

Confirming wallet ownership

When the INTERNAL rail resolves a transfer to a wallet address whose address-book entry hasn’t been ownership-verified, the beneficiary side is created in UNCONFIRMED_OWNERSHIP. The beneficiary’s application confirms or denies it:
The body takes confirmed (required, boolean) and an optional wallet_address. The endpoint accepts a transfer in any of these states, and returns 409 with {"detail": "Transfer is not awaiting ownership confirmation."} for anything else:
  • UNCONFIRMED_OWNERSHIP, in either direction.
  • ON_HOLD on an inbound transfer.
  • UNCONFIRMED_OWNERSHIP, ON_HOLD or COUNTERPARTY_MISMATCHED_DATA on an inbound TRP transfer, so a compliance officer can resolve a counterparty inquiry by hand.
On a transfer that Didit created itself (INTERNAL rail, sunrise registration, or a declared self-hosted wallet):
  • confirmed: true marks the matching wallet address book entry as ownership-verified, then runs the name match against that entry’s holder_name - COMPLETED if it passes, COUNTERPARTY_MISMATCHED_DATA if it doesn’t. The INTERNAL-rail counterpart, when there is one, gets the same outcome. An inbound transfer whose deposit has already settled then moves straight on to FINISHED.
  • confirmed: false moves both sides of the transfer to COUNTERPARTY_VASP_GENERAL_DECLINE.
  • The matching entry is looked up in your own address book by wallet address alone, without the chain. Resolving an inbound ON_HOLD with no matching entry creates the entry from the transfer’s beneficiary_data.wallet_address and marks it verified. In every other case, a missing entry returns 409 with {"detail": "wallet address entry no longer available"}, which is also what a deleted entry returns. On an outbound declared self-hosted transfer, that means the call succeeds only if the destination wallet is already in your own address book; the wallet-ownership widget creates the entry itself.
On an inbound TRP transfer the same call answers the counterparty’s inquiry:
  • confirmed: true needs a payment address to return, taken from the request’s wallet_address, then the matched entry, then beneficiary_data.wallet_address. With none of the three the request returns 400 with {"wallet_address": ["wallet_address is required to approve."]}. Didit creates or verifies the entry, sends the inquiry-resolution callback to the counterparty, sets ownership_confirmed, and moves the transfer to COMPLETED.
  • confirmed: false sends a declined resolution and moves the transfer to COUNTERPARTY_VASP_GENERAL_DECLINE.
  • If the counterparty’s callback endpoint cannot be reached, the request returns 502 and the transfer keeps its current status. On an approval, the wallet address book entry is still created or marked verified.
The response is the fuller transfer object described above. An INTERNAL transfer from another Didit customer also needs a control proof (a signed message or a test deposit) before it can be confirmed, and answers 409 until it has one.

Masked counterparty data

Until wallet ownership is confirmed, the counterparty’s personal data on an inbound transfer comes back masked rather than left out. The first letter of each word is kept, and the wallet, chain, and VASP routing stay readable. data_masked is true, and the counterparty party in the transaction detail, list, and CSV export is masked the same way. Once ownership is confirmed the real values appear and data_unmasked records the audit entry:
reason is ownership_confirmed, wallet_proof, address_book_match, own_verified_wallet, or inquiry_approved. actor is the console user’s email or the API key label, and blank for an automatic decision.

Mismatch feedback

A COUNTERPARTY_MISMATCHED_DATA transfer now names the fields that differ. data_match holds the comparison and mismatched_fields lists the fields. Both carry field names and results only, never values, so they stay readable while the data is masked:
The timeline entry names the fields, for example “beneficiary date of birth does not match wallet holder”. A TRP rejection after a mismatch tells the originating VASP which fields differ, for example “Beneficiary data does not match the wallet holder: name”.

Messages and document requests

Two VASPs can resolve a disputed transfer inside Didit instead of by email. GET and POST /v3/transactions/{transaction_id}/travel-rule/messages/ list and send messages on a transfer:
  • kind is MESSAGE (default) or DOCUMENT_REQUEST. A document request needs at least one entry in requested_documents (up to 10); a message needs a body (up to 5000 characters) or a file.
  • reply_to names an earlier message of the same transfer. A reply that carries a document fulfils the document request it answers, and fulfilled_at is set on it.
  • Send multipart with files to attach up to 5 files of 10 MB each.
  • A transfer holds up to 100 messages; beyond that the request answers 400.
  • Messages work over the INTERNAL and EMAIL rails. On INTERNAL, the other Didit customer sees the message on its side of the pair. On EMAIL, the counterparty is emailed and answers in the pickup portal.
  • The request answers 409 with a reason in detail for a transfer over TRP, GTR, TRUST, VerifyVASP, or Sygna (those protocols carry no messages), a transfer with no counterparty yet, an INTERNAL transfer whose data did not match the wallet holder (nothing reached the recipient), and an EMAIL transfer whose pickup link has expired or whose transfer is closed.
  • No webhook fires when the counterparty writes back. Read the messages endpoint.
  • The email telling the counterparty about a new message goes out in English.

Wallet address book

Register the wallets you control so inbound transfers (INTERNAL rail, inbound TRP inquiries, and after-deposit registrations) can resolve against them, with POST/GET /v3/travel-rule/wallet-addresses/ and PATCH/DELETE /v3/travel-rule/wallet-addresses/{entry_uuid}/.
PATCH accepts holder_name, holder_vendor_data, entity_type, and travel_address, and returns only those four fields. DELETE deletes the entry (204) - its address is freed up for re-registration and it immediately stops matching new transfers.
Matching is case-insensitive on the address and scoped by chain. When a transfer carries a chain, an entry matches only if its chain is the same value (case-insensitive) or empty. Chain names are compared as text, not translated, so an entry stored as ethereum does not match a transfer whose chain is ETH. Outbound transfers take their chain from beneficiary_data.chain, then transaction_details.crypto_params.crypto_chain, then custom_properties.crypto_chain, then the currency (for example ETH, or for USDT and USDC the chain inferred from payment_reference_id), set only when it is a chain Didit recognizes, and after-deposit registrations from their chain field, lowercased. Inbound TRP inquiries ignore chain: they match by address, or, when the inquiry names no wallet, by beneficiary name against your verified entries. POST .../travel-rule/ownership/ also matches by address alone. When several entries match, the INTERNAL rail and after-deposit registrations prefer an ownership-verified entry, then the oldest, and the outbound TRP lookup takes the oldest entry that stores a travel address. Inbound TRP inquiries and the ownership endpoint take any one of the matching entries, so keep one entry per address where you can. The INTERNAL rail matches no entry at all when verified entries for the address exist in more than one other application.

Email pickup for unreachable counterparties

When no protocol rail can reach the counterparty, Didit falls back to EMAIL: it sends a one-time pickup link to beneficiary_data.counterparty_vasp_email (or an email resolved from beneficiary_data.counterparty_vasp_name against the VASP directory registry). The recipient does not need a Didit account or an API key - the link’s token is the authentication. The link opens a hosted page that shows the pending exchange and calls these two public endpoints for the recipient. For the example transfer above, the pickup details are:
This endpoint returns the stored amount verbatim, so it carries the full 18 decimal places rather than the trimmed form the transaction detail object uses.
decision is accept or decline. On accept, the supplied beneficiary_name is compared with the transfer’s beneficiary_data.name under the originator’s name_matching_strictness policy - COMPLETED on a match, COUNTERPARTY_MISMATCHED_DATA otherwise. On decline, the transfer moves to COUNTERPARTY_VASP_GENERAL_DECLINE. The response is the resulting {"status": "..."}. Both endpoints return 404 for an unknown token and 410 once the window closes - either the transfer left AWAITING_COUNTERPARTY, or its confirmation deadline (confirmation_timeout_hours after creation or the last resend) has passed. The counterparty reads and answers through the pickup link with GET and POST /v3/travel-rule/pickup/{token}/messages/. It needs no API key, accepts PDF, PNG, JPEG, or WebP attachments only, and answers 410 once the link expires or the transfer closes. A message you sent shows your VASP name as author and as the attachment’s uploaded_by, never the person or API key behind it.

After-deposit sunrise registration

When a crypto deposit settles on-chain before any Travel Rule exchange took place - the sending VASP was unreachable, or you are backfilling historical deposits - register it after the fact with POST /v3/travel-rule/inbound/.
originating_vasp.travel_address must be a real, checksum-valid travel address: a placeholder returns 400 with "Invalid travel address.". originating_vasp.lei must be empty or 20 characters in the LEI format (18 letters or digits, then 2 digits); anything else returns 400 with "Invalid LEI: expected 20 alphanumeric characters (ISO 17442).". Didit checks this format only, not the ISO 17442 check digits. Didit creates an inbound travelRule transaction (or reuses an inbound deposit you already submitted with the same hash and wallet, if it has no Travel Rule transfer yet) and resolves the transfer against your wallet address book:
  • No matching entry -> ON_HOLD. Resolve it with POST .../travel-rule/ownership/: confirmed: true adds the wallet to your address book as verified and runs the name match; on a match the transfer moves to COMPLETED, then FINISHED.
  • Entry exists but ownership is unproven -> UNCONFIRMED_OWNERSHIP.
  • Beneficiary name does not match the entry’s holder_name -> COUNTERPARTY_MISMATCHED_DATA.
  • Verified entry and the name matches -> COMPLETED, then immediately FINISHED (the deposit has already settled).
With the verified address-book entry registered in the example above, the response is:
The new transaction’s txn_id is tr-in-, the first 80 characters of the txid, and a 12-character hash of the txid and wallet. created is true (HTTP 201) when this call registered the deposit, and false (HTTP 200) when the same txid and wallet_address were already registered, in which case the existing transfer is returned unchanged. On the verified-entry path both transitions happen before the endpoint responds, so the synchronous response already shows the terminal FINISHED status - the intermediate COMPLETED step is still recorded in the transfer’s timeline. Each transition fires a travel_rule.status.updated webhook (COMPLETED and FINISHED each fire one), and when Didit created a new transaction, a transaction.created webhook follows them. Registration carries no Travel Rule or monitoring fee. When crypto monitoring is enabled and the chain is one Didit can screen, a newly created deposit is also screened and billed once at the Wallet Verification & Screening rate.

Wallet ownership widget

To move a transfer out of UNCONFIRMED_OWNERSHIP - or to pre-verify a wallet before any transfer arrives - mint a hosted wallet-ownership widget with POST /v3/travel-rule/widget-session/ and send your customer to the returned url. The widget proves control of the wallet by message signing, a Satoshi test, a screenshot, or self-declaration, then marks the matching address-book entry verified so the exchange can continue. A screenshot is the exception: it never verifies the wallet by itself, so your compliance team reviews it and confirms a transfer in UNCONFIRMED_OWNERSHIP with POST .../travel-rule/ownership/. See Wallet ownership widget for the full flow, the proof methods, and the important note that the widget must be opened in a real browser - never inside a mobile SDK webview.

Directory

GET /v3/travel-rule/vasps/ searches the VASP directory: the shared directory entries plus the members of the Didit network. Every row says how your application can reach that VASP today.
search matches a name or alias fragment, an exact LEI, or a domain (counterparty.example or a URL on it). Narrow the results with: Didit network members are not scored, so risk and responsiveness return directory entries only. Results are sorted by name and paginated with limit (default 50) and offset. An unknown network, country code, risk band or responsiveness status answers 400 naming the field. GET /v3/travel-rule/vasps/{vasp_id}/ returns one row by its id. Your own VASP profile never appears in your own results. dd_status, dd_score, dd_score_breakdown, dd_assessed_at, dd_score_semantic, risk_level, responsiveness and essential_checks carry the shared due-diligence assessment of a directory entry. decision, entity_screening and questionnaires are your application’s own records on the VASP, and no other customer sees them. See Counterparty due diligence for what each one means, and gate on them with the counterparty rule fields rather than branching on them inline. Didit network members are not scored: dd_status is COMPLETED, dd_score is null, risk_level is empty and the breakdown is empty.

Routes

routes lists the ways your application can deliver to the VASP, in the order the routing ladder tries them, and reachable is true when at least one is available:
  • TRP when the entry carries a TRP endpoint the directory verified.
  • GTR, TRUST, VERIFYVASP, SYGNA when a network lists the VASP under a code. The route is available only when your application can send over that network today (via: own_membership for your own connected membership, via: didit_membership for Didit’s). Otherwise it is unavailable with reason: no_membership or reason: network_switched_off, never shown as a way to reach the VASP. Didit’s own network memberships are not live yet, so today a network route is available only through a membership you connected in the Marketplace.
  • EMAIL when there is a compliance contact for the entry.
  • INTERNAL for a Didit network member.
A TRISA listing appears under networks and tells you who the VASP is on TRISA, but Didit has no TRISA rail, so it is never a route.

Brands and their entities

A brand that operates through several regulated entities is one row whose entities are the legal entities that receive its transfers; an entity row points back at its brand in umbrella. A transfer that names the brand goes to the reachable entity in the jurisdiction it states in beneficiary_data.counterparty_vasp_jurisdiction, else to the entity blockchain analytics attribution linked to the destination wallet, else to the brand’s only reachable entity when no jurisdiction is stated. Two entities that fit equally, or a stated jurisdiction none of them is in, never pick one: which legal entity receives the originator’s data is never a guess. The brand’s own route is used then, and without one the transfer is COUNTERPARTY_VASP_NOT_FOUND.

Contacts

contacts are the addresses the EMAIL rail writes to. scope: platform contacts serve every Didit customer: Didit curates them (source: staff), or an address at one of the VASP’s own domains answered a request through its response link (source: learned). scope: application contacts are private to your application. A transfer emails your application’s own contact first, then a platform contact that has answered before (verified_at set), then the others.

How entries get into the directory

  • The ESMA interim MiCA register of crypto-asset service providers, read every week: each provider’s name, LEI and home member state.
  • The public TRISA directory, read every week: who each member is, its country and its TRISA listing. A listing the directory stops showing for three weeks is removed.
  • The global LEI register fills in the LEI, other legal names and the country of an entry, and finds the LEI of an entry by its exact legal name in its country.
  • Blockchain analytics attribution adds or matches the VASP that owns a destination wallet, and keeps the attributed name as an alias.
  • Didit staff curate entries: routing identifiers and network listings they verified, compliance contacts, aliases, brands and their entities, merged duplicates and hidden entries.
Data a customer reports about a counterparty, such as an inbound originating_vasp or a travel address, stays on that customer’s transfer and never changes a shared entry. Entries are matched by LEI first, then by exact name or alias, and enriched rather than duplicated.

The Didit network

Your VASP profile is a member of the Didit network, so other Didit customers find it in the directory and reach you on the INTERNAL rail, only while all of these hold:
  • your application is live (not sandbox) and Travel Rule is on;
  • is_discoverable is true and legal_name is set;
  • compliance_email is set and confirmed: send the confirmation with POST /v3/travel-rule/settings/compliance-email/confirmation/ and open the emailed link. Changing the address needs a new confirmation;
  • Didit has not marked the application as a test, demo or internal one.
GET /v3/travel-rule/settings/ returns didit_network: listed, compliance_email_confirmed, and in missing each condition still unmet (travel_rule_off, sandbox_application, not_discoverable, legal_name_missing, compliance_email_missing, compliance_email_unconfirmed, excluded_by_staff, internal_organization).

Routing identifiers

Outbound routing consults a directory entry whenever your transfer names the counterparty by counterparty_vasp_lei or counterparty_vasp_name (an alias works too). An entry carries one identifier per network it can be reached on, a verified TRP endpoint and its compliance contacts, and routes shows which of them your application can use. You can always bypass the directory on a single transfer by passing the explicit fields in Counterparty resolution fields.

Counterparty due diligence

Before your customers’ funds reach another VASP, you need to know who that VASP is and whether you accept it. Due diligence on a counterparty has two layers:
  • The shared assessment. Every directory entry carries a due-diligence score, its risk level, a responsiveness status and an essential-checks report. Didit computes them from the platform’s own data, so every customer sees the same values.
  • Your own records. Your decision on the VASP, your sanctions and adverse-media screening of it, and the due-diligence questionnaires you exchanged with it belong to your application alone. Another customer never sees them, and they never change the shared entry.
Your own records work for a directory entry and for a member of the Didit network alike: address them by the id the directory shows.

Score and risk level

dd_score runs from 0 to 100 and higher is safer. Every result says so in dd_score_semantic, which is always higher_is_safer. risk_level is the band the score falls in: The score is the sum of seven components. dd_score_breakdown shows each one as {points, max, reason, detail}: max is the component’s weight, reason a machine-readable code you can translate, and detail a one-line English explanation. Points are rounded to one decimal and the total to a whole number. history reads the same requests as responsiveness, with no time window: a COMPLETED or FINISHED exchange is a success, and an EXPIRED, COUNTERPARTY_MISMATCHED_DATA or COUNTERPARTY_VASP_GENERAL_DECLINE exchange a failure. A cancellation is the sender’s own act and never counts. Didit recomputes the assessment when an exchange with the entry completes, finishes, expires, mismatches or is declined, when it refreshes or curates the entry, and for every entry in the weekly directory refresh. dd_assessed_at says when it last ran; an entry not assessed yet has no dd_assessed_at, a dd_score of 0 and an empty risk_level, so read risk_level rather than the raw score. Didit network members are not scored: their dd_score is null, their risk_level is empty and their dd_score_breakdown is empty.

Essential checks

essential_checks is a short report on four checks Didit runs on every directory entry, from the platform’s verified sources only, never from data a customer reported: Each check in checks reports its status (pass, fail, or unknown when no source on record can decide it), a machine-readable reason and a one-line English detail. passed, failed, unknown and total count them, level is essential, and checked_at says when the report was built. The essentials component scores the share of decided checks that pass, so an unknown check counts neither for nor against the entry. The report is empty until the weekly directory refresh first checks an entry, and always empty for a Didit network member.

Responsiveness

responsiveness says whether the VASP answers Travel Rule requests:
total counts the requests Didit customers started towards the VASP in the last 90 days whose outcome is decided, and answered the ones it answered:
  • Counted: requests sent over a network or protocol rail, and requests emailed to a shared address, which is the entry’s compliance address or a platform contact.
  • Never counted: a request emailed to an address one customer typed, or to a customer’s private contact. That exchange stays in the customer’s own transfer history and never labels the VASP for everyone else.
  • Answered: COMPLETED, FINISHED, COUNTERPARTY_VASP_GENERAL_DECLINE or COUNTERPARTY_MISMATCHED_DATA. A decline or a mismatch is an answer: the VASP engaged.
  • Unanswered: EXPIRED.
  • Neither: every other status. A CANCELLED request is the sender’s own act, COUNTERPARTY_VASP_NOT_REACHABLE can be a failure on the sender’s side, and a request still in progress is not decided yet.
Didit network members answer inside Didit and always show UNKNOWN with zero counts. Act on an unresponsive counterparty with the non-responsive policy or the travel-rule-counterparty-unresponsive preset.

Your decision on a counterparty

Record your compliance team’s verdict on a counterparty with PUT /v3/travel-rule/vasps/{vasp_id}/decision/:
  • The decision is private to your application: another customer never sees it, and it never changes the shared entry.
  • A decision changes nothing on its own and never changes how a transfer is routed. It reaches your transactions through the counterparty_dd_status rule field: install the presets, or write your own rule on it.
  • A decision on a brand covers each of its entities that has no decision of its own, and the entity shows the brand in inherited_from. A decision on the entity overrides the brand’s for that entity.
  • GET returns the decision that applies, or {"decision": null}. DELETE clears your own decision and returns what applies afterwards: the brand’s decision for an entity, otherwise null. It answers 404 when your application has no decision of its own on the counterparty.
  • reason is optional, up to 255 characters, and each PUT replaces it. decided_by is the console user who made or last changed the decision, and is empty when an API key did.

Counterparty screening

Counterparty screening checks the counterparty VASP itself, as a company, against sanctions and adverse media. It is opt-in and billed: turn it on with counterparty_screening_enabled in your settings. While it is off, nothing is screened or billed.
  • Billing. Each new screening costs your organization’s Company AML Screening price. GET /v3/travel-rule/vasps/{vasp_id}/screening/ returns that price, fresh_days and whether screening is enabled, at no charge. A screening younger than 30 days is fresh: POST returns it again with 200 and outcome: reused and bills nothing, unless you send refresh: true. A new screening answers 201 with outcome: screened. Sandbox applications are never screened or billed.
  • Automatic screening. With screening on, Didit also screens the counterparty an outbound exchange reaches when the exchange starts, before your rules evaluate the transaction, with the same reuse and billing. A refusal or failure there never fails the transfer.
  • Result. status is Approved when no possible match needs attention, and In Review or Declined for a possible sanctions or adverse-media match. flagged is true while a fresh screening is In Review or Declined.
  • Effect. While your latest screening of a counterparty is flagged and screening stays on, counterparty_risk_level is HIGH in your rules, so the travel-rule-counterparty-high-risk preset, once installed, holds its transfers. The shared risk_level in the directory does not change.
  • Privacy. The screening is private to your application: another customer never sees it, and it never changes the shared entry. The directory shows your latest one as entity_screening.
  • Refusals. 409 when screening is off, for a sandbox application, or while a screening of the same counterparty is already running. 403 with code: insufficient_credits when your balance cannot cover the price. 502 when the screening could not be completed. None of them bills anything.

Due-diligence questionnaire

Ask a counterparty for its due-diligence answers with Didit’s standard questionnaire. POST /v3/travel-rule/vasps/{vasp_id}/questionnaires/ emails it to a compliance address on record for the counterparty, with a single-use link to an answer page:
  • Where it goes. sent_to must be a compliance address on record: for a directory entry, its compliance address or a contact your application sees in contacts; for a Didit network member, its confirmed compliance email. Leave it out to use the address a Travel Rule email to the counterparty goes to: your own contact first, then a platform contact, then the entry’s compliance address. Didit never sends the questionnaire anywhere else, and answers 400 when the counterparty has no compliance address on record.
  • Answering. The link opens /v3/travel-rule/questionnaires/{token}/, a page for a person at the counterparty that needs no account or API key. They answer every question, up to 2,000 characters each (N/A where one does not apply), and submit once. A counterparty can also post {"answers": {"<question id>": "<answer>"}} to the same link. A link that was used, replaced or is past its deadline shows an expiry page instead. The email and the page are translated; the questions themselves are always in English, so their meaning never changes between languages.
  • Deadlines. The counterparty has 30 days to answer (expires_at). Answers stay current for 365 days (valid_until), and current is true until then. An unanswered questionnaire past its deadline, and answers past their validity, become EXPIRED.
  • One open questionnaire per counterparty. A new send replaces your earlier unanswered questionnaire to the same counterparty: it becomes EXPIRED and its link stops working. One email per minute can go to an address; a faster send answers 429.
  • Reading answers. GET /v3/travel-rule/vasps/{vasp_id}/questionnaires/ lists your questionnaires with the counterparty, newest first, with answers keyed by question id once answered. The directory shows them as questionnaires. They are private to your application.
Form version 1 asks these twelve questions:

Transfers with one counterparty

GET /v3/travel-rule/transfers/?counterparty={vasp_id} lists your application’s Travel Rule transfers with one counterparty, newest first, by the id the directory shows: your transfers whose counterparty is that directory entry, or your INTERNAL exchanges with that Didit network member. A value that is not a valid id answers 400.

Sunrise and non-responsive policies

When a transfer expires (confirmation_timeout_hours passed while it was AWAITING_COUNTERPARTY), timeout_outcome decides what happens to its transaction. Two settings let you treat two common reasons for a timeout differently: Both take HOLD, REJECT or PROCEED, with the same meaning as for timeout_outcome, or an empty string to follow timeout_outcome. Both are empty by default, so nothing changes until you set one:
The first of these that applies decides the outcome:
  1. sunrise_policy, when it is set and the counterparty’s jurisdiction is in sunrise_jurisdictions.
  2. non_responsive_policy, when it is set and the counterparty is UNRESPONSIVE.
  3. timeout_outcome in every other case, including a transfer to a Didit network member or to a counterparty with no directory entry.
sunrise_jurisdictions is read-only and currently ["CO", "KH", "VN"]. Only these countries count as in a sunrise period: any other jurisdiction, and an empty or unknown one, follows timeout_outcome, so a counterparty never gets your sunrise policy by accident. The expired transfer’s timeline names the outcome that applied, for example sunrise_policy=PROCEED (counterparty jurisdiction CO does not enforce the Travel Rule yet). Your rules still run after the expiry, so a rule such as the EU TFR tr_eu_expired_review can still move the transaction to review under PROCEED.

Rule fields and presets

Four rule fields carry what your application knows about the counterparty a transaction’s Travel Rule exchange reached: its directory entry, or for an INTERNAL exchange the Didit customer that received it. Each field is empty when the exchange reached no known counterparty. Five presets in the travel_rule bundle act on them. Install them from Transactions → Rules → Library in the Business Console or with the rule library API: All five apply to Travel Rule transactions only.

Webhooks

Subscribe a destination to travel_rule.status.updated to get notified on every status transition of every transfer in your application, outbound and inbound.
rail is empty until a rail is selected. The first events for a new transfer are queued while Didit processes the request, before transaction.created for the same transaction, but delivery order is not guaranteed; key your handler on transaction_id rather than on arrival order. A transfer sent over GTR, TRUST, or VERIFYVASP gets no COMPLETED event today: unless the send fails or you cancel it, its last event is EXPIRED. Sandbox applications send no webhooks. See the webhooks guide for the full event catalog, signature verification, and retry policy.

Pricing

Every outbound travelRule transaction is billed $0.02, charged once at creation regardless of how many status transitions the exchange goes through afterward. The fee applies whatever happens to the exchange: it is also charged when the amount is below your threshold_amount, when Travel Rule is disabled and the data is only recorded, and when no rail can reach the counterparty. Sandbox applications are never charged.
  • Inbound travelRule transactions carry no Travel Rule or monitoring fee: the inbound leg Didit creates on the beneficiary’s application via the INTERNAL rail, inbound TRP inquiries, and after-deposit registrations.
  • If crypto AML/wallet screening also runs on the same transaction (for example an inbound deposit with currency_kind: "crypto" and monitoring enabled), that’s billed separately at the standard Wallet Verification & Screening rate - it is not bundled into the Travel Rule fee.
  • Travel Rule transactions use this dedicated line item instead of standard transaction-monitoring pricing, not both.
  • Counterparty screening is opt-in and billed separately, at the Company AML Screening price per screening; a screening younger than 30 days is reused at no charge. Decisions, questionnaires and the directory’s due-diligence assessment cost nothing.

Attribution pricing

Blockchain analytics attribution needs one wallet lookup against the analytics provider, and you always get the full value of that lookup:
  • Crypto monitoring already screened the wallet on this transaction - the stored result is reused. One analytics call, one Wallet Verification & Screening charge (the one crypto monitoring already made). Attribution adds nothing.
  • Crypto monitoring did not run - Didit makes the required analytics call once and passes its full value on: the transaction is charged the standard Wallet Verification & Screening rate ($0.15 with Didit-managed analytics) and receives the complete crypto screening output in return - risk score, wallet risk category, network snapshot - not just the VASP name. The usage line item is marked with the travel_rule_attribution trigger so the charge is explainable.
  • Bring-your-own analytics key - the same platform fee that applies to crypto monitoring with your own key applies here ($0.02 per call). Your provider bills you for the underlying lookup directly.
  • Failed lookups are never charged, resends reuse the recorded attribution without a new call or charge, and sandbox applications are never charged.
So an outbound transfer that needed attribution costs $0.02 (Travel Rule) + one Wallet Verification & Screening charge - and when both products are enabled, the analytics call and its charge happen only once.

EU TFR rules bundle

Four preset rules ship in the travel_rule_eu_tfr bundle, purpose-built for EU TFR obligations. Like every other preset bundle, enable it from the Business Console under Transactions → Rules → Library. These fire on every relevant transition - at transfer creation, on ownership confirmation, on email pickup response, and from the expiry sweeper - so a transfer that never resolves still surfaces for compliance review instead of settling silently.

Why this works better on one platform

Travel Rule compliance is an identity problem wearing a payments costume - and Didit already holds the identity:
  • Verified data at hand. Each transfer’s subject is linked by vendor_data to the KYC profile Didit verified at onboarding (document OCR, liveness, face match), so you can build originator_data from verified fields on the same platform. Didit transmits exactly the originator_data you send.
  • Step-up biometrics for risky transfers. A rule can hold any high-value transfer until the user passes a biometric re-check against the face captured at onboarding - the strongest possible answer to account takeover at the moment of withdrawal. See the worked example.
  • One device graph. SDK-submitted transfers carry the same device intelligence as KYC sessions, so “new device + high amount + first-time beneficiary” is a single rule, not a data-warehouse join.
  • Screening, rules, cases, and the exchange in one place. The wallet screening that attributes the counterparty VASP is the same screening your rules and analysts see - one charge, one console, one audit trail.
  • Published pricing. $0.02 per outbound transfer, no volume tiers, no sales call.

FAQ

How the exchange works

Didit runs the exchange itself. When you submit a travelRule transfer, Didit performs live counterparty discovery, sends the originator and beneficiary data to the receiving VASP on the rails that carry it, and marks the transfer COMPLETED only once the counterparty side confirms it - it is a managed handshake, not a post-settlement log. Today a counterparty answer is processed on the INTERNAL, TRP, SYGNA, and EMAIL rails; GTR, TRUST, and VERIFYVASP only send, so transfers over them expire instead of completing. Didit also keeps the full record and compliance status of every exchange (a 13-state lifecycle, webhooks, and an audit trail). If you prefer to run Travel Rule through your own process, Didit can instead record the data passively as your single system of record (see the passive-recording question below). Both modes are available: the active exchange runs once you set is_enabled: true in settings (it is false by default), and until then Didit records the data passively.
It is native. Didit operates its own Travel Rule Protocol (TRP) infrastructure at trp.didit.me and implements the industry network protocols directly - GTR (Global Travel Rule), TRUST, VerifyVASP, and Sygna Bridge (spoken natively, including Sygna’s ECIES-encrypted IVMS 101 and signed permission requests). Didit is not a reseller of a single third-party interoperability layer. Reaching a counterparty that lives on a specific membership network necessarily happens over that network - there is no single universal channel in the Travel Rule - but the discovery, handshake, verification, and recordkeeping are all executed by Didit. Today every network rail needs your own membership, and GTR, TRUST, and VerifyVASP only send: see Overview.
Natively implemented, tried in strict priority order: INTERNAL (both VASPs on Didit), TRP (OpenVASP Travel Rule Protocol), GTR, TRUST, VerifyVASP, Sygna Bridge, and an EMAIL fallback for counterparties with no tooling. The network rails need your own membership today, and GTR, TRUST, and VerifyVASP only send for now. CODE, TRISA, Notabene, and Veriscope appear in Didit’s Marketplace as request-access tiles, with no integration yet. See How Didit routes an exchange for the full routing table.
TRP is the baseline we recommend: it needs no shared membership - sharing your travel address lets any VASP that speaks TRP reach you - and when a counterparty is known only by wallet address, blockchain-analytics attribution routes it over TRP only if the attributed directory entry already has a TRP endpoint. Outbound, Didit sends BTC and ETH transfers over it. For membership networks, choose by where your counterparties operate: GTR offers broad global coverage, TRUST is oriented to US VASPs, and VerifyVASP (which now also operates Sygna Bridge) has strong coverage in Asia. Yes, today you need your own membership: Didit holds no platform membership on these networks yet and is onboarding as a provider. Connect your membership in Marketplace; you are not locked to a single network. Remember that GTR, TRUST, and VerifyVASP only send today, so a transfer over them does not complete.
Through a resolution chain: wallet address books (another Didit customer’s for the INTERNAL rail, your own for a stored travel address), then the protocol and network rails, then blockchain-analytics attribution. If the transfer carries only a destination wallet, Didit resolves it against blockchain-analytics data to attribute the counterparty exchange or custody service and catalogues it in the VASP directory. The transfer then routes automatically only if that directory entry already has a route, such as a TRP endpoint; otherwise it ends in COUNTERPARTY_VASP_NOT_FOUND with the counterparty’s name recorded, and you can resend once a route exists, for example after you store the counterparty’s travel_address on your address book entry for that wallet (BTC and ETH).

Pricing and licensing

Travel Rule is a separate, usage-based product. It is not bundled into identity verification. It costs **0.02 per outbound transfer** ([published pricing](#pricing), no volume tiers, no sales call). When routing needs a blockchain analytics lookup to identify the counterparty VASP, that lookup is charged once at the [attribution rate](#attribution-pricing): from 0.15 managed, or $0.02 with an existing provider key. It is not billed again if crypto monitoring already screened the same wallet. The fee applies to every outbound travelRule transaction, including transfers below your threshold; inbound transfers, such as the legs Didit mints on the beneficiary side, are not charged it.
No. The Travel Rule fee ($0.02, outbound only) replaces the standard transaction monitoring fee for travelRule transactions, and the blockchain-analytics lookup is charged once: if crypto monitoring already screened the destination wallet on the transaction, attribution reuses that stored result at no extra cost. Inbound legs Didit mints on the beneficiary side are never billed the Travel Rule fee.
Yes. Didit’s KYT and transaction monitoring, AML and sanctions screening, blockchain-analytics wallet risk, and Travel Rule capabilities are all documented publicly on docs.didit.me, and you are free to cite these pages in a regulatory file. For a formal product capability statement tailored to a specific submission - for example an AIFC/AFSA, MiCA, or other VASP licensing application - contact your Didit account team with the format your regulator requires.

Operations and data

They resolve immediately to NOT_APPLICABLE: no rail is tried and no identity data leaves Didit. An outbound transfer is still billed the $0.02 Travel Rule fee at creation. Didit compares the threshold with the amount converted to your application’s default currency. If Didit cannot convert the amount, the threshold is not applied and the transfer is exchanged. The EU TFR has no minimum, so EU CASPs should keep threshold_amount at 0.00.
The transfer sits in AWAITING_COUNTERPARTY until confirmation_timeout_hours elapses, then a sweeper moves it to EXPIRED and applies your timeout_outcome to the underlying transaction - HOLD for review, REJECT to decline, or PROCEED to leave it untouched. The tr_eu_expired_review preset rule additionally surfaces every expiry to your analysts.
Yes - that is the EMAIL rail. Their compliance contact receives a one-time pickup link and can confirm or decline the exchange from a hosted page with no Didit account, no API key, and no integration. For deposits that already settled before any exchange was possible, use after-deposit sunrise registration.
Declare the destination as self-hosted (payment_method.method_type: "unhosted_wallet"). Didit skips VASP routing and instead collects proof that your customer controls the wallet - message signing, Satoshi test, screenshot, or self-declaration - through the wallet-ownership widget. That is the EU TFR self-hosted wallet obligation in API form.
Yes. Until wallet ownership is confirmed on your side, the counterparty’s personal data on an inbound transfer is masked, in the transfer object and in the transaction’s counterparty party alike (see Masked counterparty data). Email pickup pages show only the transfer summary (originator VASP name, amount, destination wallet), never the person payloads. Rail exchanges run over HTTPS, and the network rails (GTR, TRUST, VERIFYVASP, SYGNA) additionally encrypt the IVMS payload to the receiving VASP’s public key.
Yes. With Travel Rule disabled, any travel_rule_details you submit (including a caller-supplied status, current or legacy) is recorded passively on the transaction - no rail is dispatched, nothing is negotiated. Use this to backfill or run your own process while keeping one system of record.
Sandbox applications skip rules, webhooks, and billing, and the exchange engine still runs - but sandbox never dispatches over a rail. So an outbound sandbox transfer that reaches routing parks at AWAITING_COUNTERPARTY instead of completing, then expires after its confirmation deadline and applies your timeout_outcome: use it to check that a transfer is created with the payload you expect. Inbound registration (POST /v3/travel-rule/inbound/) does not dispatch in the first place, so sandbox runs it for real against your wallet address book and you can exercise every outcome end to end - ON_HOLD, UNCONFIRMED_OWNERSHIP, COUNTERPARTY_MISMATCHED_DATA, or COMPLETED then FINISHED. For a full two-sided outbound loop, run it between two live applications on the INTERNAL rail: register a wallet in the second application’s address book and watch both sides transition.

Next steps

Wallet ownership widget

Prove a customer controls a wallet by signing, Satoshi test, screenshot, or self-declaration.

Interoperability

The counterparty-facing endpoints other VASPs use to reach you: TRP, callbacks, email pickup, sunrise.

Submitting transactions

How to include travel rule data in the Create Transaction payload.

Rules library

The full preset rule catalog, including the Travel Rule bundle.

Webhooks

Subscribe to travel_rule.status.updated and every other event family.

Console guide

Viewing travel rule status in the transaction detail view.