Skip to main content

Overview

Bluvo’s OAuth2 integration lets your users connect their exchange accounts through a secure popup window. No API keys are exposed to your frontend, users authenticate directly with the exchange, and your app receives a wallet ID that represents the connection. From that point forward, the wallet ID is all you need to fetch balances, request withdrawal quotes, and execute transactions. Bluvo handles the entire OAuth lifecycle behind the scenes: authorization URL generation, token exchange, token refresh, AES-256-CBC encryption, and tenant-isolated storage. This page walks through the OAuth2 popup flow using @bluvo/react and Next.js server actions, the fastest path to a working integration.
If you pass a walletId that already exists, startWithdrawalFlow() automatically detects the existing wallet and skips OAuth, no separate resume call needed.

Integration Levels

Bluvo supports multiple integration depths. Pick the level that matches your team’s needs:
LevelDescriptionEffortPackage
REST APIFull control, implement the entire flow yourself~3-4 weeksAPI Reference
Server SDKLanguage-specific wrapper around the REST API~3 weeks@bluvo/sdk-ts
State Machine SDK + Server SDKYou build the UI, the state machine handles orchestration~5 days@bluvo/sdk-ts
React State MachineReact hooks with built-in state management~24 hours@bluvo/react
This page focuses on the React State Machine approach with @bluvo/react. The same concepts (states, transitions, error handling) apply to all levels.

How OAuth2 Works

When a user connects an exchange account, this is what happens under the hood:
  1. Your app calls startWithdrawalFlow({ exchange, walletId }) with the user’s selected exchange and a wallet ID (new or previously stored)
  2. Bluvo requests an OAuth2 authorization URL from the exchange and opens a popup window
  3. The user authenticates directly with the exchange inside the popup
  4. The exchange redirects back to Bluvo’s callback with an authorization code
  5. Bluvo exchanges the code for access/refresh tokens server-side, then encrypts and stores them
  6. A wallet record is created and the wallet ID is returned to your app via WebSocket
  7. The popup closes automatically and the flow transitions to wallet:loading

What Bluvo Handles vs. What You Handle

Bluvo manages:
  • OAuth URL generation and popup lifecycle
  • Token exchange, refresh, and revocation
  • AES-256-CBC encryption of all credentials
  • WebSocket notifications for real-time state updates
  • Wallet ID creation and tenant-isolated storage
You are responsible for:
  • Calling startWithdrawalFlow() when the user selects an exchange
  • Rendering UI based on the current state
  • Persisting wallet IDs via the onWalletConnectedFn callback (so returning users skip OAuth automatically)
  • Handling popup-closed and error states gracefully

State Machine

The useBluvoFlow hook exposes the current state of the OAuth flow as boolean properties. Your UI simply reacts to these states.

OAuth State Flow

State Reference

StateMeaningSuggested UI
idleFlow not startedExchange selector
oauth:waitingPopup open, user authenticatingSpinner + “Complete authentication in the popup window”
oauth:processingToken exchange in progressSpinner + “Connecting your account…”
oauth:completedOAuth succeeded, auto-transitionsBrief “Connected!” message
oauth:errorRecoverable errorError message + “Try Again” button
oauth:fatalNon-recoverable (exchange rejected)Error message + “Start Over” button
oauth:window_closed_by_userUser closed the popup”Authentication cancelled” + retry option
wallet:loadingFetching wallet balancesLoading skeleton
wallet:readyWallet connected and loadedBalance list, ready for withdrawals

Hook Booleans

Each state maps to a boolean on the flow object returned by useBluvoFlow:
// OAuth lifecycle
flow.isOAuthPending         // true during oauth:waiting OR oauth:processing
flow.isOAuthWaiting         // true when popup is open (oauth:waiting)
flow.isOAuthProcessing      // true during token exchange (oauth:processing)
flow.isOAuthComplete        // true on success (oauth:completed)

// OAuth errors
flow.isOAuthError           // true for any OAuth error (recoverable or fatal)
flow.isOAuthFatal           // true for non-recoverable errors only
flow.isOAuthWindowBeenClosedByTheUser  // true when user closed popup

// Wallet
flow.isWalletLoading        // true while fetching balances
flow.isWalletReady          // true when wallet is connected and balances loaded
flow.isWalletError          // true on wallet loading error

React Implementation

Prerequisites

Install the React SDK:
pnpm add @bluvo/react @bluvo/sdk-ts
Set up environment variables:
VariableSideDescription
BLUVO_ORG_IDServerOrganization ID from Bluvo Portal
BLUVO_PROJECT_IDServerProject ID
BLUVO_API_KEYServerSecret API key (never expose to the client)
NEXT_PUBLIC_BLUVO_ORG_IDClientOrganization ID for hooks
NEXT_PUBLIC_BLUVO_PROJECT_IDClientProject ID for hooks
NEXT_PUBLIC_BLUVO_ENVClientOptional: production, staging, or development

Server Actions

Create server actions that proxy Bluvo SDK calls.
// app/actions/flowActions.ts
'use server'

import { createClient, createSandboxClient, createDevClient } from "@bluvo/sdk-ts";

function loadBluvoClient() {
    const env = process.env.NEXT_PUBLIC_BLUVO_ENV;
    if (env === 'production') {
        return createClient({
            orgId: process.env.BLUVO_ORG_ID!,
            projectId: process.env.BLUVO_PROJECT_ID!,
            apiKey: process.env.BLUVO_API_KEY!,
        });
    } else if (env === 'staging') {
        return createSandboxClient({
            orgId: process.env.BLUVO_ORG_ID!,
            projectId: process.env.BLUVO_PROJECT_ID!,
            apiKey: process.env.BLUVO_API_KEY!,
        });
    } else {
        return createDevClient({
            orgId: process.env.BLUVO_ORG_ID!,
            projectId: process.env.BLUVO_PROJECT_ID!,
            apiKey: process.env.BLUVO_API_KEY!,
        });
    }
}

export async function listExchanges(status?: string) {
    return await loadBluvoClient().oauth2.listExchanges(status as any);
}

export async function fetchWithdrawableBalances(walletId: string) {
    return await loadBluvoClient().wallet.withdrawals.getWithdrawableBalance(walletId);
}

export async function requestQuotation(walletId: string, params: {
    asset: string; amount: string; address: string;
    network?: string; tag?: string; includeFee?: boolean;
}) {
    return await loadBluvoClient().wallet.withdrawals.requestQuotation(walletId, params);
}

export async function executeWithdrawal(
    walletId: string, idem: string, quoteId: string,
    params?: {
        twofa?: string | null; emailCode?: string | null;
        smsCode?: string | null; bizNo?: string | null;
        tag?: string | null; params?: { dryRun?: boolean } | null;
    }
) {
    return await loadBluvoClient().wallet.withdrawals.executeWithdrawal(
        walletId, idem, quoteId, params ?? {}
    );
}

export async function getWalletById(walletId: string) {
    return await loadBluvoClient().wallet.get(walletId);
}

export async function pingWalletById(walletId: string) {
    return await loadBluvoClient().wallet.ping(walletId);
}

Initialize the Hook

Wire up useBluvoFlow in a client component. No provider or context wrapper is needed, the hook manages its own state.
// app/connect/page.tsx
"use client";

import React from "react";
import { useBluvoFlow } from "@bluvo/react";
import {
    listExchanges, fetchWithdrawableBalances, requestQuotation,
    executeWithdrawal, getWalletById, pingWalletById
} from "../actions/flowActions";

export default function ConnectExchange() {
    const flow = useBluvoFlow({
        orgId: process.env.NEXT_PUBLIC_BLUVO_ORG_ID!,
        projectId: process.env.NEXT_PUBLIC_BLUVO_PROJECT_ID!,
        listExchangesFn: listExchanges,
        fetchWithdrawableBalanceFn: fetchWithdrawableBalances,
        requestQuotationFn: requestQuotation,
        executeWithdrawalFn: executeWithdrawal,
        getWalletByIdFn: getWalletById,
        pingWalletByIdFn: pingWalletById,
        onWalletConnectedFn: (walletId, exchange) => {
            // Persist so returning users skip OAuth automatically
            localStorage.setItem("walletId", walletId);
            localStorage.setItem("exchange", exchange);
        },
        options: {
            sandbox: process.env.NEXT_PUBLIC_BLUVO_ENV === 'staging',
            dev: process.env.NEXT_PUBLIC_BLUVO_ENV === 'development',
            // autoRefreshQuotation: true,  // optional: auto-refresh quotes
            // cache: true,                 // optional: cache exchange list
        },
    });

    // Render UI based on flow state, see next section
}
useBluvoFlow captures its configuration at mount time. Changing props after mount has no effect, remount the component to reinitialize.

Render OAuth States

Use the hook’s boolean properties to conditionally render each phase of the flow:
Each walletId must be unique per user and per exchange. See Wallet Id for the recommended pattern.
export default function ConnectExchange() {
    const flow = useBluvoFlow({ /* ... config from above */ });

    const [selectedExchange, setSelectedExchange] = React.useState<string>("");

    // Load available exchanges on mount
    React.useEffect(() => {
        flow.listExchanges("live");
    }, []);

    // Generate a new wallet ID, or use one from a previous session
    const getWalletId = () => {
        const stored = localStorage.getItem("walletId");
        if (stored) return stored;
        return crypto.randomUUID();
    };

    // Step 1: Exchange selector
    if (flow.isIdle || flow.isExchangesReady) {
        return (
            <div>
                <h2>Connect your exchange</h2>

                {flow.isExchangesLoading && <p>Loading exchanges...</p>}
                {flow.exchangesError && <p>Failed to load exchanges</p>}

                {flow.exchanges.length > 0 && (
                    <>
                        <select
                            value={selectedExchange}
                            onChange={(e) => setSelectedExchange(e.target.value)}
                        >
                            <option value="">Select an exchange</option>
                            {flow.exchanges.map((exchange) => (
                                <option key={exchange.id} value={exchange.id}>
                                    {exchange.name}
                                </option>
                            ))}
                        </select>
                        <button
                            disabled={!selectedExchange}
                            onClick={() =>
                                flow.startWithdrawalFlow({
                                    exchange: selectedExchange,
                                    walletId: getWalletId(),
                                })
                            }
                        >
                            Connect
                        </button>
                    </>
                )}
            </div>
        );
    }

    // Step 2: OAuth in progress, popup is open
    if (flow.isOAuthPending) {
        return (
            <div>
                <p>Complete authentication in the popup window...</p>
                <button onClick={() => flow.cancel()}>Cancel</button>
            </div>
        );
    }

    // Step 3: User closed the popup
    if (flow.isOAuthWindowBeenClosedByTheUser) {
        return (
            <div>
                <p>Authentication was cancelled.</p>
                <button onClick={() => flow.startWithdrawalFlow({
                    exchange: selectedExchange,
                    walletId: getWalletId(),
                })}>
                    Try Again
                </button>
                <button onClick={() => flow.cancel()}>Start Over</button>
            </div>
        );
    }

    // Step 4: OAuth error
    if (flow.isOAuthError) {
        return (
            <div>
                <p>Connection failed: {flow.error?.message}</p>
                {!flow.isOAuthFatal ? (
                    <button onClick={() => flow.startWithdrawalFlow({
                        exchange: selectedExchange,
                        walletId: getWalletId(),
                    })}>
                        Try Again
                    </button>
                ) : null}
                <button onClick={() => flow.cancel()}>Start Over</button>
            </div>
        );
    }

    // Step 5: Loading wallet balances
    if (flow.isWalletLoading) {
        return <p>Loading your balances...</p>;
    }

    // Step 6: Wallet ready, OAuth flow complete
    if (flow.isWalletReady) {
        return (
            <div>
                <h2>Connected</h2>
                {flow.walletBalances.map((balance) => (
                    <div key={balance.asset}>
                        {balance.asset}: {balance.free}
                    </div>
                ))}
            </div>
        );
    }

    return null;
}

Error Handling

The OAuth flow has three distinct error scenarios, each requiring different UI treatment: Recoverable OAuth error (flow.isOAuthError && !flow.isOAuthFatal), a transient failure like a network timeout or temporary exchange issue. The user can retry by calling startWithdrawalFlow() again with the same exchange. Fatal OAuth error (flow.isOAuthFatal), the exchange permanently rejected the connection (e.g., account restrictions, unsupported region). Display flow.error?.message and offer flow.cancel() to reset the flow. User closed popup (flow.isOAuthWindowBeenClosedByTheUser), the user manually closed the authentication window. This is not an error, offer a retry button or let them pick a different exchange.
After wallet:ready, errors shift to wallet-specific states. Use flow.isWalletError for general wallet errors, flow.hasWalletNotFoundError when a wallet ID is no longer valid, and flow.hasInvalidCredentialsError when stored tokens have been revoked by the exchange.

What Comes After OAuth

Once flow.isWalletReady is true, the OAuth flow is complete. From here, the typical withdrawal flow continues:
  1. User selects an asset, amount, and destination address
  2. Your app calls flow.requestQuote(...) to get a real-time quote with fees
  3. User confirms, and flow.executeWithdrawal(quoteId) submits the transaction
  4. If the exchange requires 2FA, the hook surfaces flow.requires2FA or flow.requires2FAMultiStep
  5. Transaction completes at flow.isWithdrawalComplete
The state machine handles all of these transitions automatically. For the full withdrawal implementation, see the code samples.

Next Steps

Get API Keys

Create your organization and project in the Bluvo Portal

Code Samples

Full working examples for Next.js, React, and more

Encryption & Security

How Bluvo encrypts and isolates exchange credentials

Supported Exchanges

See which exchanges support OAuth2 connections