Skip to main content
The Didit SDKs can submit monitored transactions directly from the end-user’s device, including Travel Rule transfers and crypto screening. Your backend mints a short-lived scoped token; the SDK does the rest. It attaches device intelligence automatically, and when a Travel Rule transfer needs proof of wallet control, it auto-launches that flow on the spot. A required verification session is never auto-launched - it comes back in the response for your app to launch with your own Didit verification integration; see Required user actions below.

Overview

Client-side submission is built on three guarantees:
  • Spoof-proof subject binding. The token is scoped to one vendor_data (your user id) at mint time. The subject identity of every transaction submitted with it is enforced server-side from that binding, so a tampered client cannot submit on behalf of another user, and the payload never needs to carry credentials.
  • Automatic device intelligence. The SDK collects a device fingerprint and sends it with the submission, and Didit derives the IP address and user agent server-side. The combined device context is attached to the transaction’s subject with no extra library, no separate device token, and no plumbing on your side. It powers device-based rules and related-by-device correlation across sessions and transactions.
  • Auto-launched wallet-ownership proof. When the created transaction requires the end user to prove control of a wallet, the response carries an action_required block of type wallet_ownership and the SDK opens that flow immediately, then refreshes the transaction when it finishes - no polling loops or link delivery to build yourself. A required verification_session action is different: it is never auto-launched, and comes back in the same block for your app to launch with its own Didit verification integration.
Server-side POST /v3/transactions/ remains the right choice when no end user is present: back-office transfers, batch imports, or events reported by your own systems. Both paths create the same transaction records, run the same rules, and emit the same webhooks.

Flow

1

Mint a transaction token

Your backend calls POST /v3/transactions/sdk-token/ with your API key and the user’s vendor_data, and hands the returned sdk_token to your app.
2

Submit from the device

The app calls submitTransaction on the SDK, which posts the payload to POST /v1/transactions/ with the X-Transaction-Token header and automatic device enrichment.
3

Handle any required action

If action_required.type is wallet_ownership, the SDK auto-launches the widget, then refreshes the transaction via GET /v1/transactions/{transaction_id}/. If it’s verification_session, the SDK does not launch anything - read action_required and start your own verification flow with its session_token (or url on web).

Minting a transaction token

Mint tokens from your backend with POST /v3/transactions/sdk-token/ - the same API-key authentication as the rest of the /v3/ transaction endpoints.
curl -X POST https://verification.didit.me/v3/transactions/sdk-token/ \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vendor_data": "user-042",
    "ttl_seconds": 900,
    "max_uses": 3
  }'
{
  "sdk_token": "q7Zt...urlsafe",
  "expires_at": "2026-07-07T12:15:00Z"
}
FieldTypeDescription
vendor_datastringRequired. Your internal identifier for the end user this token is scoped to. Every transaction submitted with the token has its subject identity enforced from this value.
ttl_secondsintegerToken lifetime in seconds. Defaults to 900 (15 minutes); maximum 86400 (24 hours).
max_usesintegerOptional. Maximum number of successful submissions allowed with this token. Omit for unlimited uses within the TTL.
camelCase aliases (vendorData, ttlSeconds, maxUses) are also accepted, like the rest of the /v3/ API.
Never ship your API key in an app. The API key stays on your backend and mints tokens; only the short-lived sdk_token travels to the device. Mint a fresh token per user session rather than reusing long-lived ones, and keep max_uses tight when the number of expected submissions is known.

Submitting from the device

The SDKs post to POST /v1/transactions/ with the token in the X-Transaction-Token header. The wire payload is camelCase; the submitTransaction methods build it for you, so you only deal with it directly when calling the endpoint from your own HTTP client:
curl -X POST https://verification.didit.me/v1/transactions/ \
  -H "X-Transaction-Token: SDK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "txnId": "wd-2026-07-07-0042",
    "type": "travelRule",
    "info": {
      "direction": "out",
      "amount": "0.25",
      "currency": "ETH",
      "currencyType": "crypto",
      "cryptoParams": { "crypto_chain": "ETH" }
    },
    "subject": { "fullName": "Jane Doe" },
    "counterparty": {
      "fullName": "Ana Diaz",
      "paymentMethod": { "type": "unhosted_wallet", "accountId": "0xBeneficiaryWallet01" }
    },
    "travelRule": {
      "status": "AWAITING_COUNTERPARTY",
      "beneficiaryData": { "wallet_address": "0xBeneficiaryWallet01", "name": "Ana Diaz" }
    },
    "includeCryptoScreening": true
  }'
Each top-level field maps onto its POST /v3/transactions/ equivalent:
Wire field/v3/ equivalentNotes
txnIdtransaction_idRequired. Your unique identifier for the transaction.
txnDatetransaction_atISO-8601 timestamp; defaults to now.
zoneIdtime_zoneIANA time zone id, e.g. Europe/Madrid.
typetransaction_categoryRequired. finance, kyc, travelRule, userPlatformEvent, gamblingBet, and the other categories.
infotransaction_detailsRequired. direction, amount, currency (required), plus currencyType, amountInDefaultCurrency, defaultCurrencyCode, paymentDetails, paymentTxnId, type (action type), cryptoParams.
subject, counterpartysubject, counterpartyParticipants: type (entity type), externalUserId, fullName, firstName, lastName, dob, address, institutionInfo, device, paymentMethod (type, accountId, issuingCountry). A counterparty paymentMethod.type of unhosted_wallet declares the destination as self-hosted - see below.
propscustom_propertiesCustom key-value pairs, usable in rules as custom_values.<key>.
travelRuletravel_rule_detailsstatus, protocol, required, obligationsCount, originatorData, beneficiaryData, metadata. When Travel Rule is enabled, Didit ignores any status you send and drives the exchange itself.
includeCryptoScreeninginclude_crypto_screeningPer-transaction crypto screening override.
The response is the same snake_case transaction detail as the /v3/ endpoints, plus the action_required block:
{
  "uuid": "22222222-3333-4444-5555-666666666666",
  "txn_id": "wd-2026-07-07-0042",
  "transaction_type": "travelRule",
  "direction": "OUTBOUND",
  "status": "APPROVED",
  "amount": "0.25",
  "currency": "ETH",
  "travel_rule": {
    "uuid": "33333333-4444-5555-6666-777777777777",
    "status": "UNCONFIRMED_OWNERSHIP",
    "required": true
  },
  "action_required": {
    "type": "wallet_ownership",
    "url": "https://verify.didit.me/wallet-ownership/3s9x...urlsafe",
    "widget_session_id": "77777777-8888-9999-aaaa-bbbbbbbbbbbb",
    "expires_at": "2026-07-07T13:00:00Z"
  }
}

Declaring a self-hosted wallet

The example above declares the destination as the user’s own self-hosted wallet: the counterparty’s paymentMethod.type is unhosted_wallet. That declaration changes how the Travel Rule exchange runs:
  • Didit skips counterparty-VASP routing entirely - there is no VASP behind a self-hosted wallet - and creates the transfer directly in UNCONFIRMED_OWNERSHIP.
  • When auto_wallet_verification is on in your Travel Rule settings (the default), a wallet-ownership widget session is auto-minted and returned in action_required, exactly as in the response above.
  • The SDK auto-launches the widget; once the user proves control of the wallet, the transfer runs the name match and advances to COMPLETED.
Without the declaration, Didit instead tries the rails in order and only falls back to the ownership flow if no counterparty VASP can be found. Declare the wallet whenever your product already knows the withdrawal target is the user’s own wallet - it saves the failed routing pass and gets the user into the proof flow immediately.

Subject enforcement

Three things are always decided by the token, never by the payload:
  • The application the transaction is created under.
  • The subject’s identity: subject.externalUserId in the payload is ignored and replaced with the token’s vendor_data.
  • The device context of the subject (see below); server-derived fields win on conflict with anything supplied in subject.device.
A 401 from this endpoint means the token was rejected: missing, unknown, revoked, over its max_uses, or expired. Expired-token responses always contain the word expired in the error detail, which is how the SDKs distinguish expired_token from invalid_token errors.

Reading the transaction back

GET /v1/transactions/{transaction_id}/ returns the same detail with the same X-Transaction-Token authentication. The SDKs call it automatically after a launched action flow completes or its window closes, so the final state never depends on a single event or notification. Use it yourself to re-check status and action_required at any point while the token is valid; from your backend, GET /v3/transactions/{transaction_id}/ returns the same record.

Automatic device intelligence

Every submission carries device intelligence without any integration work on your side:
  • The SDK collects a privacy-safe device fingerprint (the didit-fp-v2 schema: platform and navigator signals, a persistent device id, a bot score, and a composite hash) and sends it with the request.
  • Didit derives the IP address, user agent, and accept-language server-side from the connection itself, so they cannot be forged by the payload.
The merged result is normalized into the subject participant’s device context and upserted into the device graph, the same one KYC sessions feed. That makes transactions correlatable by device across your whole user base (“related by device”), feeds device and network signals into rules and IP enrichment, and shows up on the transaction detail in the Console. Server-submitted /v3/ transactions can keep passing explicit device_context objects per participant; on the SDK path you get a stronger version of the same data for free.

Required user actions

Some transactions need the end user to do something before they can settle: a rule can require a verification session, or a Travel Rule transfer can require proof that the destination wallet is really the user’s. Both cases surface in the same place - the action_required block of the submit response (and of every subsequent read of the transaction):
"action_required": {
  "type": "verification_session",
  "url": "https://verify.didit.me/session/...",
  "session_id": "11111111-2222-3333-4444-555555555555",
  "session_token": "SESSION_TOKEN",
  "status": "Not Started"
}
"action_required": {
  "type": "wallet_ownership",
  "url": "https://verify.didit.me/wallet-ownership/3s9x...urlsafe",
  "widget_session_id": "77777777-8888-9999-aaaa-bbbbbbbbbbbb",
  "expires_at": "2026-07-07T13:00:00Z"
}
TypeWhen it appearsWhat the user does
verification_sessionA rule action required additional user verification and the transaction moved to AWAITING_USER.Completes a hosted verification session (for example a biometric re-check), launched by your app - see Launching a verification session below.
wallet_ownershipA Travel Rule transfer needs end-user proof of wallet control - the destination was declared a self-hosted wallet (UNCONFIRMED_OWNERSHIP), or an outbound transfer found no counterparty VASP - and auto_wallet_verification is enabled in your Travel Rule settings. Didit auto-mints a wallet-ownership widget session bound to the transaction.Proves control of the wallet by message signing, Satoshi test, screenshot, or self-declaration - the SDK auto-launches this one for you.
If both could apply to the same transaction, wallet_ownership takes precedence, because the missing ownership proof blocks the Travel Rule exchange itself. action_required becomes null once the pending action is resolved. The existing remediation field is kept for backward compatibility; action_required is the canonical block.

Auto-launch contract

autoLaunchAction (default true) auto-launches only the wallet_ownership action:
  • Web - opened in the SDK’s own overlay modal, with completion detected by origin-validated messaging plus a bounded transaction poll (never a single-event dependency).
  • iOS / Android (and the React Native and Flutter wrappers, which delegate to the native SDKs) - presented as the SDK’s native wallet-ownership experience (SwiftUI / Jetpack Compose) when useNativeWalletOwnership is true (the default), falling back to SFSafariViewController / a Chrome Custom Tab, and finally the default browser. After the flow closes or the app returns to the foreground, the SDK re-reads the transaction automatically.
A verification_session action is never auto-launched: it always comes back in action_required for your app to start with its own Didit verification integration.
The wallet-ownership widget must never be rendered inside an embedded mobile webview - wallet deep links cannot escape it and the proof flow dead-ends. The SDKs already use the correct browser surface when auto-launching; keep that rule if you handle the url yourself. See the wallet-ownership widget guide for details.
After the SDK’s own auto-launched wallet-ownership flow completes or is closed, it re-reads the transaction and hands the refreshed result to your onActionCompleted callback. A verification_session action is not part of that cycle, since your app launches it - see the next section.

Launching a verification session

When action_required.type is verification_session, read the block from result.actionRequired and hand it to your existing Didit verification integration:
if (result.actionRequired?.type === "verification_session") {
  DiditSdk.shared.startVerification({ url: result.actionRequired.url });
}
if actionRequired.type == "verification_session" {
    try await DiditSdk.shared.startVerification(token: actionRequired.sessionToken)
}
if (actionRequired.type == "verification_session") {
    DiditSdk.startVerification(token = actionRequired.sessionToken) { result ->
        // handle the verification result
    }
}
if (actionRequired.type === "verification_session") {
  await startVerification(actionRequired.sessionToken);
}
if (actionRequired.type == 'verification_session') {
  await DiditSdk.startVerification(actionRequired.sessionToken);
}
Once your verification flow finishes, call GET /v1/transactions/{transaction_id}/ (or wait for the transaction webhook) to pick up the transaction’s refreshed status.

Example: step-up biometrics for a high-value withdrawal

This is the flow at its best: gate risky transactions - large amounts, new beneficiaries, new devices - behind a biometric re-check of the same person you verified at onboarding. Account takeover looks exactly like this: correct password, correct 2FA code (phished or SIM-swapped), a withdrawal to a wallet you have never seen. Every credential-based defense has already failed by that point. A face has not: the attacker cannot pass a liveness-checked selfie that must match the face captured during the legitimate owner’s KYC onboarding.
1

Create the rule once, in the console

Transactions → Rules → Create Rule: condition amount gte 5000, action Change status → AWAITING_USER, and select your Biometric Authentication workflow on the action. Add any other risk conditions you like - first-time counterparty, new device fingerprint, IP country change - as extra conditions or extra rules.
2

Submit the withdrawal as usual

Nothing changes in your submission code. When the rule fires, the response comes back with status: "AWAITING_USER" and an action_required block of type verification_session:
{
  "status": "AWAITING_USER",
  "action_required": {
    "type": "verification_session",
    "url": "https://verify.didit.me/session/...",
    "session_id": "11111111-2222-3333-4444-555555555555",
    "session_token": "SESSION_TOKEN",
    "status": "Not Started"
  }
}
3

Launch the re-check

Hand the session_token (or url) to your existing Didit verification integration, exactly as in Launching a verification session. Because the session is created for the same vendor_data as the transaction’s subject, Didit already holds the user’s verified reference face from onboarding - the approved liveness selfie, the passport chip photo, or the document portrait. The user takes one live selfie; Didit runs liveness plus a 1:1 face match against that stored reference. No document rescan, no re-onboarding - a few seconds of friction, only on the transactions that deserve it.
4

Settle on the outcome

When the session completes, the transaction re-evaluates automatically: APPROVED if the re-check passed, DECLINED if it failed, with a transaction.status.updated webhook either way (reason: remediation_completed in the transaction’s timeline). Your app picks up the result via onActionCompleted-style callbacks, GET /v1/transactions/{transaction_id}/, or the webhook.
This works because the KYC onboarding, the biometric re-check, and the transaction decision run on one platform: the face captured at onboarding is the reference for the step-up, the device graph links the session to the transaction, and the rule engine ties them together - no vendor glue, no face-image export, no separate biometrics contract. The re-check bills as a standard Biometric Authentication feature ($0.10) only when it actually runs.

Web SDK

submitTransaction is available on the Web SDK singleton today:
import { DiditSdk } from "@didit-protocol/sdk-web";

const result = await DiditSdk.shared.submitTransaction({
  transactionToken: sdkToken, // minted by your backend via POST /v3/transactions/sdk-token/
  transaction: {
    txnId: "wd-2026-07-07-0042",
    txnDate: new Date().toISOString(),
    zoneId: "Europe/Madrid",
    category: "travelRule",
    details: {
      direction: "out",
      amount: "0.25",
      currency: "ETH",
      currencyType: "crypto",
      paymentDetails: "ETH withdrawal to self-hosted wallet",
      cryptoParams: { crypto_chain: "ETH" }
    },
    subject: {
      type: "individual",
      fullName: "Jane Doe"
      // no externalUserId needed: the subject identity comes from the token
    },
    counterparty: {
      type: "individual",
      fullName: "Ana Diaz",
      paymentMethod: {
        type: "unhosted_wallet",
        accountId: "0xBeneficiaryWallet01"
      }
    },
    travelRule: {
      status: "AWAITING_COUNTERPARTY", // ignored when Travel Rule is enabled: Didit drives the exchange
      beneficiaryData: {
        wallet_address: "0xBeneficiaryWallet01",
        name: "Ana Diaz"
      }
    },
    includeCryptoScreening: true
  },
  autoLaunchAction: true, // default: auto-launches wallet_ownership only; verification_session comes back in result.actionRequired for you to launch
  onActionCompleted: (refreshed) => {
    console.log(refreshed.status, refreshed.travelRuleStatus);
  }
});

console.log(result.transactionId, result.status, result.actionRequired?.type);
The result is { transactionId, status, travelRuleStatus?, actionRequired? }. In the SDK-level payload you write category, details, and customProperties; the SDK maps them onto the wire aliases (type, info, props) for you. Errors are typed via DiditTransactionError:
import { DiditSdk, DiditTransactionError } from "@didit-protocol/sdk-web";

try {
  await DiditSdk.shared.submitTransaction({ transactionToken, transaction });
} catch (error) {
  if (error instanceof DiditTransactionError) {
    switch (error.type) {
      case "expired_token": // mint a fresh token and retry
        break;
      case "invalid_token": // rejected: unknown, revoked, or over max_uses
        break;
      case "validation": // inspect error.fieldErrors for per-field details
        break;
      case "network": // retry with backoff
        break;
    }
  }
}

Native SDKs

The native submitTransaction API ships with iOS and Android SDK 4.1.0 (and the React Native and Flutter releases that pin it). Unlike the Web SDK, the native payloads use the wire field names directly: type, info, and props.
import DiditSDK

let payload = DiditTransactionPayload(
    txnId: "wd-2026-07-07-0042",
    type: "travelRule",
    info: DiditTransactionInfo(
        direction: "out",
        amount: 0.25,
        currency: "ETH",
        currencyType: "crypto",
        cryptoParams: ["crypto_chain": "ETH"]
    ),
    subject: DiditTransactionParticipant(type: "individual", fullName: "Jane Doe"),
    counterparty: DiditTransactionParticipant(
        type: "individual",
        fullName: "Ana Diaz",
        paymentMethod: DiditTransactionPaymentMethod(type: "unhosted_wallet", accountId: "0xBeneficiaryWallet01")
    ),
    travelRule: DiditTravelRuleInfo(
        beneficiaryData: ["wallet_address": "0xBeneficiaryWallet01", "name": "Ana Diaz"]
    ),
    includeCryptoScreening: true
)

let result = try await DiditSdk.shared.submitTransaction(
    transactionToken: sdkToken,
    transaction: payload,
    options: DiditTransactionOptions(
        autoLaunchAction: true,           // default
        useNativeWalletOwnership: true,   // default: native flow, browser fallback
        onTransactionUpdated: { refreshed in
            print(refreshed.status ?? "", refreshed.travelRuleStatus ?? "")
        }
    )
)

print(result.transactionId, result.status ?? "", result.actionRequired?.type ?? "none")
// Errors: DiditTransactionError .invalidToken / .expiredToken / .validation(fieldErrors) / .network
import me.didit.sdk.DiditSdk
import me.didit.sdk.transactions.*

val payload = DiditTransactionPayload(
    txnId = "wd-2026-07-07-0042",
    type = "travelRule",
    info = DiditTransactionInfo(
        direction = "out",
        amount = 0.25,
        currency = "ETH",
        currencyType = "crypto",
        cryptoParams = mapOf("crypto_chain" to "ETH")
    ),
    subject = DiditTransactionParticipant(type = "individual", fullName = "Jane Doe"),
    counterparty = DiditTransactionParticipant(
        type = "individual",
        fullName = "Ana Diaz",
        paymentMethod = DiditTransactionPaymentMethod(
            type = "unhosted_wallet",
            accountId = "0xBeneficiaryWallet01"
        )
    ),
    travelRule = DiditTravelRule(
        beneficiaryData = mapOf("wallet_address" to "0xBeneficiaryWallet01", "name" to "Ana Diaz")
    ),
    includeCryptoScreening = true
)

// suspend fun - call from a coroutine; DiditSdk.initialize() must have run first
val result = DiditSdk.submitTransaction(
    transactionToken = sdkToken,
    transaction = payload,
    options = DiditTransactionOptions(
        autoLaunchAction = true,          // default
        useNativeWalletOwnership = true,  // default: native flow, Custom Tab fallback
        onTransactionUpdated = { refreshed ->
            Log.d("Didit", "${refreshed.status} ${refreshed.travelRuleStatus}")
        }
    )
)

Log.d("Didit", "${result.transactionId} ${result.status} ${result.actionRequired?.type}")
// Errors: sealed DiditTransactionException - InvalidToken / ExpiredToken / Validation(fieldErrors) / Network
import {
  submitTransaction,
  getTransaction,
  DiditTransactionError,
} from "@didit-protocol/sdk-react-native";

const result = await submitTransaction(sdkToken, {
  txnId: "wd-2026-07-07-0042",
  type: "travelRule",
  info: {
    direction: "out",
    amount: 0.25,
    currency: "ETH",
    currencyType: "crypto",
    cryptoParams: { crypto_chain: "ETH" },
  },
  subject: { type: "individual", fullName: "Jane Doe" },
  counterparty: {
    type: "individual",
    fullName: "Ana Diaz",
    paymentMethod: { type: "unhosted_wallet", accountId: "0xBeneficiaryWallet01" },
  },
  travelRule: {
    beneficiaryData: { wallet_address: "0xBeneficiaryWallet01", name: "Ana Diaz" },
  },
  includeCryptoScreening: true,
}, {
  autoLaunchAction: true, // default
  onTransactionUpdated: (refreshed) => {
    console.log(refreshed.status, refreshed.travelRuleStatus);
  },
});

console.log(result.transactionId, result.status, result.actionRequired?.type);
// Errors: DiditTransactionError with code invalid_token | expired_token | validation | network
import 'package:sdk_flutter/sdk_flutter.dart';

final result = await DiditSdk.submitTransaction(
  sdkToken,
  DiditTransactionPayload(
    txnId: 'wd-2026-07-07-0042',
    type: 'travelRule',
    info: DiditTransactionInfo(
      direction: 'out',
      amount: 0.25,
      currency: 'ETH',
      currencyType: 'crypto',
      cryptoParams: {'crypto_chain': 'ETH'},
    ),
    subject: DiditTransactionParticipant(type: 'individual', fullName: 'Jane Doe'),
    counterparty: DiditTransactionParticipant(
      type: 'individual',
      fullName: 'Ana Diaz',
      paymentMethod: DiditTransactionPaymentMethod(
        type: 'unhosted_wallet',
        accountId: '0xBeneficiaryWallet01',
      ),
    ),
    travelRule: DiditTravelRule(
      beneficiaryData: {'wallet_address': '0xBeneficiaryWallet01', 'name': 'Ana Diaz'},
    ),
    includeCryptoScreening: true,
  ),
  options: DiditTransactionOptions(
    autoLaunchAction: true, // default
    onTransactionUpdated: (refreshed) {
      debugPrint('${refreshed.status} ${refreshed.travelRuleStatus}');
    },
  ),
);

debugPrint('${result.transactionId} ${result.status} ${result.actionRequired?.type}');
// Errors: DiditTransactionException with code invalid_token | expired_token | validation | network
Every SDK also exposes getTransaction(transactionToken, transactionId) to re-read the transaction with the same token - the SDKs call it for you after a launched action flow, and you can call it yourself at any point. On mobile the device fingerprint transport is already applied to all SDK network calls, so transaction submissions carry the same device intelligence automatically.

Without the SDKs

Everything the SDKs do is plain HTTPS - nothing here requires them. Two equivalent integration shapes: Server-side only (no tokens at all). Submit with POST /v3/transactions/ from your backend using your API key. The response (and every subsequent GET /v3/transactions/{transaction_id}/) carries the same action_required block. You can also pass explicit device context per participant, since there is no SDK to collect it for you. Client-side with your own HTTP stack. Mint a token with POST /v3/transactions/sdk-token/, then call POST /v1/transactions/ and GET /v1/transactions/{transaction_id}/ from the device with the X-Transaction-Token header - the same subject binding and server-side IP/user-agent derivation apply. Then handle action_required yourself:
action_required.typeWhat to do
wallet_ownershipDeliver url to the user and open it in a real browser surface - a normal tab on web, SFSafariViewController / Chrome Custom Tabs on mobile, or out-of-band via an email link or QR code. Never render it inside an embedded webview: wallet deep links cannot escape one.
verification_sessionSend the user to url - it is the standard hosted Didit verification page - or feed session_token to any Didit verification integration you already run.
And detect completion without any client callback:
  • Webhooks (recommended). transaction.status.updated fires when the pending action resolves the transaction, and travel_rule.status.updated fires on every exchange transition - both include enough to update your records with no polling.
  • Polling. Re-read the transaction (/v3/ from your backend, or /v1/ with the token while it is valid): action_required returns to null once resolved, and status / travel_rule.status carry the outcome.
There are no one-shot dependencies in this design: the widget, the verification session, and your backend all converge on the same durable transaction state, so a closed tab or a missed webhook is always recoverable by re-reading the transaction.

Webhooks

Your backend does not need the app to report back. When a submitted transaction requires user action (it lands in AWAITING_USER), the transaction webhook - transaction.created or transaction.status.updated - includes the same action_required block, so your systems know about the pending action even if the app is closed before the flow finishes. Travel Rule transitions additionally emit travel_rule.status.updated.

Next steps

Submitting transactions

The server-side Create Transaction API and the full payload reference.

Travel Rule

The managed originator-beneficiary exchange, settings, and lifecycle.

Wallet ownership widget

The hosted proof-of-control flow behind wallet_ownership actions.

Webhooks

transaction.created, transaction.status.updated, and signature verification.

Rules

Rule actions, including the ones that create verification sessions.

Statuses

What AWAITING_USER means in the transaction lifecycle.