> ## Documentation Index
> Fetch the complete documentation index at: https://v2.docs.conduit.financial/llms.txt
> Use this file to discover all available pages before exploring further.

# Travel Rule scenarios

> Force Travel Rule resolution and counterparty webhook outcomes

## How it works

Travel Rule outcomes in sandbox are driven by two layers:

1. **Wallet screening** (synchronous, at create time): resolves a destination to one of four categories -- VASP-attributed, self-hosted, elevated risk, or sanctions match. Magic-suffix encoded.
2. **Counterparty webhook** (asynchronous, post-create): the VASP counterparty's terminal decision -- `acknowledged`, `approved`, `rejected`, `declined`. Either auto-pilot fires after 10s based on the suffix, or you call the simulate endpoint to override.

## Wallet screening scenario catalog

| Suffix     | Screening resolution               | Outcome                                                                                                                                                                                           |
| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `5A50AB1E` | VASP-attributed                    | Routes through Travel Rule; Travel Rule row persisted in `SENT` state; no auto-pilot counterparty webhook -- drive the counterparty leg yourself via `payouts/:id/simulate/counterparty-webhook`. |
| `5E1F0577` | Self-hosted wallet                 | No Travel Rule transfer; proceeds to broadcast                                                                                                                                                    |
| `12517C00` | Elevated risk (wallet screening)   | Routes self-hosted; the score sits below the rejection threshold so the payout proceeds. The elevated-risk classification is recorded for audit.                                                  |
| `5A4070ED` | Sanctions match (wallet screening) | Payout terminates as `failed`; `transaction.failed` fires with `failureCode: compliance_review_rejected` (sanctions classification recorded for audit)                                            |

<Warning>
  **Address format.** EVM addresses must be all-lowercase OR a correctly EIP-55
  checksummed mixed-case form. The mnemonic suffixes called out in this page are
  uppercase for readability; the wire-format addresses you send to the API are
  all-lowercase.
</Warning>

## Counterparty-outcome scenario catalog (VASP-attributed only)

These suffixes route through the Travel Rule flow and auto-fire the encoded counterparty-webhook outcome 10 seconds after the payout is created.

Pre-emption via `payouts/:id/simulate/counterparty-webhook` cancels the pending auto-pilot job. There is a sub-second race window if the auto-pilot has already started; in that case both signals may be processed in arrival order.

| Suffix     | Auto-pilot counterparty outcome                                       | Final transfer state                                                                                                                                                                            | Effect on payout                                                                           |
| ---------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `AC6BC0DE` | `acknowledged` only (no terminal)                                     | `ACK`                                                                                                                                                                                           | Informational — payout proceeds.                                                           |
| `ACCEEDED` | `approved`                                                            | `ACCEPTED`                                                                                                                                                                                      | Informational — payout proceeds (counterparty resolution does not gate broadcast).         |
| `BAD6A1A4` | `rejected`                                                            | `REJECTED`                                                                                                                                                                                      | See [pre- vs post-broadcast carve-out](#pre--vs-post-broadcast) below.                     |
| `DEC11A1D` | `declined`                                                            | `DECLINED`                                                                                                                                                                                      | Same as `BAD6A1A4`; see [pre- vs post-broadcast carve-out](#pre--vs-post-broadcast) below. |
| `DA171465` | `WAITING_FOR_INFORMATION` at `/tx/create`, then auto-pilot `approved` | Row starts at `WAITING_FOR_INFORMATION` (the payout broadcasts in parallel), then advances to `ACCEPTED` via the auto-pilot webhook. The broadcast leg never blocks on counterparty resolution. |                                                                                            |

For non-custodial payouts, the Travel Rule transfer is created before the customer is asked to sign (during compliance/Travel Rule screening), so it exists throughout the signing window up to broadcast. An auto-pilot `rejected` / `declined` arriving any time in that pre-broadcast window terminates the payout before the on-chain broadcast happens. For custodial payouts that broadcast inline, the auto-pilot fires after broadcast.

### Pre- vs post-broadcast

**Pre-broadcast:** counterparty REJECTED / DECLINED cancels and terminalizes the transaction (custodial and non-custodial).

**Post-broadcast in sandbox:** the transaction terminalizes with `failureCode: travel_rule_rejected`; the counterparty `reason` surfaces on `failureMessage` within about 5 seconds.

**Post-broadcast in production:** audit-only; a confirmed chain transfer cannot be unwound (FATF Rec. 16).

<Tip>
  **To guarantee a pre-broadcast terminal rejection:** use a non-custodial wallet and call `payouts/:id/simulate/counterparty-webhook { outcome: "rejected", reason }` once the Travel Rule row exists. Travel Rule runs before signing, so the row appears at the pre-signing Travel Rule step — wait for `transaction.processing` (which fires right after) before calling; a call made earlier during compliance screening returns `404` (no row yet). A rejection is deterministically terminal through the whole queue + signing window (up to and including `collect-signatures`), which is where you drive this scenario. (There is a narrow window during the final co-stamp step where a late rejection instead parks the payout for operator review rather than terminating cleanly — not reachable from this sandbox scenario, which fires pre-signing.) Or use the suffix `BAD7E517` (TR\_TX\_VALIDATE\_REJECTED), which is unconditionally pre-broadcast.
</Tip>

| Suffix     | Pre-broadcast outcome                                                                                                                                                        |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BAD7E517` | Travel Rule validation rejection → payout fails before broadcast                                                                                                             |
| `5A4ED0DD` | Travel Rule row is persisted in a non-sendable state after `/tx/create` → payout fails pre-broadcast with `travel_rule_rejected`                                             |
| `503CA110` | Travel Rule provider returns a retryable unavailable error; retries exhaust and the payout parks in `status: processing`                                                     |
| `D15CCAD0` | Travel Rule discrepancy: customer attests the destination is their own wallet but wallet screening identifies a VASP — payout fails with `failureCode: travel_rule_rejected` |

## How to guarantee a pre-broadcast terminal rejection

A counterparty rejection arrives pre-broadcast as long as the payout has not yet broadcast on-chain. On the non-custodial flow Travel Rule runs before signing, so the Travel Rule row exists throughout the signing window and right up to broadcast — a reject stays pre-broadcast until the payout broadcasts, after which it becomes audit-only. Two reliable approaches:

* **Non-custodial wallet, manual counterparty call:** use a non-custodial wallet so the payout pauses for cosign after clearing the Travel Rule step. The Travel Rule row is created before the customer is asked to sign, so call `payouts/:id/simulate/counterparty-webhook { outcome: "rejected", reason: "..." }` anytime before the payout broadcasts — including while it is still awaiting signatures. Within that pre-broadcast window the transaction terminates with `failureCode: travel_rule_rejected` and the supplied `reason` on `failureMessage`.
* **Unconditional pre-broadcast suffix:** use destination suffix `BAD7E517` (TR\_TX\_VALIDATE\_REJECTED). This is unconditionally pre-broadcast; no manual call is needed.

For both flows, the optional `reason` propagates 1:1 to the DB `failure_message`, the polled `GET /v2/transactions/:id` `failureMessage`, and the `transaction.failed` webhook `failureMessage`. See the [`failureMessage` symmetry contract](/webhooks#failuremessage-symmetry-contract).

## Override the auto-pilot

To inject a counterparty webhook synchronously (before the 10s timer), call:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/sandbox/payouts/$PAYOUT_ID/simulate/counterparty-webhook \
    -H "x-api-key: $SANDBOX_API_KEY" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{ "outcome": "rejected", "reason": "explicit pre-empt" }'
  ```

  ```javascript Node theme={null}
  const response = await fetch(
    `${process.env.SANDBOX_HOST}/v2/sandbox/payouts/${PAYOUT_ID}/simulate/counterparty-webhook`,
    {
      method: "POST",
      headers: {
        "x-api-key": process.env.SANDBOX_API_KEY,
        "idempotency-key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ outcome: "rejected", reason: "explicit pre-empt" }),
    }
  );
  ```

  ```python Python theme={null}
  import httpx, uuid, os

  httpx.post(
  f"{os.environ['SANDBOX_HOST']}/v2/sandbox/payouts/{PAYOUT_ID}/simulate/counterparty-webhook",
  headers={
  "x-api-key": os.environ["SANDBOX_API_KEY"],
  "idempotency-key": str(uuid.uuid4()),
  },
  json={"outcome": "rejected", "reason": "explicit pre-empt"},
  )

  ```
</CodeGroup>

The synchronous call cancels the pending auto-pilot job and records the counterparty outcome on the payout's Travel Rule row. For `rejected` / `declined`: see the [pre- vs post-broadcast carve-out](#pre--vs-post-broadcast) above; in sandbox, post-broadcast still terminates the transaction with `failureCode: travel_rule_rejected` and the supplied `reason` on `failureMessage`. For `acknowledged` / `approved`: always informational; the payout proceeds independently of counterparty resolution. Subsequent simulate calls are accepted; an outcome that would regress the row's state is ignored.

<Note>
  **`reason` propagation.** The `reason` you supply propagates 1:1 to the DB `failure_message`, the polled `GET /v2/transactions/:id` `failureMessage`, AND the `transaction.failed` webhook `failureMessage`. QA tooling can correlate the simulator call with the resulting webhook by the `reason` text.
</Note>

## Example: VASP destination + REJECTED counterparty

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.sandbox.conduit.financial/v2/payouts \
    -H "x-api-key: $SANDBOX_API_KEY" \
    -H "idempotency-key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{
      "customerId": "cus_01H...",
      "assetAmount": {
        "code": "USDC",
        "chain": "ethereum",
        "amount": "10.000000"
      },
      "purpose": "treasury_management",
      "documents": ["$DOC_ID"],
      "destination": {
        "type": "crypto",
        "recipient": {
          "rail": "crypto",
          "chain": "ethereum",
          "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabad6a1a4",
          "attestation": { "custody": "third_party" },
          "type": "individual",
          "firstName": "Counterparty",
          "lastName": "Recipient",
          "countryOfCitizenship": "USA"
        }
      }
    }'
  ```

  ```javascript Node theme={null}
  const response = await fetch(`${process.env.SANDBOX_HOST}/v2/payouts`, {
    method: "POST",
    headers: {
      "x-api-key": process.env.SANDBOX_API_KEY,
      "idempotency-key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      customerId: "cus_01H...",
      assetAmount: {
        code: "USDC",
        chain: "ethereum",
        amount: "10.000000",
      },
      purpose: "treasury_management",
      documents: [docId],
      destination: {
        type: "crypto",
        recipient: {
          rail: "crypto",
          chain: "ethereum",
          address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabad6a1a4",
          attestation: { custody: "third_party" },
          type: "individual",
          firstName: "Counterparty",
          lastName: "Recipient",
          countryOfCitizenship: "USA",
        },
      },
    }),
  });
  const payout = await response.json();
  // payout.id → "txn_..."
  ```

  ```python Python theme={null}
  import httpx, uuid, os

  r = httpx.post(
      f"{os.environ['SANDBOX_HOST']}/v2/payouts",
      headers={
          "x-api-key": os.environ["SANDBOX_API_KEY"],
          "idempotency-key": str(uuid.uuid4()),
      },
      json={
          "customerId": "cus_01H...",
          "assetAmount": {
              "code": "USDC",
              "chain": "ethereum",
              "amount": "10.000000",
          },
          "purpose": "treasury_management",
          "documents": [doc_id],
          "destination": {
              "type": "crypto",
              "recipient": {
                  "rail": "crypto",
                  "chain": "ethereum",
                  "address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabad6a1a4",
                  "attestation": {"custody": "third_party"},
                  "type": "individual",
                  "firstName": "Counterparty",
                  "lastName": "Recipient",
                  "countryOfCitizenship": "USA",
              },
          },
      },
  )
  payout = r.json()
  # payout["id"] → "txn_..."
  ```
</CodeGroup>

`amount` is a canonical decimal string with exactly `asset.precision` digits after `.` (USDC has 6 decimals → `"10.000000"` = 10 USDC).

Lifecycle (custodial payout — broadcasts inline):

1. Wallet screening resolves VASP-attributed.
2. Synthetic Travel Rule transfer row written; auto-pilot job queued.
3. The payout broadcasts on-chain (mocked) and receives a synthetic `txHash`.
4. 10s after create, the synthetic counterparty webhook fires with `outcome: rejected`. Broadcast already happened — the Travel Rule row records `REJECTED` for audit, the payout is **not** unwound (tipping-off-safe).
5. Call `payouts/:id/simulate/confirm` with a synthetic `txHash` to advance the payout to its terminal state.

Lifecycle (non-custodial payout — screened before signing):

1. Wallet screening resolves VASP-attributed; the synthetic Travel Rule transfer row is written and the auto-pilot job is queued — all before the customer is asked to sign.
2. The payout reports `requiresUserSignature: true` and waits while the customer's authorized signers approve it. The Travel Rule row already exists, so a counterparty rejection can land during this window.
3. The synthetic counterparty webhook fires with `outcome: rejected`. Broadcast has not yet happened; the payout terminates with `transaction.failed` carrying `failureCode: travel_rule_rejected`. The funds reserved for the payout are returned; the upstream counterparty transfer is best-effort cancelled.

## Errors

| Status | Reason                                                                                                                                                           |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `404`  | `simulate/counterparty-webhook` called on a payout with no Travel Rule row (self-hosted wallet resolution or the payout hasn't reached the Travel Rule step yet) |

## See also

* [Sandbox overview](/sandbox/overview)
* [Webhooks reference](/webhooks)
* [Error codes](/errors)
* [Withdrawals](/sandbox/withdrawals)
* [Withdrawal failure paths](/sandbox/withdrawals#failure-paths)
* [Non-custodial wallets](/concepts/non-custodial-wallets)
