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:
Level Description Effort Package REST API Full control, implement the entire flow yourself ~3-4 weeks API Reference Server SDK Language-specific wrapper around the REST API ~3 weeks @bluvo/sdk-tsState Machine SDK + Server SDK You build the UI, the state machine handles orchestration ~5 days @bluvo/sdk-tsReact State Machine React 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:
Your app calls startWithdrawalFlow({ exchange, walletId }) with the user’s selected exchange and a wallet ID (new or previously stored)
Bluvo requests an OAuth2 authorization URL from the exchange and opens a popup window
The user authenticates directly with the exchange inside the popup
The exchange redirects back to Bluvo’s callback with an authorization code
Bluvo exchanges the code for access/refresh tokens server-side, then encrypts and stores them
A wallet record is created and the wallet ID is returned to your app via WebSocket
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
State Meaning Suggested UI idleFlow not started Exchange selector oauth:waitingPopup open, user authenticating Spinner + “Complete authentication in the popup window” oauth:processingToken exchange in progress Spinner + “Connecting your account…” oauth:completedOAuth succeeded, auto-transitions Brief “Connected!” message oauth:errorRecoverable error Error 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 balances Loading skeleton wallet:readyWallet connected and loaded Balance 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:
Variable Side Description BLUVO_ORG_IDServer Organization ID from Bluvo Portal BLUVO_PROJECT_IDServer Project ID BLUVO_API_KEYServer Secret API key (never expose to the client) NEXT_PUBLIC_BLUVO_ORG_IDClient Organization ID for hooks NEXT_PUBLIC_BLUVO_PROJECT_IDClient Project ID for hooks NEXT_PUBLIC_BLUVO_ENVClient Optional: 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:
User selects an asset, amount, and destination address
Your app calls flow.requestQuote(...) to get a real-time quote with fees
User confirms, and flow.executeWithdrawal(quoteId) submits the transaction
If the exchange requires 2FA, the hook surfaces flow.requires2FA or flow.requires2FAMultiStep
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