@unifold/connect-react
v0.1.80
Published
Unifold Connect React - Complete React SDK with UI components for crypto deposits
Downloads
37,597
Readme
@unifold/connect-react
The complete React SDK for integrating crypto deposits and onramp functionality into your application. Simple, powerful, and fully customizable.
Installation
npm install @unifold/connect-react
# or
pnpm add @unifold/connect-reactQuick Start
1. Wrap your app with UnifoldProvider
import { UnifoldProvider } from '@unifold/connect-react';
function App() {
return (
<UnifoldProvider
publishableKey="pk_test_your_key"
config={{
modalTitle: 'Deposit Crypto',
hideDepositTracker: false,
}}
>
<YourApp />
</UnifoldProvider>
);
}2. Call beginDeposit() anywhere in your app
import { useUnifold } from '@unifold/connect-react';
function DepositButton() {
const { beginDeposit } = useUnifold();
const handleDeposit = async () => {
try {
// beginDeposit returns a Promise
const result = await beginDeposit({
// Required fields
userId: 'user_123', // Your user's unique identifier
destinationChainId: '137', // Polygon
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
// Optional callbacks (fired immediately)
onSuccess: ({ message }) => {
console.log('Immediate callback:', message);
},
onError: ({ message }) => {
console.error('Immediate callback error:', message);
},
});
// Handle the result from the promise
console.log('Deposit completed!', result);
alert('Success: ' + result.message);
} catch (error) {
// Handle the error from the promise
console.error('Deposit failed:', error);
alert('Error: ' + error.message);
}
};
return <button onClick={handleDeposit}>Deposit</button>;
}That's it! The modal with full deposit UI will appear automatically.
API Reference
UnifoldProvider
Wraps your application and provides the Unifold context.
Props:
publishableKey(required): Your Unifold API publishable keyconfig(optional): Configuration objectmodalTitle(optional): Custom title for the deposit modalhideDepositTracker(optional): Hide the deposit tracker optionappearance(optional): Theme appearance -'light'|'dark'|'auto'(defaults to'dark')'light': Force light mode'dark': Force dark mode (default)'auto': Use system preference and respond to changes
layout(optional): Deposit menu layout and its options -{ type, tabOrder }(typedefaults to'tabs')
children(required): Your React components
<UnifoldProvider
publishableKey="pk_test_..."
config={{
modalTitle: 'Deposit Crypto',
hideDepositTracker: false,
appearance: 'dark', // 'light' | 'dark' | 'auto' (defaults to 'dark')
}}
>
{children}
</UnifoldProvider>Menu layout
layout controls how the deposit menu arranges the funding methods, and carries
the options belonging to that layout:
<UnifoldProvider
publishableKey="pk_test_..."
config={{
layout: {
type: 'tabs',
tabOrder: ['cash', 'crypto'], // opens on "Use Cash"
},
}}
>
{children}
</UnifoldProvider>type: 'tabs' (the default) groups the options under "Use Crypto" and "Use
Cash" tabs, and tabOrder controls which tab leads. 'stacked' puts every
option in a single list instead:
config={{ layout: { type: 'stacked' } }}Tabs you list lead, in the order given; any tab you leave out keeps its default
position behind them, so ['cash'] is equivalent to ['cash', 'crypto']. The
leading tab is the one shown on the main menu until the user picks another —
deep-linking with initialScreen still opens that method's own tab. A tab whose
methods are all disabled stays hidden regardless of order, and the tab bar
disappears entirely when only one tab has options.
tabOrder has no effect under type: 'stacked', where the methods are
interleaved into a single list rather than split across tabs.
The default changed to
'tabs'. It was previously'stacked', so an integration that sets neitherlayoutnordisplayModewill see the tabbed menu after upgrading. Passlayout: { type: 'stacked' }to keep the single list.
Migrating from displayMode
displayMode is deprecated but still honoured, so existing integrations keep
working unchanged. It only sets the layout type:
config={{ displayMode: 'stacked' }} // before
config={{ layout: { type: 'stacked' } }} // afterlayout.type wins when both are set. A layout that omits type still falls
back to displayMode, so you can adopt the layout's options without changing
how the layout is selected.
useUnifold()
Hook that provides access to the deposit functionality.
Returns:
publishableKey: The current publishable keybeginDeposit(config): Function to launch the deposit modal (returns a Promise)closeDeposit(): Function to programmatically close the modal
const { beginDeposit, closeDeposit, publishableKey } = useUnifold();beginDeposit(config)
Launches the deposit modal with the specified configuration. Returns a Promise that resolves when the deposit completes or rejects on error/cancellation.
Parameters (DepositConfig):
| Parameter | Type | Required | Description |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| userId | string | ✅ | Your user's unique identifier for wallet creation/tracking |
| destinationChainId | string | ✅ | Target blockchain chain ID |
| destinationTokenAddress | string | ✅ | Token contract address |
| destinationTokenSymbol | string | ✅ | Token symbol (e.g., "USDC") |
| recipientAddress | string | ✅ | Recipient wallet address |
| defaultSourceChainType | string | - | Prefer source chain type in Transfer Crypto, Wallet Connect, and Connect Exchange (e.g. "solana", "ethereum"). Must be paired with defaultSourceChainId + symbol or token address. If omitted or no match, each flow uses its normal ordering. |
| defaultSourceChainId | string | - | Source chain ID (e.g. "mainnet", "137"). Paired with defaultSourceChainType. |
| defaultSourceTokenAddress | string | - | Source token contract address. Paired with chain type + chain ID. |
| defaultSourceSymbol | string | - | Source token symbol (e.g. "USDC"). Paired with chain type + chain ID. |
| prefilledAmountUsd | string | - | Optional USD amount prefilled for deposit amount inputs (Connect Wallet, Deposit with Card, Bank Transfer, Cash App). It is applied as initial typed input and does not enable checkout mode. |
| onSuccess | function | - | Success callback (fired immediately) |
| onError | function | - | Error callback (fired immediately) |
| initialScreen | 'main' \| 'transfer' \| 'card' \| 'cashapp' \| 'tracker' \| 'pay_with_exchange' \| 'exchange_connect' \| 'wallet_connect' | - | main (default) = deposit menu. transfer / card use the same geo/validation gates as the menu. tracker opens the list without those gates. cashapp, pay_with_exchange, exchange_connect, wallet_connect open their respective flows and fall back to main when the matching feature flag (enableCashApp, enablePayWithExchange, enableConnectExchange, enableConnectWallet) is disabled. If not main, the header back is hidden at the flow root (standalone); from main, back returns to the menu. Inner steps (card quotes/onramp, tracker detail, exchange pending, cashapp payment) still show back. |
Returns: Promise<DepositResult>
DepositResult:
interface DepositResult {
message: string;
transaction?: unknown;
executionId?: string;
}DepositError:
interface DepositError {
message: string;
error?: unknown;
code?: string; // e.g., 'DEPOSIT_CANCELLED', 'POLLING_ERROR'
}Example:
// Promise-based (async/await)
try {
const result = await beginDeposit({
userId: 'user_123',
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
});
console.log('Success:', result);
} catch (error) {
console.error('Error:', error);
}
// Standalone Transfer Crypto (geo checks + no back to menu by default)
await beginDeposit({
externalUserId: 'user_123',
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
initialScreen: 'transfer',
});
// Deposit mode with pre-filled amount (no Payment Intent required)
await beginDeposit({
externalUserId: 'user_123',
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
initialScreen: 'wallet_connect',
prefilledAmountUsd: '25.00',
});
// Hybrid (promise + callbacks)
const depositPromise = beginDeposit({
userId: 'user_123',
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
onSuccess: (data) => {
// Immediate callback
showToast('Deposit initiated!');
},
onError: (error) => {
// Immediate callback
showToast('Error: ' + error.message);
},
});
// Later handling
depositPromise
.then((result) => console.log('Completed:', result))
.catch((error) => console.error('Failed:', error));Features
- ✅ Multi-chain Support - Ethereum, Polygon, Arbitrum, Optimism, Solana, Bitcoin
- ✅ Fiat Onramp - Credit/debit card purchases via Meld
- ✅ Auto-swap - Automatic token conversion
- ✅ QR Codes - Mobile wallet support
- ✅ Deposit Tracking - Real-time status updates
- ✅ TypeScript - Full type definitions
- ✅ SSR-safe - Works with Next.js and other frameworks
- ✅ Customizable - Configure per-transaction
Analytics & Journey Events
The SDK emits two complementary streams so you can understand the full user journey — what method they chose, where they dropped off, and whether the transaction completed or failed.
1. Developer-facing onEvent (deposit & withdraw)
Pass onEvent to beginDeposit / beginWithdraw to receive lifecycle events. Every event shares the same envelope:
{
id: string; // sevt_<ksuid>
type: string; // see tables below
created: number; // unix seconds
sessionId: string; // asess_<ksuid> — one journey, modal open → close
externalUserId?: string;// the id you opened the flow with (deposit/withdraw)
method?: DepositMethod;// deposit events only
data: { object: ... }; // type-specific payload
}sessionId is minted when the modal opens and cleared when it closes, so you can correlate every event from one attempt — including the analytics stream below, which sends the same id as session_id. Out-of-widget telemetry has no session_id.
externalUserId is the externalUserId you passed to beginDeposit / beginWithdraw, echoed back so one shared onEvent handler can attribute a journey without you threading your own id through the call site. Checkout events don't carry it — you open a checkout with a client secret, and the payment intent you created server-side is the key that maps back to your user.
Deposit events (DepositEventType)
| type | When | data.object |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| deposit.method_selected | User picks a funding method | { method } |
| deposit.flow_started | A funding method that initiates a session/tx begins (card, cash app, bank transfer, apple pay, stripe link, wallet connect, connect-exchange). Not emitted for transfer-crypto / pay-with-exchange, which have no in-SDK initiation step. | { method?, provider?, amountUsd?, chain? } |
| deposit.token_selected | User picks an asset — fires with the full identity whenever the token or its chain changes | { currency?, tokenAddress?, chain?, chainType?, network?, method? } |
| deposit.wallet_selected | User picks a browser wallet | { wallet, walletName?, installed? } |
| deposit.provider_selected | User picks an onramp/exchange provider | { provider, method? } |
| deposit.verification_started | User reached a step asking them to prove something — KYC, a document, a one-time code, an exchange MFA prompt | { method? } |
| deposit.verification_completed | The check passed | { method? } |
| deposit.verification_failed | The check didn't pass | { method? } — see note below |
| deposit.account_connection_started | User began linking an account held elsewhere (exchange, Stripe Link) | { provider, method? } |
| deposit.account_connected | The external account is linked and usable | { provider, method? } |
| deposit.account_connection_failed | Linking the external account failed or was abandoned | { provider, method? } — no reason: see note below |
| deposit.wallet_connection_started | User began connecting a browser wallet | { wallet, method?, chainType? } |
| deposit.wallet_connected | The wallet is connected and usable | { wallet, method?, chainType? } |
| deposit.wallet_connection_failed | The connection failed, or the user declined it in their wallet | { wallet, method?, chainType?, failureReason? } — connect_declined or connect_failed |
| deposit.flow_failed | The deposit failed, including before anything reached a chain — a declined signature, a declined card, a purchase cap | { method?, message, errorCode? } |
| deposit.limit_reached | The user is at a purchase cap they can't raise — offer another method | { method?, provider? } |
| onramp_session.created | Hosted onramp session opened | {} — a signal; the method and sessionId are on the envelope |
| direct_execution.succeeded | Deposit confirmed on-chain | DirectExecution |
| direct_execution.failed | Deposit failed on-chain — follows deposit.flow_failed with the execution detail | DirectExecution (see failureReason) |
Withdraw events (WithdrawEventType)
| type | When | data.object |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| withdraw.token_selected | User picks the destination asset — fires with the full identity whenever the token or its chain changes | { currency?, tokenAddress?, chain?, chainType?, network?, method? } |
| withdraw.flow_started | Withdraw form confirmed | { token?, chain?, amount?, amountBaseUnit?, amountUsd? } (telemetry additionally carries currency + network) |
| direct_execution.succeeded | Withdraw confirmed on-chain | DirectExecution |
| direct_execution.failed | Withdraw failed | DirectExecution |
Checkout events (CheckoutEventType)
| type | When | data.object |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| checkout.method_selected | User picks how to pay | { method } |
| checkout.token_selected | User picks an asset to pay with — fires with the full identity whenever the token or its chain changes | { currency?, tokenAddress?, chain?, chainType?, network?, method? } |
| checkout.wallet_selected | User picks a browser wallet | { wallet, walletName?, installed? } |
| checkout.wallet_connection_started | User began connecting a browser wallet | { wallet, method?, chainType? } |
| checkout.wallet_connected | The wallet is connected and usable | { wallet, method?, chainType? } |
| checkout.wallet_connection_failed | The connection failed, or the user declined it in their wallet | { wallet, method?, chainType?, failureReason? } |
| checkout.flow_started | The payment was submitted | { method?, amountUsd?, chain? } |
| checkout.flow_failed | The payment attempt failed, including before anything reached a chain — a signature declined in the wallet, no deposit address for the wallet's chain | { method?, message, errorCode? } |
| payment_intent.succeeded | Payment confirmed | CheckoutPaymentIntent |
Note:
sessionIdis a required field on the envelope. If you construct events yourself (e.g. in tests/mocks), include it.
Note: checkout envelopes carry
paymentIntentIdrather than theexternalUserIdthat deposit and withdraw echo — you open a checkout with a client secret, and the intent you created server-side is the key back to your own records. PassexternalUserIdtobeginCheckoutif you'd rather attribute by user; it's echoed onto every envelope and sent nowhere else.
Note: verification tells you only that a check started, passed or didn't. Which gate it was, which tier, the provider's verdict, the field that didn't match — all of that describes your user rather than the transaction, so it stays in our funnel where support can reach it. The same goes for why an exchange account wouldn't link. Note also that
deposit.verification_failedcan fire on an attempt the user recovers from, such as a mistyped code, and that a provider verifying in tiers produces one started/outcome cycle per tier; when a verification actually ends the flow you getdeposit.limit_reachedordeposit.flow_failed, and those are the ones worth acting on. Wallet connections are the exception to all of this:deposit.wallet_connection_faileddoes carry a reason, because declining a prompt in your own wallet is your own visible action.
2. Telemetry (funnel analytics)
Independently, the SDK forwards funnel events (widget_opened, screen_viewed, payment_method_selected, token_selected, wallet_selected, provider_selected, payment_method_type_selected, verification_started, verification_submitted, verification_completed, verification_failed (funnel only — only the failure reaches onEvent), account_connection_started, account_connected, account_connection_failed, wallet_connection_started, wallet_connected, wallet_connection_failed, flow_started, flow_completed, flow_failed, back_clicked, widget_closed) to the Unifold backend. Each carries session_id (asess_…) and the internal user_id — so drop-off funnels can be grouped per journey and per user. Abandonment is derived from the funnel (widget_opened − flow_completed).
The internal id comes from the API — deposit addresses, the execution query, a payment intent — so it lands on the journey's events from the moment the first of those responses names the user, whichever screen the user is on. Nothing is needed on your side beyond the externalUserId you already pass. The events that fire before that response, widget_opened and possibly the first screen_viewed, carry session_id and no user_id; join them to the rest of the journey on session_id, which every event in it shares. A journey never carries a different user's id: each one starts from a clean slate rather than inheriting what the last flow resolved.
The terminal flow events carry the id of the record they produced: flow_completed and flow_failed on a deposit or withdrawal carry execution_id (exec_…, the direct execution the poll resolved), and a checkout's flow_completed carries payment_intent_id (pi_…). They are the only high-cardinality properties in the funnel — join keys back to the execution or the intent, not breakdown dimensions. execution_id is absent when a flow completes without one (a provider reporting success before any execution row exists).
flow_started can't carry execution_id, because at that point there is no execution: one is created when funds land on the deposit wallet, which is after the user has paid, sent, or been handed off to a provider. It carries deposit_wallet_id (wallet_…) instead — the wallet the flow is funding, and the same id the execution carries when it appears. That's what joins a journey that started and never completed to whatever did (or didn't) follow. A deposit wallet is reused across a user's deposits to the same destination, so pair it with the journey's time window rather than treating it as unique per execution.
Events that name an asset carry currency and network next to the raw token and chain. Those are the slugs the API serves on every token and chain — lowercase, with non-alphanumerics collapsed to _ (USDC.e → usdc_e, USDC (Perp) → usdc_perp, Base Sepolia → base_sepolia) — and they are the same identifiers the payment-intents API takes as destination_currency / destination_network. Group funnels on those; read token/chain when you want the symbol as displayed or the exact chain id. A currency is not unique per network (Polygon carries both usdc and usdc_e, HyperCore both usdc and usdc_perp), so the pair is the key, never the currency alone.
Advanced Usage
Pattern 1: Promise-based (Recommended for Modern Apps)
Use async/await for clean, linear code flow:
const { beginDeposit } = useUnifold();
const handleDeposit = async () => {
try {
const result = await beginDeposit({
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
});
// Update your UI state
setDepositStatus('completed');
showSuccessMessage(result.message);
} catch (error) {
// Handle errors
setDepositStatus('failed');
showErrorMessage(error.message);
}
};Pattern 2: Callback-based (Fire-and-Forget)
Use callbacks for immediate side effects without awaiting:
const { beginDeposit } = useUnifold();
const handleDeposit = () => {
beginDeposit({
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
onSuccess: (data) => {
console.log('Deposit initiated:', data);
showToast('Deposit in progress!');
},
onError: (error) => {
console.error('Deposit failed:', error);
showToast('Error: ' + error.message);
},
});
// Code continues immediately without waiting
console.log('Deposit modal opened');
};Pattern 3: Hybrid (Promise + Callbacks)
Combine both for maximum flexibility:
const { beginDeposit } = useUnifold();
const handleDeposit = async () => {
try {
// Get the promise
const depositPromise = beginDeposit({
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
// Immediate callbacks for real-time feedback
onSuccess: (data) => {
showToast('Deposit detected!');
trackAnalyticsEvent('deposit_initiated', data);
},
onError: (error) => {
showToast('Error: ' + error.message);
},
});
// Continue other work immediately
setModalOpen(true);
// Await the final result
const result = await depositPromise;
// Update final state
setDepositComplete(true);
navigateToSuccessPage(result);
} catch (error) {
setDepositError(error);
}
};Programmatic Control
Close the modal programmatically:
const { beginDeposit, closeDeposit } = useUnifold();
// Open modal
beginDeposit({
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
});
// Close modal after 10 seconds
setTimeout(() => {
closeDeposit();
}, 10000);Note: Closing the modal via closeDeposit() or clicking outside will reject the promise with code 'DEPOSIT_CANCELLED'.
TypeScript
Full TypeScript support with type definitions included:
import {
UnifoldProvider,
useUnifold,
DepositConfig,
UnifoldConnectProviderConfig,
} from '@unifold/connect-react';
// Provider config
const providerConfig: UnifoldConnectProviderConfig = {
publishableKey: 'pk_test_...',
config: {
modalTitle: 'Deposit',
hideDepositTracker: false,
},
};
// Deposit config
const depositConfig: DepositConfig = {
destinationChainId: '137',
destinationTokenAddress: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
destinationTokenSymbol: 'USDC',
recipientAddress: '0x606C49ca2Fa4982F07016265040F777eD3DA3160',
onSuccess: (data) => console.log(data),
// TypeScript will validate all fields
};
const { beginDeposit } = useUnifold();
beginDeposit(depositConfig);Architecture
@unifold/connect-react is built on a clean, modular architecture:
@unifold/connect-react (this package)
├─ @unifold/react-provider (base context)
└─ @unifold/ui-react (UI components)License
MIT
Support
- 📧 Email: [email protected]
- 🐛 Issues: GitHub Issues
- 📖 Documentation: unifold.io/docs
