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

# SDK Transaction Submission

> Submit monitored transactions straight from your app with a scoped token: automatic device intelligence, spoof-proof subject binding, an auto-launched wallet-ownership flow.

The Didit SDKs can submit [monitored transactions](/transaction-monitoring/transactions) directly from the end-user's device, including [Travel Rule](/transaction-monitoring/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](#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](/transaction-monitoring/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.

<Note>
  Server-side [`POST /v3/transactions/`](/transaction-monitoring/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](/transaction-monitoring/webhooks).
</Note>

## Flow

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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).
  </Step>
</Steps>

## 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.

```bash theme={null}
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
  }'
```

```json theme={null}
{
  "sdk_token": "q7Zt...urlsafe",
  "expires_at": "2026-07-07T12:15:00Z"
}
```

| Field         | Type    | Description                                                                                                                                                                    |
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `vendor_data` | string  | **Required.** 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_seconds` | integer | Token lifetime in seconds. Defaults to `900` (15 minutes); maximum `86400` (24 hours).                                                                                         |
| `max_uses`    | integer | Optional. 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.

<Warning>
  **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.
</Warning>

## 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:

```bash theme={null}
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/`](/transaction-monitoring/transactions) equivalent:

| Wire field                | `/v3/` equivalent          | Notes                                                                                                                                                                                                                                                                                                                                               |
| ------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `txnId`                   | `transaction_id`           | **Required.** Your unique identifier for the transaction.                                                                                                                                                                                                                                                                                           |
| `txnDate`                 | `transaction_at`           | ISO-8601 timestamp; defaults to now.                                                                                                                                                                                                                                                                                                                |
| `zoneId`                  | `time_zone`                | IANA time zone id, e.g. `Europe/Madrid`.                                                                                                                                                                                                                                                                                                            |
| `type`                    | `transaction_category`     | **Required.** `finance`, `kyc`, `travelRule`, `userPlatformEvent`, `gamblingBet`, and the other categories.                                                                                                                                                                                                                                         |
| `info`                    | `transaction_details`      | **Required.** `direction`, `amount`, `currency` (required), plus `currencyType`, `amountInDefaultCurrency`, `defaultCurrencyCode`, `paymentDetails`, `paymentTxnId`, `type` (action type), `cryptoParams`.                                                                                                                                          |
| `subject`, `counterparty` | `subject`, `counterparty`  | Participants: `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](#declaring-a-self-hosted-wallet). |
| `props`                   | `custom_properties`        | Custom key-value pairs, usable in rules as `custom_values.<key>`.                                                                                                                                                                                                                                                                                   |
| `travelRule`              | `travel_rule_details`      | `status`, `protocol`, `required`, `obligationsCount`, `originatorData`, `beneficiaryData`, `metadata`. When Travel Rule is enabled, Didit ignores any `status` you send and drives the exchange itself.                                                                                                                                             |
| `includeCryptoScreening`  | `include_crypto_screening` | Per-transaction crypto screening override.                                                                                                                                                                                                                                                                                                          |

The response is the same snake\_case transaction detail as the `/v3/` endpoints, plus the `action_required` block:

```json theme={null}
{
  "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](/transaction-monitoring/travel-rule#declared-self-hosted-wallets) 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](/transaction-monitoring/travel-rule#setup) (the default), a [wallet-ownership widget](/transaction-monitoring/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](/transaction-monitoring/travel-rule#overview) 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}/`](/transaction-monitoring/transactions) 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](/transaction-monitoring/rules) and [IP enrichment](/transaction-monitoring/ip-enrichment), and shows up on the transaction detail in the [Console](/transaction-monitoring/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):

```json theme={null}
"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"
}
```

```json theme={null}
"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"
}
```

| Type                   | When it appears                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | What the user does                                                                                                                                                                  |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verification_session` | A [rule action](/transaction-monitoring/rules) 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](#launching-a-verification-session) below. |
| `wallet_ownership`     | A Travel Rule transfer needs end-user proof of wallet control - the destination was [declared a self-hosted wallet](#declaring-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](/transaction-monitoring/travel-rule#setup). Didit auto-mints a [wallet-ownership widget](/transaction-monitoring/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.

<Warning>
  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](/transaction-monitoring/wallet-ownership-widget#open-the-returned-url) for details.
</Warning>

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:

<CodeGroup>
  ```typescript Web theme={null}
  if (result.actionRequired?.type === "verification_session") {
    DiditSdk.shared.startVerification({ url: result.actionRequired.url });
  }
  ```

  ```swift iOS theme={null}
  if actionRequired.type == "verification_session" {
      try await DiditSdk.shared.startVerification(token: actionRequired.sessionToken)
  }
  ```

  ```kotlin Android theme={null}
  if (actionRequired.type == "verification_session") {
      DiditSdk.startVerification(token = actionRequired.sessionToken) { result ->
          // handle the verification result
      }
  }
  ```

  ```typescript React Native theme={null}
  if (actionRequired.type === "verification_session") {
    await startVerification(actionRequired.sessionToken);
  }
  ```

  ```dart Flutter theme={null}
  if (actionRequired.type == 'verification_session') {
    await DiditSdk.startVerification(actionRequired.sessionToken);
  }
  ```
</CodeGroup>

Once your verification flow finishes, call `GET /v1/transactions/{transaction_id}/` (or wait for the [transaction webhook](#webhooks)) 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.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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`:

    ```json theme={null}
    {
      "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"
      }
    }
    ```
  </Step>

  <Step title="Launch the re-check">
    Hand the `session_token` (or `url`) to your existing Didit verification integration, exactly as in [Launching a verification session](#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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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](/getting-started/pricing)) only when it actually runs.

## Web SDK

`submitTransaction` is available on the Web SDK singleton today:

```typescript theme={null}
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`:

```typescript theme={null}
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

<Note>
  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`.
</Note>

<CodeGroup>
  ```swift Swift theme={null}
  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
  ```

  ```kotlin Kotlin theme={null}
  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
  ```

  ```typescript React Native theme={null}
  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
  ```

  ```dart Flutter theme={null}
  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
  ```
</CodeGroup>

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/`](/management-api/transactions/create) 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/`](/management-api/transactions/sdk-token), then call [`POST /v1/transactions/`](/management-api/transactions/submit-sdk) and [`GET /v1/transactions/{transaction_id}/`](/management-api/transactions/get-sdk) 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.type` | What to do                                                                                                                                                                                                                                                                        |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet_ownership`     | Deliver `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_session` | Send 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-monitoring/webhooks) - `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`](/transaction-monitoring/travel-rule#webhooks).

## Next steps

<CardGroup cols={2}>
  <Card title="Submitting transactions" icon="arrow-right-to-bracket" href="/transaction-monitoring/transactions">
    The server-side Create Transaction API and the full payload reference.
  </Card>

  <Card title="Travel Rule" icon="route" href="/transaction-monitoring/travel-rule">
    The managed originator-beneficiary exchange, settings, and lifecycle.
  </Card>

  <Card title="Wallet ownership widget" icon="wallet" href="/transaction-monitoring/wallet-ownership-widget">
    The hosted proof-of-control flow behind wallet\_ownership actions.
  </Card>

  <Card title="Webhooks" icon="bell" href="/transaction-monitoring/webhooks">
    transaction.created, transaction.status.updated, and signature verification.
  </Card>

  <Card title="Rules" icon="scale-balanced" href="/transaction-monitoring/rules">
    Rule actions, including the ones that create verification sessions.
  </Card>

  <Card title="Statuses" icon="diagram-project" href="/transaction-monitoring/statuses">
    What AWAITING\_USER means in the transaction lifecycle.
  </Card>
</CardGroup>
