> ## Documentation Index
> Fetch the complete documentation index at: https://www.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.

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