Skip to content

Example

End-to-end Coinbase OAuth withdraw with React hooks: connect, build the withdraw form from CoinbaseProvider, handle 2FA, branch screens, show a success UI.

Route here when the payment method has provider: coinbase and type: exchange_oauth. Requires SwappedConnectProvider, CoinbaseProvider, and a loaded active session. After a completed payment, call restartSession() before starting again.

Use state hooks for UI (including which step to show). Use event hooks only for side effects (navigate, toast).

1. Providers + session

tsx
import { createSwappedConnectClient } from '@swapped/connect-sdk'
import {
  CoinbaseProvider,
  SwappedConnectProvider,
} from '@swapped/connect-sdk/react'

const client = createSwappedConnectClient({
  sessionId: 'your-session-id', // from your backend
})

void client.loadSession()

function App() {
  return (
    <SwappedConnectProvider client={client}>
      <CoinbaseProvider>
        <CoinbaseFlow />
      </CoinbaseProvider>
    </SwappedConnectProvider>
  )
}

2. Gate on Coinbase availability

Use CoinbaseGuard (or useCoinbaseAvailability for custom branching).

tsx
import { CoinbaseGuard } from '@swapped/connect-sdk/react'

function App() {
  return (
    <CoinbaseGuard
      loading={<p>Loading…</p>}
      fallback={<p>Coinbase is not available for this session</p>}
      onNotAvailable={() => {
        // e.g. navigate('/payment-methods')
      }}
    >
      <CoinbaseFlow />
    </CoinbaseGuard>
  )
}

3. Connect

tsx
import { useCoinbaseConnection } from '@swapped/connect-sdk/react'

function ConnectStep() {
  const {
    isConnected,
    isConnecting,
    isInitializing,
    isPopupOpen,
    connect,
    error,
  } = useCoinbaseConnection()

  if (isInitializing) {
    return <p>Restoring Coinbase session…</p>
  }

  if (isConnected) {
    return <p>Connected</p>
  }

  return (
    <div>
      {error && <p>{error.message}</p>}
      <button
        type="button"
        disabled={isConnecting || isPopupOpen}
        onClick={() => void connect()}
      >
        {isPopupOpen ? 'Complete login in the popup…' : 'Connect Coinbase'}
      </button>
    </div>
  )
}

4. Withdraw form (selection from context)

CoinbaseProvider owns token / network / funding / amount. Hooks read and update that state; startWithdrawal() builds the request from context. When the amount is above the fee-reserve max, the SDK auto-adjusts it, syncs the amount input, and runs onAmountAdjusted (toast, confirm dialog, etc.).

tsx
import {
  useCoinbaseAmount,
  useCoinbaseBalances,
  useCoinbaseFundingTokens,
  useCoinbaseNetwork,
  useCoinbaseSelection,
  useCoinbaseToken,
  useCoinbaseWithdrawal,
  useCoinbaseWithdrawalCooldown,
} from '@swapped/connect-sdk/react'

function WithdrawForm() {
  const { allBalances, isLoading, error: balancesError } = useCoinbaseBalances()
  const { selectedCurrency, selectToken, isTokenSelected } = useCoinbaseToken()
  const {
    activeNetworks,
    selectedNetwork,
    select: selectNetwork,
    isLoading: networksLoading,
  } = useCoinbaseNetwork()
  const funding = useCoinbaseFundingTokens()
  const {
    amountDisplay,
    setAmount,
    amountFiat,
    setAmountFiat,
    setMax,
    formatted,
    error: amountError,
    setTouched,
    isFiatAvailable,
  } = useCoinbaseAmount()
  const { canSubmit } = useCoinbaseSelection()
  const { startWithdrawal, isStartingWithdrawal, error: withdrawError } =
    useCoinbaseWithdrawal()
  const { isInCooldown, remainingMs } = useCoinbaseWithdrawalCooldown()

  async function onWithdraw() {
    setTouched(true)
    const result = await startWithdrawal(undefined, {
      onAmountAdjusted: ({ formatted }) => {
        // Toast / banner — withdrawal continues with the adjusted amount.
        // Return false (or await a confirm dialog) to cancel instead.
        showToast(
          `Amount reduced to ${formatted.adjustedAmount} for the $${formatted.feeReserveFiat} network fee reserve.`,
        )
      },
    })

    if (result.status === 'idle') {
      // Cancelled via onAmountAdjusted — stay on the form
    }
  }

  if (isLoading) return <p>Loading balances…</p>
  if (balancesError) return <p>{balancesError.message}</p>

  return (
    <div>
      <ul>
        {allBalances.map(balance => (
          <li key={balance.id}>
            <button
              type="button"
              disabled={!balance.eligible}
              aria-pressed={isTokenSelected(balance.currency)}
              onClick={() => selectToken(balance.currency)}
            >
              {balance.currency} — {balance.formatted.aggregatedBalance}
            </button>
          </li>
        ))}
      </ul>

      <ul>
        {activeNetworks.map(network => (
          <li key={network.id}>
            <button
              type="button"
              disabled={!network.eligible || networksLoading}
              aria-pressed={selectedNetwork === network.id}
              onClick={() => selectNetwork(network.id)}
            >
              {network.name}
            </button>
          </li>
        ))}
      </ul>

      <ul>
        {funding.fundingTokens.map(token => (
          <li key={token.currency}>
            <label>
              <input
                type="checkbox"
                checked={funding.isTokenSelected(token.currency)}
                onChange={() => funding.toggle(token.currency)}
                disabled={token.isCurrentToken}
              />
              {token.currency} — {token.formatted.balance}
            </label>
          </li>
        ))}
      </ul>

      <p>
        Aggregated: {funding.aggregatedBalance.formatted.balance}{' '}
        {selectedCurrency}
      </p>

      <input
        value={amountDisplay}
        onChange={event => setAmount(event.target.value)}
        onBlur={() => setTouched(true)}
        inputMode="decimal"
      />
      {isFiatAvailable && (
        <input
          value={amountFiat}
          onChange={event => setAmountFiat(event.target.value)}
          onBlur={() => setTouched(true)}
          inputMode="decimal"
        />
      )}
      <button type="button" onClick={setMax}>
        Max
      </button>
      <p>
        Between {formatted.minAmount} and {formatted.maxAmount}
        {formatted.minAmountFiat
          ? ` ($${formatted.minAmountFiat} – $${formatted.maxAmountFiat})`
          : ''}
      </p>
      {amountError && <p>{amountError.crypto.message}</p>}
      {amountError?.fiat && <p>{amountError.fiat.message}</p>}
      {withdrawError && <p>{withdrawError.message}</p>}

      <button
        type="button"
        disabled={!canSubmit || isStartingWithdrawal || isInCooldown}
        onClick={() => void onWithdraw()}
      >
        {isInCooldown
          ? `Wait ${Math.ceil(remainingMs / 1000)}s`
          : 'Continue to confirmation'}
      </button>
    </div>
  )
}

5. Confirm 2FA

The 2FA code can come from SMS, email, or an authenticator app and is typically 6–7 characters. Do not hardcode a 6-digit-only input.

tsx
import { useCoinbaseWithdrawal } from '@swapped/connect-sdk/react'
import { useState } from 'react'

function TwoFactorStep() {
  const {
    requires2fa,
    confirmWithdrawal,
    cancelWithdrawal,
    isConfirming,
    is2faInvalid,
    clear2faInvalid,
    error,
  } = useCoinbaseWithdrawal()
  const [code, setCode] = useState('')

  if (!requires2fa) return null

  return (
    <div>
      {is2faInvalid && <p>Invalid 2FA code. Please try again.</p>}
      {error && <p>{error.message}</p>}
      <input
        value={code}
        onChange={event => {
          setCode(event.target.value)
          clear2faInvalid()
        }}
        autoComplete="one-time-code"
        placeholder="2FA code"
      />
      <button
        type="button"
        disabled={isConfirming || !code}
        onClick={() => void confirmWithdrawal(code)}
      >
        Confirm
      </button>
      <button type="button" onClick={cancelWithdrawal}>
        Cancel
      </button>
    </div>
  )
}

6. Drive steps from withdrawal state

Derive the current screen from connection + withdrawal flags (same pattern as a typical page-level flow):

tsx
import {
  useCoinbaseConnection,
  useCoinbaseWithdrawal,
} from '@swapped/connect-sdk/react'

type CoinbaseStep = 'login' | 'form' | 'twofa' | 'done'

function CoinbaseFlow() {
  const { isConnected } = useCoinbaseConnection()
  const withdrawal = useCoinbaseWithdrawal()

  const step: CoinbaseStep = withdrawal.result
    ? 'done'
    : withdrawal.requires2fa ||
        withdrawal.isConfirming ||
        withdrawal.status === 'confirming'
      ? 'twofa'
      : isConnected
        ? 'form'
        : 'login'

  if (step === 'login') return <ConnectStep />
  if (step === 'form') return <WithdrawForm />
  if (step === 'twofa') return <TwoFactorStep />
  return <SuccessStep result={withdrawal.result!} />
}
StepCondition
donewithdrawal.result is set (status completed)
twofarequires2fa, isConfirming, or status === 'confirming'
formConnected, withdrawal not pending confirmation
loginNot connected

Prefer state hooks for this branching. Use event hooks only if you need a side effect (e.g. useOnCoinbaseWithdrawalCompleted to navigate to a separate route).

7. Success screen

Two options:

  1. useCoinbaseWithdrawal().result — full CoinbaseWithdrawal (id, amounts, network fee, destination). Best when the success UI lives next to the withdraw form.
  2. useCoinbaseCompletedTransactionSummary() — flatter display-ready summary. Best for a dedicated success route or when you only need amount / fee / destination.

After a completed payment the session is no longer active — call restartSession() before starting another one.

From withdrawal.result

tsx
import type { CoinbaseWithdrawal } from '@swapped/connect-sdk'
import { useSwappedConnectClient } from '@swapped/connect-sdk/react'

function SuccessStep({ result }: { result: CoinbaseWithdrawal }) {
  const client = useSwappedConnectClient()

  return (
    <div>
      <p>Withdrawal submitted</p>
      <p>
        {result.amount.amount} {result.amount.currency}
      </p>
      <p>
        Native: {result.nativeAmount.amount} {result.nativeAmount.currency}
      </p>
      <p>
        Network fee: {result.network.transactionFee.amount}{' '}
        {result.network.transactionFee.currency}
      </p>
      <p>Status: {result.status}</p>
      <p>Network status: {result.network.status}</p>
      {result.destinationAddress && (
        <p>Destination: {result.destinationAddress}</p>
      )}
      <p>Id: {result.id}</p>
      <p>Created: {new Date(result.createdAt).toLocaleString()}</p>
      <button type="button" onClick={() => void client.restartSession()}>
        Start another payment
      </button>
    </div>
  )
}

From the summary hook

tsx
import {
  useCoinbaseCompletedTransactionSummary,
  useSwappedConnectClient,
} from '@swapped/connect-sdk/react'

function SuccessStep() {
  const summary = useCoinbaseCompletedTransactionSummary()
  const client = useSwappedConnectClient()

  if (!summary) return null

  return (
    <div>
      <p>
        Sent {summary.amount.amount} {summary.amount.currency}
      </p>
      {summary.network && <p>Network: {summary.network}</p>}
      {summary.fee && (
        <p>
          Fee {summary.fee.amount} {summary.fee.currency}
        </p>
      )}
      {summary.destinationAddress && (
        <p>Destination: {summary.destinationAddress}</p>
      )}
      {summary.status && <p>Status: {summary.status}</p>}
      {summary.id && <p>Withdrawal id: {summary.id}</p>}
      <button type="button" onClick={() => void client.restartSession()}>
        Start another payment
      </button>
    </div>
  )
}

See Summary for field details and nullability after reload / restartSession().

Putting it together

  1. Gate with CoinbaseGuard
  2. Connect → withdraw form → 2FA (if required) → success
  3. Branch screens from isConnected + useCoinbaseWithdrawal() (section 6)
  4. Show success from result or useCoinbaseCompletedTransactionSummary
  5. restartSession() before another payment

Next