> ## Documentation Index
> Fetch the complete documentation index at: https://developers.fireblocks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sumsub

> Exchange Travel Rule data through Sumsub with the Fireblocks API: set up a legal entity and integration, create and encrypt Travel Rule messages, link them to transactions, and handle required actions and missing messages.

Sumsub exchanges Travel Rule data with counterparty VASPs for your incoming and outgoing transactions. You create a Travel Rule message (TRM) through the Fireblocks API, with the PII encrypted on your side, and link it to the Fireblocks transaction. Because Fireblocks does not hold the key that decrypts the PII, creating messages is API-only.

Sumsub is a premium add-on that Fireblocks Support enables on your workspace. It is available on Developer Sandbox, Testnet, and Mainnet workspaces. See [Travel Rule Overview](/docs/travel-rule-overview) for how Travel Rule fits into screening.

## What you can do through the API

| Task | Console | API |
| - | - | - |
| Configure Travel Rule rules | No: submit a policy template to Fireblocks Support | Read only |
| Set up the legal entity and integration | No | Yes |
| Create, link, and manage Travel Rule messages | No | Yes |

## How it works

Transactions pass through three policy stages:

| Stage | What it decides | Actions |
| - | - | - |
| Trigger (Screening Policy) | Whether the transaction needs Travel Rule review. | Screen (continue to the next stage) or pass (approve without review). |
| Missing Travel Rule Message Policy | What happens while a required message does not exist yet. | Wait, accept, or reject. Rules can depend on how long the transaction has been waiting, for example accepting small transactions after 30 minutes but rejecting large ones. |
| Outcome (Post-Screening Policy) | The final action once Sumsub returns a result. | Accept, reject, alert, or wait if the result is still pending. |

Each stage evaluates its rules top to bottom, with a required catch-all default rule.

Rule configuration is not self-service. Fill out a policy template and submit it to Fireblocks Support, who apply it for you. Run policy changes through your organization's compliance approval process before you submit them. You can read the current policy with [Get TRLink policy](/api-reference/trlink/get-trlink-policy).

A transaction's screening status is Completed, Pending, Bypassed (approved without review, by your Trigger (Screening Policy)), or Failed.

Each destination of a multi-destination transaction is evaluated separately, and the most restrictive result applies to the whole transaction.

The order of operations depends on direction:

* **Outgoing**: create the TRM first, then create the Fireblocks transaction with the TRM linked.
* **Incoming**: create the TRM after Fireblocks detects the transaction, referencing its `txId`.

## Before you start

* **Sumsub Travel Rule** is enabled on your Sumsub account. See [Sumsub's Fireblocks integration guide](https://docs.sumsub.com/docs/fireblocks-integration).
* **Fireblocks Support** has enabled Sumsub on your workspace.
* **Your key pair.** Sumsub generates your public and private key pair, which you download from Sumsub. You sign the IVMS101 data you send with your private key, and decrypt data you receive with it.

## Setup

You do this once, during onboarding.

### Step 1: Create a legal entity

A legal entity represents the business that is subject to the Travel Rule. It is specific to this setup, and separate from the legal entities you register in [Address Registry](/reference/address-registry). Most customers need one. If you operate across jurisdictions with different requirements, create one per jurisdiction and assign vault accounts to each. Only one legal entity should have no vault restriction; it is the default for every vault account not assigned elsewhere.

Create it with [`POST /screening/trlink/customers`](/api-reference/trlink/create-customer):

| Field | Description |
| - | - |
| `shortName` | Required. |
| `fullLegalName` | The full registered name. |
| `countryOfRegistration` | ISO 3166-1 alpha-2 code, for example `US`. |
| `nationalIdentification` | National identifier, for example an EIN. |
| `dateOfIncorporation` | ISO date, for example `2020-01-15`. |
| `geographicAddress` | Registered address. |
| `vaults` | Vault account IDs this entity covers. |
| `discoverable` | Visibility in the partner's address book. `anonymous` (default): counterparties cannot see your entity details, but Fireblocks confirms the address exists. `hidden`: nothing is disclosed. `discoverable`: full details are shared. |

### Step 2: Create the integration

Create an integration between the legal entity and Sumsub with [`POST /screening/trlink/customers/integration`](/api-reference/trlink/create-customer-integration), passing the legal entity's `customerId` and Sumsub's `partnerIdent`. List partners and their identifiers with [`GET /screening/trlink/partners`](/api-reference/trlink/list-available-trsupport-partners).

The response returns a `customerIntegrationId`. Every Travel Rule call after this uses it.

### Step 3: Connect to Sumsub

Share the `customerIntegrationId` with Sumsub. Sumsub supports auto-connect only: it sends your API credentials to Fireblocks directly, so you do not enter them yourself.

### Step 4: Test the connection

Call [`POST /screening/trlink/customers/integration/{customerIntegrationId}/test_connection`](/api-reference/trlink/test-connection). A `success` value of `true` confirms the integration can reach Sumsub.

## Encrypt PII for Sumsub

Sumsub uses a nested JWT: a JWS signed with your private key, inside a JWE encrypted with Sumsub's public key.

| | Value |
| - | - |
| Signature | `RS256`, with your private key |
| Key encryption | `RSA-OAEP-256`, with Sumsub's public key |
| Content encryption | `A256GCM` |
| Sumsub's public key | [`GET /screening/trlink/customers/integration/{customerIntegrationId}/public_key`](/api-reference/trlink/get-public-key-for-pii-encryption), returned as a JWK with `use: "enc"` |

Fetch Sumsub's public key before each encryption, since keys can rotate. The encrypted result goes in the TRM's `ivms101` object:

```json theme={"system"}
{
  "version": "IVMS101.2023",
  "data": "<compact-serialized JWE>",
  "filledFields": [
    "Originator.originatorPerson[].naturalPerson.name.nameIdentifier[].primaryIdentifier",
    "Beneficiary.beneficiaryPerson[].naturalPerson.name.nameIdentifier[].primaryIdentifier"
  ]
}
```

`filledFields` lists, in dot notation, every IVMS101 field you populated.

To read data you receive, decrypt the JWE with your private key and verify the inner JWS with Sumsub's public key.

<Accordion title="Reference implementation: JWT encryption and decryption">
  Below is an example of how to encrypt and decrypt IVMS data exchanged with Sumsub. To encrypt, sign the IVMS data with your private key, then encrypt it with Sumsub's public key. To decrypt, use your own private key to decrypt the data, then verify the signature with Sumsub's public key. Your key pair is generated by Sumsub on their marketplace and available to download there.

  <Note>
    Custom key pair upload is under consideration for future releases.
  </Note>

  #### Encryption example

  Sumsub's public key response looks like this:

  ```json theme={"system"}
  {
    "issuer": "sumsub.com",
    "publicKey": {
      "kty": "RSA",
      "kid": "test-sender-2025",
      "e": "AQAB",
      "n": "q6fmy_rjX2xizMiXoWKRVZSrDXXYLnMk6J_q-5FAD5q7CdqK10XmQZrlzItLpj9qxl0LfcZhZ93PL55abei1F4u6Jdzh9LrfUF5WZ7Cvi25n6aeJoOd7JIERY0hP8s-xdiDrr5fRqdseHymENhvwK5J8_adEOcWHR_7ymb17c3ivFQmprytkJD6BfXaF7qGeE6cx0UiDlD1sHCGcd26I1JNjYQZJie0qlPT8x7jILNA7sW1_Nk8wx9jRDtUv6-K2qnz4MvFe1D6rddcnCsiX7MRPWKm10hy41MTJkQcWUYgtDqfdOXNo8D8BnaVuaLEsByNxMQiL8ePXZpBro0nrnw"
    }
  }
  ```

  Here is an example of a private key:

  ```json theme={"system"}
  {
    "kty": "RSA",
    "kid": "test-recipient-2025",
    "e": "AQAB",
    "n": "m58UpcvahBK3-Uma_S4HTGwijmASh_3ATDwkjIb1svp2jnrv6lhjE2Yz28b0oUsEa7Wau6x0NPbaUCrQjjN5iUoumn4Jwq6jKttXFtd4v3583co4sBjGoPqk7GbkeYKzEOs2wmKgKbQjjk1BaHFpTPlAmS2JbNy6VmHNj-62BK1vIeegXheqxkr-b-MvESmL746juz_LlTMPmJqbaNuMeQMktT9lBLPfZermLfUuC7myuQhrsD8zJaNiNDWJQ6dzNi-Cy0uGlDcy9xtSQMW1Tv3Fc7hO_bRD1NI-sJp7nVi6hdc-QMOyrv8tzlhTV7gh3Wnn2kIFFkWy86gYYHw79Q",
    "d": "HJYLvmrkYGdp2QZ-zGwUliK09E9MiCOCG97eXdv6rR5aAdEuWe9Tf8BB3Wi-DhTQIpLw8fF7RTFlJ929gqmM9T2lsuZdF6Bpw5kX9c-t1AtBl6IqaJqcffycp_o8lN9_0idK30krn42CDIU_cxaGH8gXaCvXtyISroR3tK1GTTRfLSqcAQ3wI5Kj7IRf_oCyU6zZ9Vo30X1Tus-yE9Gv0D5wDOybfRljQJ7D7lA-thXzVeO8YwvoIp_K7aA5BaJjMg2TaYdPskCV4Wbu3O0ezHw2GCbkgusIG_ZEtcCFbldR45Vwtpp6Hm82g2lqDynNyRrTqeCoigkIqLwW8vir3w",
    "p": "0huuQD7Cv4R6pgucabPQt7I-1BSG5Ao32ZzTQzqB6H5zeyF76G82TRuSu3Ky4WBCJiFr1czHlA5RlpllU6lEivoOaslc4NtgABLyfTE3EPhP45xBhfOh1FmtMkZdJFXGwpIGOlPByc2nQC3mO9I8wRltvDAB0hds4vsEdQPPpNs",
    "q": "vZy-PHqEib7mYEGmhwoJcHzTrFjK5CAcQ0gqH41ALvKdArk1RzGJWEduQqG8s2_g84z3iobnlslSFM_FVvtQjGGqw8ul9VvHTOCiKlOCnJtCTOHqhGZMQTVSxoQito-RidKNA4rHpjHbU5Gq_3gLmVqdMeQw_Fx_NS1D2y5-k28",
    "dp": "YZA4-dwq0oPR8Ai0OOEmqiY6xoBBouKbzJDmCPHCIROWzDZgMy5xKJ0FJcW9CqqIDOy4Bi9w_W8os6XHR3HyQhabWzrlxgQYL_CcaUXRLDAh6K9GPc1D-DcsFYxW8-hgwzjLa4o5ElxMraCiqGSXkZMdQaWJMuVtyniFOVDrusE",
    "dq": "s3uvx-fhldISmIMMcz9Y-BXw-G-EfrS2jCm_VeaLHuWhInbWq_GEJQBYqtIWoXQB6AlEOOjCR8WB4Rlbn558_KVm07ft_HdIDMmGN7KdLEj7VXN0XqfG_uLO3AMwKMd16JRZz0SLABKpnk2BJBoqQJu5uQRcKkYUU-3pEYzNXBk",
    "qi": "k1qx6Qd4FpHw1185FZiRm4wGv6wzqSIm1LC0KS29CsGl4D6CVqVVaBwoW0Olw3jkrQe7znSuezC1_lZOGmjB2h3I5bKPlAUVKVqdfk4mwlh7Dfgs6AFcqUj5mpSx1a2L0SHQeZXsprA_EtXs0L37iLCEsoOre9qYRivZBqiTdJk"
  }
  ```

  Here is an example of source IVMS data:

  ```json theme={"system"}
  {
    "Originator": {
      "originatorPerson": [
        {
          "naturalPerson": {
            "name": {
              "nameIdentifier": [
                {
                  "primaryIdentifier": "Doe",
                  "secondaryIdentifier": "John",
                  "nameIdentifierType": "BIRT"
                }
              ],
              "localNameIdentifier": [],
              "phoneticNameIdentifier": []
            },
            "customerIdentification": "e8nvdf2vwa5036ix",
            "dateAndPlaceOfBirth": {
              "dateOfBirth": "1992-05-08"
            }
          }
        }
      ],
      "accountNumber": []
    },
    "Beneficiary": {
      "beneficiaryPerson": [
        {
          "naturalPerson": {
            "name": {
              "nameIdentifier": [
                {
                  "primaryIdentifier": "Doe",
                  "secondaryIdentifier": "Jack",
                  "nameIdentifierType": "BIRT"
                }
              ],
              "localNameIdentifier": [],
              "phoneticNameIdentifier": []
            },
            "customerIdentification": "67b4defbfa5d2e41d8177ab3",
            "dateAndPlaceOfBirth": {
              "dateOfBirth": "1991-04-07"
            }
          }
        }
      ],
      "accountNumber": []
    }
  }
  ```

  This utility takes the paths to Sumsub's public key, your private key, and the IVMS data, and produces an encrypted IVMS block with its filled fields:

  ```javascript theme={"system"}
  const jose = require('node-jose');
  const crypto = require('crypto');
  const path = require('path');
  const fs = require('fs/promises');

  const SIGNATURE_ALGORITHM = 'RS256';
  const ENCRYPTION_ALGORITHM = 'RSA-OAEP-256';
  const ENCRYPTION_METHOD = 'A256GCM';
  const DEFAULT_EXPIRY_MS = 300000; // 5 minutes
  const CLOCK_SKEW_SEC = 60; // ±60s skew
  const CLAIM_DATA = 'data'; // The JWT claim holding the payload

  /**
   * Travel Rule JWT Codec for PII Data Encryption
   *
   * Implements a nested JWT (JWS signed token encrypted inside a JWE)
   * for secure PII exchange, compatible with the Java TRJWTCodec implementation.
   *
   * Features:
   * - Object signing and encryption: `prepareIvmsData` / `decryptAndDecode`
   * - JTI (JWT ID) claim for replay attack protection
   * - Standard claims validation (iss, iat, exp)
   */
  class TRJWTCodec {
    constructor() {
      this.keyStore = jose.JWK.createKeyStore();
      this.publicKey = null; // Partner's public key (for encryption)
      this.privateKey = null; // Our private key (for signing)
      this.initialized = false;
    }

    /**
     * Initialize the codec with RSA key pairs.
     *
     * @param {Object} config - Configuration object
     * @param {string} config.publicKey - PEM-encoded RSA public key (partner's key for encryption)
     * @param {string} config.privateKey - PEM-encoded RSA private key (our key for signing)
     */
    async initialize(config) {
      this._assert(config.publicKey, 'Public key is required');
      this._assert(config.privateKey, 'Private key is required');

      try {
        this.publicKey = await this.keyStore.add(config.publicKey);
        this.privateKey = await this.keyStore.add(config.privateKey);
        this.initialized = true;
      } catch (error) {
        throw new Error(`Failed to initialize TRJWTCodec with provided keys: ${error.message}`);
      }
    }

    /**
     * Encrypt and sign an object as a nested JWT (JWS inside JWE).
     * This is the lower-level function that returns only the JWE string.
     *
     * @param {Object} data - The JSON-serializable object to encrypt.
     * @param {string} issuer - The 'iss' claim (our identifier).
     * @param {number} [expiryMs=DEFAULT_EXPIRY_MS] - Expiry time in milliseconds.
     * @returns {Promise<string>} - The compact-serialized JWE token string.
     */
    async encodeAndEncrypt(data, issuer, expiryMs = DEFAULT_EXPIRY_MS) {
      this._assertInitialized();
      this._assert(data, 'Data cannot be null or undefined');
      this._assert(issuer && typeof issuer === 'string' && issuer.trim(), 'Issuer must be a non-empty string');

      // 1. Serialize the object to a JSON string
      const dataJson = JSON.stringify(data);
      // 2. Encode the JSON string as base64url
      const dataB64Url = Buffer.from(dataJson, 'utf8').toString('base64url');
      // 3. Pass the base64url string to the byte-level encryption method
      return this.encryptBytes(dataB64Url, issuer, expiryMs);
    }

    /**
     * Prepares the full IVMS data object, including the encrypted JWE
     * and the list of filled fields.
     *
     * @param {Object} data - The IVMS data object to encrypt.
     * @param {string} issuer - The 'iss' claim (our identifier).
     * @param {number} [expiryMs=DEFAULT_EXPIRY_MS] - Expiry time in milliseconds.
     * @returns {Promise<{version: string, data: string, filledFields: string[]}>}
     * An object containing the API version, the encrypted JWE string,
     * and the dot-notation paths of all non-null fields.
     */
    async prepareIvmsData(data, issuer, expiryMs = DEFAULT_EXPIRY_MS) {
      // 1. Create the encrypted JWE string from the data object.
      // This handles signing with our private key and encrypting with the partner's public key.
      const encryptedData = await this.encodeAndEncrypt(data, issuer, expiryMs);
      // 2. Extract all filled field paths from the original data object.
      const filledFields = this._extractFilledFields(data);

      return {
        version: 'IVMS101.2023', // API version
        data: encryptedData,
        filledFields: filledFields
      };
    }

    /**
     * Decrypt and decode data from an encrypted JWE token.
     *
     * @param {string} encryptedJWT - The compact-serialized JWE token.
     * @param {string} [expectedIssuer] - (Optional) The expected 'iss' claim for validation.
     * @returns {Promise<Object>} - The original decrypted and parsed JSON object.
     */
    async decryptAndDecode(encryptedJWT, expectedIssuer = null) {
      this._assertInitialized();
      this._assert(encryptedJWT, 'Encrypted JWT cannot be null or empty');

      // --- JWE Decryption ---
      // 1. Decrypt the outer JWE layer using our private key.
      const jweResult = await jose.JWE.createDecrypt(this.privateKey)
        .decrypt(encryptedJWT);

      // The payload of the JWE is the inner JWS.
      const signedJWT = jweResult.payload.toString('utf8');

      // --- JWS Verification ---
      // 2. Verify the signature of the inner JWS using the partner's public key.
      const jwsResult = await jose.JWS.createVerify(this.publicKey)
        .verify(signedJWT);

      // The payload of the JWS is the claims object.
      const claimsJson = jwsResult.payload.toString('utf8');
      const claims = JSON.parse(claimsJson);

      // --- Claims Validation ---
      // 3. Validate standard JWT claims (iat, exp, iss, jti).
      if (expectedIssuer && claims.iss !== expectedIssuer) {
        throw new Error(`Invalid issuer: expected ${expectedIssuer}, got ${claims.iss}`);
      }

      const nowSec = Math.floor(Date.now() / 1000);
      if (claims.iat && claims.iat > nowSec + CLOCK_SKEW_SEC) {
        throw new Error('Token "iat" (issued at) is in the future');
      }
      if (claims.exp && nowSec - CLOCK_SKEW_SEC > claims.exp) {
        throw new Error('Token has expired');
      }
      if (!claims.jti) {
        throw new Error('Missing "jti" (JWT ID) claim');
      }

      // --- Payload Extraction ---
      // 4. Extract, decode, and parse the custom 'data' claim.
      const dataB64Url = claims[CLAIM_DATA];
      if (!dataB64Url) {
        throw new Error(`Missing required "${CLAIM_DATA}" claim`);
      }

      const dataBuffer = Buffer.from(dataB64Url, 'base64url');
      const dataJson = dataBuffer.toString('utf8');

      return JSON.parse(dataJson);
    }

    /**
     * Encrypts a base64url-encoded string payload into a nested JWS-in-JWE token.
     *
     * @param {string} dataB64Url - The base64url-encoded data string.
     * @param {string} issuer - The 'iss' claim.
     * @param {number} expiryMs - Expiry time in milliseconds.
     * @returns {Promise<string>} - The compact-serialized JWE token string.
     * @private
     */
    async encryptBytes(dataB64Url, issuer, expiryMs) {
      this._assertInitialized();

      try {
        // --- JWS Claims Setup ---
        // 1. Create the claims for the inner JWS token.
        const nowMs = Date.now();
        const iat = Math.floor(nowMs / 1000); // Issued at
        const exp = Math.floor((nowMs + expiryMs) / 1000); // Expiration
        const jti = crypto.randomUUID(); // Unique token ID

        const claims = {
          iss: issuer,
          iat: iat,
          exp: exp,
          jti: jti,
          [CLAIM_DATA]: dataB64Url,
        };
        const claimsJson = JSON.stringify(claims);

        // --- JWS Creation (Signing) ---
        // 2. Sign the claims with our private key.
        const jwsOptions = {
          format: 'compact',
          fields: {
            alg: SIGNATURE_ALGORITHM,
            typ: 'JWT',
            kid: this.privateKey.kid
          }
        };

        const signedJWT = await jose.JWS.createSign(jwsOptions, this.privateKey)
          .update(claimsJson)
          .final();

        // --- JWE Creation (Encryption) ---
        // 3. Encrypt the signed JWS with the partner's public key.
        const jweOptions = {
          format: 'compact',
          contentAlg: ENCRYPTION_METHOD,
          fields: {
            alg: ENCRYPTION_ALGORITHM,
            enc: ENCRYPTION_METHOD,
            kid: this.publicKey.kid,
            cty: 'JWT' // Content Type: Indicates payload is a JWT
          }
        };

        const encryptedJWT = await jose.JWE.createEncrypt(jweOptions, this.publicKey)
          .update(signedJWT)
          .final();

        return encryptedJWT;

      } catch (error) {
        throw new Error(`Nested JWT encryption failed: ${error.message}`);
      }
    }

    /**
     * Recursively extracts all filled (non-null, non-undefined)
     * leaf node paths from an object in dot-notation.
     *
     * @param {Object} obj - The object to traverse.
     * @returns {string[]} - An array of dot-notation field paths.
     * @private
     */
    _extractFilledFields(obj) {
      // Start the recursion with the root object and no path prefix
      return this._recursiveExtract(obj, '');
    }

    /**
     * Helper function for _extractFilledFields.
     * @param {*} current - The current value being inspected.
     * @param {string} path - The dot-notation path to this value.
     * @returns {string[]} - A flat array of leaf paths found from this node.
     * @private
     */
    _recursiveExtract(current, path) {
      // 1. Base Case: Null/Undefined values are "empty" and ignored.
      if (current === null || current === undefined) {
        return [];
      }

      // 2. Array Case: Recurse on each item, marking the path as an array
      // with a trailing "[]". This "flattens" the array, so
      // 'person.addresses[0].city' and 'person.addresses[1].city' both
      // map to 'person.addresses[].city'.
      if (Array.isArray(current)) {
        const arrayPath = path ? `${path}[]` : path;
        // Use flatMap to collect and flatten results from all items.
        return current.flatMap(item => this._recursiveExtract(item, arrayPath));
      }

      // 3. Object Case: Recurse on each key/value pair, *extending* the path.
      if (typeof current === 'object') {
        return Object.entries(current).flatMap(([key, value]) => {
          const newPath = path ? `${path}.${key}` : key;
          return this._recursiveExtract(value, newPath);
        });
      }

      // 4. Primitive Case: This is a leaf node (string, number, boolean).
      // Return its path, as long as it's not an empty path (i.e., a root primitive).
      return path ? [path] : [];
    }

    /**
     * Throws an error if the codec is not initialized.
     * @private
     */
    _assertInitialized() {
      if (!this.initialized || !this.privateKey || !this.publicKey) {
        throw new Error('TRJWTCodec is not initialized. Call initialize() with keys first.');
      }
    }

    /**
     * Simple assertion helper.
     * @private
     */
    _assert(condition, message) {
      if (!condition) {
        throw new Error(message);
      }
    }
  }

  // --- Command-line usage: encrypt ---

  async function readJsonFile(filePath) {
    const content = await fs.readFile(path.resolve(filePath), 'utf8');
    return JSON.parse(content);
  }

  async function main() {
    const [,, publicKeyFile, privateKeyFile, ivmsDataFile] = process.argv;

    const publicKeyData = await readJsonFile(publicKeyFile);
    const privateKey = await readJsonFile(privateKeyFile);
    const ivmsData = await readJsonFile(ivmsDataFile);
    const { issuer, publicKey } = publicKeyData;

    const codec = new TRJWTCodec();
    await codec.initialize({ publicKey, privateKey });

    // Signs with your private key and encrypts with Sumsub's public key.
    const ivmsRequest = await codec.prepareIvmsData(ivmsData, issuer);
    console.log(JSON.stringify(ivmsRequest, null, 2));
  }

  main();
  ```

  Running it (`node encrypt.js public-key.json private-key.json ivms.json`) produces:

  ```json theme={"system"}
  {
    "version": "IVMS101.2023",
    "data": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoidGVzdC1zZW5kZXItMjAyNSIsImN0eSI6IkpXVCJ9...<truncated JWE>",
    "filledFields": [
      "Originator.originatorPerson[].naturalPerson.name.nameIdentifier[].primaryIdentifier",
      "Originator.originatorPerson[].naturalPerson.name.nameIdentifier[].secondaryIdentifier",
      "Originator.originatorPerson[].naturalPerson.name.nameIdentifier[].nameIdentifierType",
      "Originator.originatorPerson[].naturalPerson.customerIdentification",
      "Originator.originatorPerson[].naturalPerson.dateAndPlaceOfBirth.dateOfBirth",
      "Beneficiary.beneficiaryPerson[].naturalPerson.name.nameIdentifier[].primaryIdentifier",
      "Beneficiary.beneficiaryPerson[].naturalPerson.name.nameIdentifier[].secondaryIdentifier",
      "Beneficiary.beneficiaryPerson[].naturalPerson.name.nameIdentifier[].nameIdentifierType",
      "Beneficiary.beneficiaryPerson[].naturalPerson.customerIdentification",
      "Beneficiary.beneficiaryPerson[].naturalPerson.dateAndPlaceOfBirth.dateOfBirth"
    ]
  }
  ```

  #### Decryption example

  To decrypt, use the same `TRJWTCodec` class with its `decryptAndDecode` method. The example input below is for illustration only — it cannot be decrypted with the keys above, since it is sample data that has already expired; test with the data Sumsub actually sends you as part of creating or reading a TRM.

  ```javascript theme={"system"}
  const codec = new TRJWTCodec();
  await codec.initialize({
    publicKey,  // Sumsub's public key (for signature verification)
    privateKey, // Your private key (for decryption)
  });

  // Decrypts with your private key and verifies the signature with Sumsub's public key.
  const decryptedIvms = await codec.decryptAndDecode(encryptedIvms.data, issuer);
  console.log(JSON.stringify(decryptedIvms, null, 2));
  ```
</Accordion>

## Send an outgoing transaction

1. **Assess the requirement** with [Assess Travel Rule requirement](/api-reference/trlink/assess-travel-rule-requirement) (`POST .../trm/assess`), passing the amount, asset, source, destination, and `beneficiaryVaspId`. The `decision` is `REQUIRED`, `NOT_REQUIRED`, or `NEED_MORE_INFO`. When it is `REQUIRED`, `requiredFields` lists the IVMS101 fields to collect.
2. **Encrypt the PII** as described above.
3. **Create the TRM** with [`POST .../trm`](/api-reference/trlink/create-travel-rule-message), using the same transaction details and the `ivms101` object.
4. **Create the Fireblocks transaction** with `travelRuleMessageId` set to the TRM's own `id` (for example `trm_1234567890abcdef`). If the transaction already exists, link the TRM with [`POST /screening/trlink/transaction/{txId}/travel_rule_message_id`](/api-reference/trlink/set-transaction-travel-rule-message-id).

```ts theme={"system"}
const { data: assessment } = await fireblocks.trLink.assessTRLinkTravelRuleRequirement({
  customerIntegrationId,
  tRLinkAssessTravelRuleRequest: {
    amount: "1500",
    assetId: "ETH",
    direction: "OUTBOUND",
    source: { type: "VAULT_ACCOUNT", id: "0" },
    destination: { type: "ONE_TIME_ADDRESS", oneTimeAddress: { address: "0xabcd..." } },
    beneficiaryVaspId: "vasp-456",
  },
});

if (assessment.decision === "REQUIRED") {
  const { data: trm } = await fireblocks.trLink.createTRLinkTrm({
    customerIntegrationId,
    tRLinkCreateTrmRequest: {
      amount: "1500",
      assetId: "ETH",
      direction: "OUTBOUND",
      source: { type: "VAULT_ACCOUNT", id: "0" },
      destination: { type: "ONE_TIME_ADDRESS", oneTimeAddress: { address: "0xabcd..." } },
      beneficiaryVaspId: "vasp-456",
      ivms101: ivms, // encrypted IVMS101 object
    },
  });

  await fireblocks.transactions.createTransaction({
    transactionRequest: {
      assetId: "ETH",
      amount: "1500",
      source: { type: "VAULT_ACCOUNT", id: "0" },
      destination: { type: "ONE_TIME_ADDRESS", oneTimeAddress: { address: "0xabcd..." } },
      travelRuleMessageId: trm.id,
    },
  });
}
```

## Receive an incoming transaction

1. **Assess the requirement**, passing the Fireblocks `txId` and the `originatorVaspId`. The transaction details are filled in from the `txId`.
2. **Encrypt the PII** you hold for your beneficiary.
3. **Create the TRM** with the `txId`, `originatorVaspId`, and `ivms101` object.
4. **Link the TRM** to the transaction with [`POST /screening/trlink/transaction/{txId}/travel_rule_message_id`](/api-reference/trlink/set-transaction-travel-rule-message-id).

## When a message needs more data

A TRM can enter `PENDING` when the counterparty needs more information. Fireblocks then sends a `TRLink.RequiredActions` notification in the Compliance webhook category, once per destination, with the `trmId`, `customerIntegrationId`, and `requiredActions`. See [Compliance Events & Webhooks](/docs/compliance-events-webhooks) for the full payload and how to subscribe.

| Action type | What to do |
| - | - |
| `UPLOAD_BENEFICIARY_PII` | Encrypt the beneficiary fields listed in `data.beneficiaryRequiredFields` and submit them. |
| `MANUAL_REVIEW` | A compliance review at Sumsub is needed. |

Read the current actions with [Get required actions for a TRM](/api-reference/trlink/get-required-actions-for-a-trm), and resolve them one at a time with [Resolve action for a TRM](/api-reference/trlink/resolve-action-for-a-trm). Each call returns the updated TRM; when the last action is resolved, the TRM moves out of `PENDING`.

## When a transaction has no message

If a transaction that needs a TRM does not have one, it waits in the Missing Travel Rule Message state until a message arrives or your Missing Travel Rule Message Policy times out. Fireblocks sends a `TRLink.NoTRMessage` notification in the Compliance webhook category when this happens. See [Compliance Events & Webhooks](/docs/compliance-events-webhooks).

To decide without waiting, for example based on your own KYC data, call [`POST /screening/trlink/customers/integration/{customerIntegrationId}/transactions/{txId}/manual_decision`](/api-reference/trlink/manual-decision-for-missing-trm) with `action` set to `ACCEPT` or `REJECT`, and an optional `reason` of up to 500 characters. Do not include PII in the reason.

* The decision applies to every destination currently in the Missing Travel Rule Message state. It returns an error if there are none.
* Sending the same action again has no effect. A different action replaces the earlier one.
* Once a destination has moved past this state, its decision cannot be changed.
* Manual decisions are recorded with `source: "MANUAL"`.

## System timeouts

Fireblocks enforces limits that you cannot configure, so a transaction never waits indefinitely: 1.5 hours for outgoing transactions, and 7 days for incoming transactions. When a transaction approaches its limit, a Wait rule is skipped instead of holding it past the limit.

## Multi-destination transactions

Each destination can need its own TRM. Link a TRM to a specific destination with [`POST /screening/trlink/transaction/{txId}/destination/travel_rule_message_id`](/api-reference/trlink/set-destination-travel-rule-message-id), matching by `amount` and `destination`. The endpoint does not accept `destinationScreeningId`: use it from the notification only to track which destination the notification is about, or to deduplicate.

## Manage messages and counterparties

All paths below start with `/screening/trlink/customers/integration/{customerIntegrationId}`.

| Task | Endpoint |
| - | - |
| Get a TRM's status and details | [`GET /trm/{trmId}`](/api-reference/trlink/get-trm-by-id) |
| Cancel a TRM and notify the counterparty | [`POST /trm/{trmId}/cancel`](/api-reference/trlink/cancel-travel-rule-message) |
| Redirect a TRM to a subsidiary VASP | [`POST /trm/{trmId}/redirect`](/api-reference/trlink/redirect-travel-rule-message) |
| List or get VASPs in Sumsub's directory | [`GET /vasps`](/api-reference/trlink/list-vasps), [`GET /vasps/{vaspId}`](/api-reference/trlink/get-vasp-by-id) |
| Check whether Sumsub supports an asset | [`GET /assets`](/api-reference/trlink/list-supported-assets), [`GET /assets/{assetId}`](/api-reference/trlink/get-supported-asset-by-id) |

TRM statuses are `PENDING`, `ACCEPTED`, `REJECTED`, and `FAILED`. A canceled TRM cannot be reused: create a new one if the transaction goes ahead.

## Testing and monitoring

Test with Testnet assets, `BTC_TEST`, `ETH_TEST5`, and `XRP_TEST`, before you test with real value. In the Console, Transaction History shows the policies and rules applied to each transaction, Sumsub's responses, and the final verdict with an explanation.

To update your Sumsub credentials later, submit a ticket to Fireblocks Support.

## Disconnect

[Disconnect customer integration](/api-reference/trlink/disconnect-customer-integration) (`DELETE /screening/trlink/customers/integration/{customerIntegrationId}`) permanently deletes the stored credentials and the integration. Pending TRMs may fail.

Deleting a legal entity ([`DELETE /screening/trlink/customers/{customerId}`](/api-reference/trlink/delete-customer)) also deletes its integrations, TRMs, and configuration. Neither action can be undone.

## On the Help Center

For Sumsub setup and monitoring in the Console, see [Sumsub](https://support.fireblocks.io/hc/en-us/articles/30614346908828-Sumsub) on the Help Center.
