This is an enterprise-only feature. Please contact us to enable.
Build a payment UI on top of Fireblocks Flow using the JavaScript SDK. Each section maps to a screen in the payment widget, with full component examples you can adapt.
The examples below use React for readability, but the Dynamic JavaScript SDK is framework-agnostic. The core functions (attachFlowSource, getFlowQuote, submitFlowTransaction, etc.) are plain JavaScript imported from @dynamic-labs-sdk/client and work with any framework — Vue, Svelte, Angular, or vanilla JS. The React hooks (e.g. useTokenBalances, useWalletAccounts) are convenience wrappers from @dynamic-labs-sdk/react-hooks; in other frameworks, call the equivalent client functions directly.
Do not mix the Dynamic React SDK (@dynamic-labs/sdk-react-core) with the JavaScript SDK (@dynamic-labs-sdk/client). They are separate SDKs with different architectures. Fireblocks Flow is only available in the JavaScript SDK. If your project currently uses the React SDK, you will need to migrate to the JavaScript SDK to use Flow.
Complete the Getting Started setup before following this guide.
Flow overview
1. Load the flow
Your backend creates the flow and passes the flowId to the frontend. Load the flow and check if it is still actionable:
import { getFlow } from "@dynamic-labs-sdk/client";
const TERMINAL_STATES = new Set([
"broadcasted",
"source_confirmed",
"cancelled",
"expired",
"failed",
]);
const flow = await getFlow({ flowId });
if (TERMINAL_STATES.has(flow.executionState)) {
// Flow is finished — show a message or redirect
}
Read flowId from a URL parameter (e.g. ?flowId=...) so users can resume an in-progress flow after navigating away.
2. Choose a payment source
Flow supports two source types: wallet (user signs from a Web3 wallet) and exchange (user pays via an exchange like Coinbase). Present both options and route to the matching path.
const SelectSourceView = ({ onSelectWallet, onSelectExchange }) => (
<div>
<h3>Choose payment method</h3>
<button onClick={onSelectWallet}>
<span>Pay with Wallet</span>
<span>Sign the transaction with your connected Web3 wallet</span>
</button>
<button onClick={onSelectExchange}>
<span>Pay with Exchange</span>
<span>Complete the payment via your Coinbase account</span>
</button>
</div>
);
Exchange path
For exchange sources, attach the source and open the exchange URL. After the user completes payment on the exchange, you call broadcastFlow to notify the backend (see step 7).
import { attachFlowSource } from "@dynamic-labs-sdk/client";
const { flow } = await attachFlowSource({
flowId,
sourceType: "exchange",
exchangeProvider: "coinbase",
});
// The exchange buy URL is in the flow's exchangeSource metadata
const buyUrl = flow.exchangeSource?.metadata?.url;
window.open(buyUrl, "_blank", "noopener,noreferrer");
3. Connect a wallet
With multiple chain extensions enabled, the SDK detects wallet providers on every chain. The same wallet (e.g. MetaMask) may appear multiple times — once per supported chain. Group providers by groupKey so each wallet shows as one entry in your UI.
Wallet provider list
Use useAvailableWalletProvidersData to list detected wallets, and connectWithWalletProvider to connect:
import { connectWithWalletProvider } from "@dynamic-labs-sdk/client";
import { useAvailableWalletProvidersData } from "@dynamic-labs-sdk/react-hooks";
const WalletProviderList = ({ onConnected }) => {
const { data: providers = [] } = useAvailableWalletProvidersData();
// Group providers by wallet (same wallet across chains = one entry)
const groups = {};
for (const p of providers) {
if (!groups[p.groupKey]) groups[p.groupKey] = [];
groups[p.groupKey].push(p);
}
const connect = async (provider) => {
await connectWithWalletProvider({ walletProviderKey: provider.key });
onConnected();
};
const handleGroupClick = async (groupKey) => {
const group = groups[groupKey];
if (group.length > 1) {
// Wallet supports multiple chains — show a chain picker (see below)
showChainPicker(group);
return;
}
// Single chain — connect directly
await connect(group[0]);
};
return (
<div>
{Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([groupKey, ps]) => (
<button key={groupKey} onClick={() => handleGroupClick(groupKey)}>
{ps[0].metadata.icon && (
<img src={ps[0].metadata.icon} alt="" width={28} height={28} />
)}
<span>{ps[0].metadata.displayName}</span>
</button>
))}
{providers.length === 0 && <p>No wallet extensions detected.</p>}
</div>
);
};
Chain picker
When a wallet supports multiple chains (e.g. MetaMask on EVM, or Phantom on both SOL and EVM), show a chain picker. Each entry in the group has a chain property ("EVM", "SOL", "BTC", "SUI", "TRON") and a unique key:
const ChainPicker = ({ providers, onConnect }) => (
<div>
<p>Select a chain</p>
{providers.map((p) => (
<button key={p.key} onClick={() => onConnect(p)}>
{p.chain}
</button>
))}
</div>
);
WalletConnect support
If you added WalletConnect extensions in your setup, you can let users scan a QR code to connect mobile wallets. The SDK provides connectWithWalletConnectEvm and connectWithWalletConnectSolana:
import { connectWithWalletConnectEvm } from "@dynamic-labs-sdk/evm/wallet-connect";
import { connectWithWalletConnectSolana } from "@dynamic-labs-sdk/solana/wallet-connect";
const connectWalletConnect = async (chain) => {
const connect =
chain === "EVM" ? connectWithWalletConnectEvm : connectWithWalletConnectSolana;
// Returns a pairing URI and an approval promise
const { uri, approval } = await connect();
// Render `uri` as a QR code (e.g. using the `qrcode` npm package)
const QRCode = await import("qrcode");
const dataUrl = await QRCode.toDataURL(uri, { width: 200, margin: 2 });
// Display dataUrl in an <img> tag
// Wait for the user to scan and approve
await approval();
// Wallet is now connected
};
4. Select a network and token
Once a wallet is connected, show the wallet’s address, let the user pick a network (if applicable), and list their token balances. Use useWalletAccounts to get the connected wallet account.
Network switching
EVM wallets can operate on multiple networks (Ethereum, Base, Arbitrum, etc.). Use useNetworksData to list available networks for the wallet’s chain, and switchActiveNetwork to change:
import { switchActiveNetwork } from "@dynamic-labs-sdk/client";
import {
useActiveNetworkData,
useNetworksData,
useWalletAccounts,
} from "@dynamic-labs-sdk/react-hooks";
const NetworkAndTokenView = ({ flow, onQuoted }) => {
const { data: walletAccounts = [] } = useWalletAccounts();
const walletAccount = walletAccounts[0];
const { data: activeNetworkResult, refetch: refetchNetwork } =
useActiveNetworkData({ walletAccount });
const activeNetwork = activeNetworkResult?.networkData;
// Filter to networks matching the wallet's chain
const { data: allNetworks = [] } = useNetworksData();
const walletNetworks = allNetworks.filter(
(n) => n.chain === walletAccount?.chain
);
const handleSwitch = async (networkId) => {
await switchActiveNetwork({ networkId, walletAccount });
await refetchNetwork();
};
return (
<div>
{/* Wallet address */}
<p>
{walletAccount?.address?.slice(0, 6)}...
{walletAccount?.address?.slice(-4)}
</p>
{/* Network switcher — only show if multiple networks available */}
{walletNetworks.length > 1 && (
<div>
<span>Network</span>
{walletNetworks.map((network) => (
<button
key={network.networkId}
onClick={() => handleSwitch(String(network.networkId))}
aria-pressed={network.networkId === activeNetwork?.networkId}
>
{network.iconUrl && <img src={network.iconUrl} alt="" width={20} />}
{network.displayName}
</button>
))}
</div>
)}
{/* Token list — see below */}
{activeNetwork && (
<TokenList
walletAccount={walletAccount}
networkId={activeNetwork.networkId}
flow={flow}
onQuoted={onQuoted}
/>
)}
</div>
);
};
Token list with auto-attach and auto-quote
Use useTokenBalances to fetch the wallet’s tokens on the active network. When the user picks a token, attach the wallet as the flow source and fetch a quote in one step:
import {
attachFlowSource,
getFlowQuote,
} from "@dynamic-labs-sdk/client";
import { useTokenBalances } from "@dynamic-labs-sdk/react-hooks";
const TokenList = ({ walletAccount, networkId, flow, onQuoted }) => {
const {
data: tokens = [],
isLoading,
refetch,
} = useTokenBalances({
walletAccount,
networkId,
includeNative: true,
includePrices: true,
filterSpamTokens: true,
});
const handleSelect = async (token) => {
// Attach the wallet as source
await attachFlowSource({
flowId: flow.id,
fromAddress: walletAccount.address,
fromChainId: String(networkId),
fromChainName: walletAccount.chain,
sourceType: "wallet",
});
// Fetch a quote for this token
const quotedFlow = await getFlowQuote({
flowId: flow.id,
fromTokenAddress: token.address,
fromChainId: token.networkId?.toString(),
});
onQuoted({ quotedFlow, walletAccount, fromTokenAddress: token.address });
};
if (isLoading) return <p>Loading balances…</p>;
if (tokens.length === 0) {
return (
<div>
<p>No tokens found</p>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
return (
<div>
{tokens.map((token) => (
<button
key={`${token.address}-${token.symbol}`}
onClick={() => handleSelect(token)}
>
{token.logoURI && <img src={token.logoURI} alt="" width={32} />}
<div>
<span>{token.symbol}</span>
<span>{token.name}</span>
</div>
<div>
<span>{token.balance}</span>
{token.marketValue !== undefined && (
<span>${token.marketValue.toFixed(2)}</span>
)}
</div>
</button>
))}
</div>
);
};
For Solana, pass networkId: 101. For EVM, use the chain ID (e.g. 8453 for Base). Set includeNative: true to include ETH/SOL in the list.
5. Review the quote
Display the quote so the user can review amounts, fees, and estimated time before signing. Quotes expire after 60 seconds — offer a refresh button:
import { getFlowQuote } from "@dynamic-labs-sdk/client";
const ReviewQuoteView = ({ flow, fromTokenAddress, fromChainId, onConfirm }) => {
const [quotedFlow, setQuotedFlow] = useState(flow);
const quote = quotedFlow.quote;
const refreshQuote = async () => {
const updated = await getFlowQuote({
flowId: flow.id,
fromTokenAddress,
fromChainId,
});
setQuotedFlow(updated);
};
if (!quote) return <p>Loading quote…</p>;
return (
<div>
<h3>Review quote</h3>
<div>
<div>
<span>You send</span>
<span>{quote.fromAmount}</span>
</div>
<div>
<span>You receive</span>
<span>{quote.toAmount}</span>
</div>
{quote.fees?.totalFeeUsd && (
<div>
<span>Estimated fees</span>
<span>${quote.fees.totalFeeUsd}</span>
</div>
)}
{quote.estimatedTimeSec && (
<div>
<span>Estimated time</span>
<span>~{Math.ceil(quote.estimatedTimeSec / 60)} min</span>
</div>
)}
</div>
<p>Quote expires at {new Date(quote.expiresAt).toLocaleTimeString()}</p>
<button onClick={() => onConfirm(quotedFlow)}>Confirm & Sign</button>
<button onClick={refreshQuote}>Refresh Quote</button>
</div>
);
};
6. Sign and submit
submitFlowTransaction handles the full signing pipeline: prepare → approve token spend (if needed) → sign transaction → broadcast. Use the onStepChange callback to show a two-step progress indicator:
import { useSubmitFlowTransaction } from "@dynamic-labs-sdk/react-hooks";
const STEP_LABELS = {
approval: "Approve token spend",
transaction: "Sign transaction",
};
const SubmitView = ({ flow, walletAccount, onSubmitted, onRequote }) => {
const [currentStep, setCurrentStep] = useState(null);
const hasStartedRef = useRef(false);
const {
mutate: submitFlow,
isPending,
error,
} = useSubmitFlowTransaction({
mutateParams: {
onSuccess: (completedFlow) => onSubmitted(completedFlow),
onError: (err) => {
const msg = err instanceof Error ? err.message : "Transaction failed";
const isRejected =
msg.toLowerCase().includes("reject") ||
msg.toLowerCase().includes("denied");
if (isRejected) {
// User rejected in wallet — show retry option
}
},
},
});
useEffect(() => {
if (hasStartedRef.current) return;
hasStartedRef.current = true;
submitFlow({
flowId: flow.id,
walletAccount,
onStepChange: setCurrentStep,
});
}, []);
const steps = ["approval", "transaction"];
const currentIndex = currentStep ? steps.indexOf(currentStep) : -1;
// Detect quote expiry errors
const isQuoteExpired =
error?.message?.toLowerCase().includes("expired") ||
error?.message?.toLowerCase().includes("quote");
return (
<div>
{isPending && (
<div>
<h3>Signing transaction</h3>
<p>Please confirm the prompts in your wallet</p>
</div>
)}
{/* Two-step progress */}
{steps.map((step, i) => (
<div key={step}>
<span>
{currentIndex > i ? "✓" : currentStep === step ? "…" : i + 1}
</span>
<span>{STEP_LABELS[step]}</span>
</div>
))}
{/* Error recovery */}
{error && !isPending && (
<div>
<p>{error.message}</p>
{isQuoteExpired ? (
<button onClick={onRequote}>Get a new quote</button>
) : (
<button
onClick={() => {
hasStartedRef.current = false;
submitFlow({
flowId: flow.id,
walletAccount,
onStepChange: setCurrentStep,
});
}}
>
Try again
</button>
)}
</div>
)}
</div>
);
};
Quotes expire after 60 seconds. If signing fails with a quote expiry error, navigate the user back to the quote view (step 5) to fetch a fresh quote.
7. Confirm exchange transfer
For exchange sources, show a confirmation screen after the user completes payment on the exchange. Call broadcastFlow to notify the backend:
import { useBroadcastFlow } from "@dynamic-labs-sdk/react-hooks";
const ExchangeConfirmView = ({ flow, exchangeUrl, onCompleted }) => {
const { mutate: broadcast, isPending } = useBroadcastFlow({
mutateParams: {
onSuccess: onCompleted,
},
});
return (
<div>
<h3>Complete your transfer</h3>
<p>Finish the payment on the exchange, then confirm below.</p>
{exchangeUrl && (
<a href={exchangeUrl} target="_blank" rel="noopener noreferrer">
Reopen exchange
</a>
)}
<button onClick={() => broadcast({ flowId: flow.id })} disabled={isPending}>
{isPending ? "Recording payment…" : "I've completed the transfer"}
</button>
</div>
);
};
8. Track status
After submission or exchange confirmation, poll getFlow to track the flow through three milestones:
- Payment submitted — transaction broadcasted on-chain
- Source confirmed — on-chain confirmation received
- Settlement complete — funds delivered to the destination
import { getFlow } from "@dynamic-labs-sdk/client";
import { useQuery } from "@tanstack/react-query";
const shouldStopPolling = (flow) =>
["cancelled", "expired", "failed"].includes(flow.executionState) ||
["completed", "failed"].includes(flow.settlementState);
const StatusView = ({ flow, onReset }) => {
const { data: polledFlow } = useQuery({
queryKey: ["flowStatus", flow.id],
queryFn: () => getFlow({ flowId: flow.id }),
refetchInterval: (query) => {
const data = query.state.data;
return data && shouldStopPolling(data) ? false : 3000;
},
});
const displayFlow = polledFlow ?? flow;
const exec = displayFlow.executionState;
const settle = displayFlow.settlementState;
const steps = [
{
label: "Payment submitted",
done: ["broadcasted", "source_confirmed"].includes(exec),
},
{
label: "Source confirmed",
done: exec === "source_confirmed",
},
{
label: "Settlement",
done: settle === "completed",
active: exec === "source_confirmed" && settle !== "completed" && settle !== "failed",
failed: settle === "failed",
},
];
const isDone = settle === "completed";
const isFailed = exec === "failed" || settle === "failed";
return (
<div>
<h3>
{isDone
? "Payment complete"
: isFailed
? "Payment failed"
: "Processing payment…"}
</h3>
{steps.map((step, i) => (
<div key={i}>
<span>{step.done ? "✓" : step.failed ? "✗" : step.active ? "…" : "○"}</span>
<span>{step.label}</span>
</div>
))}
{displayFlow.txHash && <p>Transaction: {displayFlow.txHash}</p>}
{(isDone || isFailed) && (
<button onClick={onReset}>Start a new flow</button>
)}
</div>
);
};
Cancellation
Users can cancel at any point before broadcast with cancelFlow. After broadcast, the transaction is on-chain and cannot be reversed.
import { cancelFlow } from "@dynamic-labs-sdk/client";
await cancelFlow({ flowId });
Show a cancel button on the source selection, wallet connection, quote review, and exchange confirmation screens. Hide it once the transaction has been broadcasted.
Error handling
See the error reference for all errors by step and how to resolve them. Key patterns:
- Quote failures (
422): Show the reason to the user (e.g. “Amount is below the minimum for this token”). Offer a different token or amount.
- Wallet rejection: Detect
"reject" / "denied" in the error message. Offer retry or cancel.
- Quote expiry: Navigate back to the quote view to refresh.
- State conflicts (
409): Re-fetch with getFlow and resume from the current state.