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

# Building an exchange transfer UI

> Let users withdraw crypto from a connected Kraken or Coinbase account to an external wallet, including Coinbase's two-step verification and Travel Rule steps.

## Prerequisites

Before this page: the user has connected a Kraken or Coinbase account through Dynamic's exchange connection flow, and the exchange is enabled for your environment in the dashboard.

Sending funds needs more permission than reading balances, and each exchange grants it differently:

* **Coinbase**: connecting grants read-only scopes, which are enough for `getCoinbaseAccounts`. To send, add `wallet:transactions:send` to the Coinbase OAuth provider's **Additional Scopes** field in the dashboard. An app that only displays balances can skip this page entirely and never gains the ability to move the user's funds.
* **Kraken**: every Kraken function, account reads included, needs `account.fast-api-key:funds-query`, `account.fast-api-key:funds-withdraw`, `account.fast-api-key:ledger-query`, and `account.fast-api-key:write` in the Kraken OAuth provider's **Additional Scopes** field.

## What you'll build

An **exchange transfer** flow lets a user move funds from a connected exchange account to an external wallet address. The transfer form is the same shape for every exchange: pick an account, a currency, an amount, and a destination.

Coinbase transfers can bounce back twice before they complete, each needing its own prompt:

1. **Two-step verification.** Coinbase sends the account holder a one-time code and asks for it.
2. **Travel Rule information.** For some transfers, Coinbase is legally required to collect basic recipient identity details.

Kraken only surfaces the first of these to `createKrakenExchangeTransfer` (as an optional `mfaCode` parameter). Kraken also has Travel Rule requirements in some regions, but it collects that information when the user adds a withdrawal address in Kraken's own UI, not when a transfer is submitted; Kraken's withdraw API only ever accepts a pre-approved address, never an arbitrary one. So the Kraken form is the same pattern as below with the Travel Rule step removed.

## The transfer form

Load the user's accounts, then collect a currency, amount, and destination address for the selected account.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import { getCoinbaseAccounts, type CoinbaseAccount } from '@dynamic-labs-sdk/client';

    async function loadAccountOptions(): Promise<CoinbaseAccount[]> {
      return getCoinbaseAccounts();
    }

    // Once the user picks an account and currency, the available balance is:
    function availableBalance(account: CoinbaseAccount, currency: string) {
      const balance = account.balances.find((b) => b.currency === currency);
      return balance?.availableBalance ?? balance?.balance ?? 0;
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import { useGetCoinbaseAccounts } from '@dynamic-labs-sdk/react-hooks';
    import { useState } from 'react';

    function ExchangeTransferForm({ onSubmit }: { onSubmit: (data: object) => void }) {
      const { data: accounts, isLoading } = useGetCoinbaseAccounts();
      const [accountId, setAccountId] = useState('');
      const [currency, setCurrency] = useState('');
      const [amount, setAmount] = useState('');
      const [to, setTo] = useState('');

      const account = accounts?.find((a) => a.id === accountId);

      if (isLoading) return <div>Loading accounts...</div>;

      return (
        <form
          onSubmit={(event) => {
            event.preventDefault();
            onSubmit({ accountId, currency, amount: parseFloat(amount), to });
          }}
        >
          <select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
            <option value="">Select an account</option>
            {accounts?.map((a) => (
              <option key={a.id} value={a.id}>{a.name ?? a.id}</option>
            ))}
          </select>

          {account && (
            <>
              <select value={currency} onChange={(e) => setCurrency(e.target.value)}>
                {account.balances.map((b) => (
                  <option key={b.currency} value={b.currency}>{b.currency}</option>
                ))}
              </select>
              <input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount" />
              <input value={to} onChange={(e) => setTo(e.target.value)} placeholder="Destination address" />
            </>
          )}

          <button type="submit">Transfer</button>
        </form>
      );
    }
    ```
  </Tab>
</Tabs>

<Tip>
  If you already know the recipient (a saved address, for example), collect Travel Rule fields on this same form and send them on the first submission. Coinbase checks two-step verification before Travel Rule on every request, so sending Travel Rule data late means the user goes through two-step verification twice instead of once. See [`createCoinbaseExchangeTransfer`](/docs/javascript/funding/create-coinbase-exchange-transfer) for the full field list.
</Tip>

## Handling two-step verification

The first `createCoinbaseExchangeTransfer` call for a transfer makes Coinbase send the account holder a one-time code, and the call throws `CoinbaseTransferMfaRequiredError`. Show a code input, then retry the same transfer with `mfaCode` set. A wrong or expired code throws `CoinbaseTransferMfaFailedError`; retry without `mfaCode` to get a fresh one.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import {
      createCoinbaseExchangeTransfer,
      CoinbaseTransferMfaRequiredError,
      CoinbaseTransferMfaFailedError,
      type CoinbaseTransferRequest,
    } from '@dynamic-labs-sdk/client';

    async function submitTransfer(params: CoinbaseTransferRequest) {
      try {
        return await createCoinbaseExchangeTransfer(params);
      } catch (error) {
        if (error instanceof CoinbaseTransferMfaRequiredError) {
          // showTwoStepPrompt is your implementation.
          const mfaCode = await showTwoStepPrompt({ invalid: false });
          return submitTransfer({ ...params, mfaCode });
        }

        if (error instanceof CoinbaseTransferMfaFailedError) {
          // Drop the bad code so Coinbase issues a fresh one
          // (the retry throws CoinbaseTransferMfaRequiredError again).
          const { mfaCode, ...rest } = params;
          return submitTransfer(rest);
        }

        throw error;
      }
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import { useCreateCoinbaseExchangeTransfer } from '@dynamic-labs-sdk/react-hooks';
    import {
      CoinbaseTransferMfaRequiredError,
      CoinbaseTransferMfaFailedError,
      type CoinbaseTransferRequest,
    } from '@dynamic-labs-sdk/client';
    import { useState } from 'react';

    function useTransferWithTwoStep() {
      const { mutateAsync: transfer } = useCreateCoinbaseExchangeTransfer();
      const [pendingParams, setPendingParams] = useState<CoinbaseTransferRequest | null>(null);
      const [status, setStatus] = useState<'idle' | 'awaiting' | 'invalid'>('idle');

      const submit = async (params: CoinbaseTransferRequest) => {
        setPendingParams(params);

        try {
          return await transfer(params);
        } catch (error) {
          if (error instanceof CoinbaseTransferMfaRequiredError) {
            setStatus('awaiting');
          } else if (error instanceof CoinbaseTransferMfaFailedError) {
            setStatus('invalid');
          } else {
            throw error;
          }
        }
      };

      const submitCode = (mfaCode: string) => {
        if (!pendingParams) return;
        setStatus('idle');
        return submit({ ...pendingParams, mfaCode });
      };

      return { submit, submitCode, status };
    }
    ```
  </Tab>
</Tabs>

## Handling the Travel Rule step

If a transfer needs recipient information that wasn't sent upfront, the call throws `CoinbaseTravelRuleRequiredError` with a `missingFields` array: each entry has a `name` (for example `BENEFICIARY_NAME`) and a `description` you can show directly to the user. Collect those fields and retry.

<Tabs>
  <Tab title="TypeScript">
    ```typescript theme={"system"}
    import {
      createCoinbaseExchangeTransfer,
      CoinbaseTravelRuleRequiredError,
      type CoinbaseTransferRequest,
    } from '@dynamic-labs-sdk/client';

    async function submitTransfer(params: CoinbaseTransferRequest) {
      try {
        return await createCoinbaseExchangeTransfer(params);
      } catch (error) {
        if (error instanceof CoinbaseTravelRuleRequiredError) {
          // renderTravelRuleForm shows one input per missing field
          // (field.name, field.description) and returns what the user entered.
          const travelRuleData = await renderTravelRuleForm(error.missingFields);

          // Adding travelRuleData changes the body, so drop any mfaCode.
          const { mfaCode, ...rest } = params;

          return submitTransfer({
            ...rest,
            travelRuleData: { ...rest.travelRuleData, ...travelRuleData },
          });
        }

        throw error;
      }
    }
    ```
  </Tab>

  <Tab title="React">
    ```tsx theme={"system"}
    import type { CoinbaseTravelRuleMissingField } from '@dynamic-labs-sdk/client';

    function TravelRuleForm({
      missingFields,
      onSubmit,
    }: {
      missingFields: CoinbaseTravelRuleMissingField[];
      onSubmit: (data: Record<string, string>) => void;
    }) {
      return (
        <form
          onSubmit={(event) => {
            event.preventDefault();
            const formData = new FormData(event.currentTarget);
            onSubmit(Object.fromEntries(formData));
          }}
        >
          {missingFields.map((field) => (
            <div key={field.name}>
              <label>{field.description}</label>
              <input name={field.name} required />
            </div>
          ))}
          <button type="submit">Continue</button>
        </form>
      );
    }
    ```
  </Tab>
</Tabs>

<Warning>
  Adding `travelRuleData` on a retry changes the request body, which invalidates any `mfaCode` already collected for it. Drop `mfaCode` from the params before this retry so the next attempt gets a fresh two-step code if one is still needed, instead of failing with `CoinbaseTransferMfaFailedError`.
</Warning>

## Putting it together

Combine both handlers into a single retry loop. In practice this resolves in one attempt for most transfers, since the earlier tip has you sending known recipient info upfront.

```typescript theme={"system"}
import {
  createCoinbaseExchangeTransfer,
  CoinbaseTransferMfaRequiredError,
  CoinbaseTransferMfaFailedError,
  CoinbaseTravelRuleRequiredError,
  type CoinbaseTransferRequest,
} from '@dynamic-labs-sdk/client';

async function completeTransfer(baseParams: CoinbaseTransferRequest) {
  let params = baseParams;

  while (true) {
    try {
      return await createCoinbaseExchangeTransfer(params);
    } catch (error) {
      if (error instanceof CoinbaseTransferMfaRequiredError) {
        const mfaCode = await showTwoStepPrompt({ invalid: false });
        params = { ...params, mfaCode };
      } else if (error instanceof CoinbaseTransferMfaFailedError) {
        const { mfaCode, ...rest } = params;
        params = { ...rest, mfaCode: await showTwoStepPrompt({ invalid: true }) };
      } else if (error instanceof CoinbaseTravelRuleRequiredError) {
        const { mfaCode, ...rest } = params;
        const travelRuleData = await renderTravelRuleForm(error.missingFields);
        params = { ...rest, travelRuleData: { ...rest.travelRuleData, ...travelRuleData } };
      } else {
        throw error;
      }
    }
  }
}
```

## Handling errors

| Error                              | Cause                                                       | What to do                                                                                 |
| ---------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `CoinbaseTransferMfaRequiredError` | Coinbase sent a two-step code.                              | Show a code input, retry with `mfaCode` set.                                               |
| `CoinbaseTransferMfaFailedError`   | The code was wrong or expired.                              | Retry without `mfaCode` to get a fresh one.                                                |
| `CoinbaseTravelRuleRequiredError`  | Recipient information is missing.                           | Show a field per `missingFields` entry, retry with `travelRuleData` set.                   |
| Kraken transfer rejects            | Insufficient balance, or an address that isn't whitelisted. | Show the message; check `getKrakenWhitelistedAddresses()` if the destination is the issue. |

## See also

* [createCoinbaseExchangeTransfer](/docs/javascript/funding/create-coinbase-exchange-transfer) reference: full parameter list and error details
* [getCoinbaseAccounts](/docs/javascript/funding/get-coinbase-accounts) reference: account and balance shape
* [Kraken Integration](/docs/javascript/funding/kraken-integration): the simpler, single-step Kraken flow
* [Funding flows](/docs/javascript/building-ui/funding-flows): on-ramp providers (buying crypto with fiat, a different flow from exchange withdrawals)
