How it works
Travel Rule outcomes in sandbox are driven by two layers:- 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.
- 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
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 viapayouts/: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.
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 withfailureCode: 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).
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 withfailureCode: travel_rule_rejectedand the suppliedreasononfailureMessage. - Unconditional pre-broadcast suffix: use destination suffix
BAD7E517(TR_TX_VALIDATE_REJECTED). This is unconditionally pre-broadcast; no manual call is needed.
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.
Override the auto-pilot
To inject a counterparty webhook synchronously (before the 10s timer), call:rejected / declined: see the pre- vs post-broadcast carve-out 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.
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.Example: VASP destination + REJECTED counterparty
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):
- Wallet screening resolves VASP-attributed.
- Synthetic Travel Rule transfer row written; auto-pilot job queued.
- The payout broadcasts on-chain (mocked) and receives a synthetic
txHash. - 10s after create, the synthetic counterparty webhook fires with
outcome: rejected. Broadcast already happened — the Travel Rule row recordsREJECTEDfor audit, the payout is not unwound (tipping-off-safe). - Call
payouts/:id/simulate/confirmwith a synthetictxHashto advance the payout to its terminal state.
- 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.
- The payout reports
requiresUserSignature: trueand waits while the customer’s authorized signers approve it. The Travel Rule row already exists, so a counterparty rejection can land during this window. - The synthetic counterparty webhook fires with
outcome: rejected. Broadcast has not yet happened; the payout terminates withtransaction.failedcarryingfailureCode: travel_rule_rejected. The funds reserved for the payout are returned; the upstream counterparty transfer is best-effort cancelled.