Skip to main content
This is an enterprise-only feature. Please contact us to enable.
The same headless architecture as iOS and Android: render your own native list and drive a hidden WebViewWidget that runs the Dynamic SDK and returns results (including message and transaction signatures) over a JS bridge. No wallet SDK in your Flutter app. The basic Flutter flow is the recommended default.
No SDK in your app. Your app links no wallet SDK: no CocoaPods, no native crypto, no Gradle dep. It needs a hidden WebViewWidget pointed at the /headless.html engine route and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list itself) comes from that hosted view. Redeploy the page to update wallets; the app never changes.
fireblocks_headless_connect.dart
models.dart
app_config.dart

1. Add dependencies

pubspec.yaml

2. Register URL schemes

You need three hosts under your scheme, plus LSApplicationQueriesSchemes on iOS for the wallet schemes you open:
  • wallet-callback carries the visible flow’s result.
  • wallet-return is Phantom’s “Return to app” tap during the visible flow. It carries no connection data (at most the echoed nonce).
  • phantom-headless is Phantom’s “Return to app” tap after a headless connect. It carries no data.
Turn off Flutter’s own deep linking on both platforms. app_links routes every incoming URL, and Flutter’s deep linking would also push each one as a route, stacking a second copy of your home screen on top.
Info.plist (iOS)
AndroidManifest.xml
On Android, wallet-return needs its own activity. It must not reach MainActivity, because resuming the Flutter app makes flutter_web_auth_2 cancel the pending authenticate() while the Custom Tab is still waiting for the WalletConnect approval. Declare it next to MainActivity, and give it its own taskAffinity so the launch does not bring MainActivity’s task forward.
AndroidManifest.xml
WalletReturnActivity.kt

3. Mount the engine and connect

Wrap your home screen with FireblocksEngineHost. It keeps the hidden WebViewWidget alive at all times. Prewarm at launch and connect on tap. Set FireblocksHeadlessConnect.shared.locale / .theme before prewarm() to override the engine’s UI locale and theme; they are forwarded to the iframe app as query parameters alongside environmentId.
main.dart
example_screen.dart
A connection is chain-specific: one wallet gives you an EVM address or a Solana address or a Bitcoin address, never several at once. Read the chains a wallet offers from HeadlessWallet.chains and pass one of them as chain. For Bitcoin, see Bitcoin wallets. Route deep links with app_links (getInitialLink + uriLinkStream). Ignore wallet-return (on iOS it reaches your app while the visible flow’s session is still waiting for the approval), then hand the URL to FireblocksHeadlessConnect.shared.handleReturnUrl(uri), which consumes phantom-headless. Do not rely on MaterialApp.onGenerateRoute for custom-scheme intents.
main.dart

4. Sign a message

After a successful headless connect, await sign() with any string.
example_screen.dart
Signing is only available for wallets connected through the headless engine (connectedHeadlessly == true). Wallets connected via the visible fallback flow do not hold an open session.

5. Send a transaction (EVM)

sendTransaction() calls eth_sendTransaction: the wallet signs and broadcasts in one step. The result is an on-chain transaction hash. Prefer this over eth_signTransaction, which mobile wallets (including MetaMask) often reject. The transaction argument is a JSON string whose shape depends on the connected wallet’s chain: EVM transaction fields here, a Bitcoin send request for a Bitcoin wallet (see Send bitcoin). signTransaction() is the sign-only sibling, used mainly for Bitcoin PSBTs.
example_screen.dart
chainId is required for send. The engine verifies it against the wallet’s active network and fails with chain_mismatch (or missing_chain_id) rather than silently using whatever network the wallet is on. Treat every send as final: confirm with the user before calling.

6. Bitcoin wallets

Bitcoin reuses the harness above: the same connect(), sign(), sendTransaction() and signTransaction() calls. What changes is the chain you connect with, and the JSON you hand to send and sign.
What you need: an engine deployment whose /headless.html build registers the Bitcoin extension, plus Xverse or Phantom installed on the device. Bitcoin runs on mainnet here, so every send moves real funds.

Connect a Bitcoin wallet

Two routes reach a Bitcoin address, and the wallet list decides between them per wallet:
  1. Headless session (Xverse, Phantom). The engine opens a WalletConnect bip122 session and deep-links the wallet. You get a full session: message signing, sends, and PSBT signing all work.
  2. Wallet browser (address only). Several Bitcoin wallets inject a provider only inside their own in-app browser and offer no relay path at all. The engine flags those in the wallets bridge message with inAppBrowserChain: 'bitcoin', and the sample offers them as a separate picker entry that runs the visible flow inside that browser. It returns an address and nothing else: there is no session behind it, so signing and sending are unavailable.
example_screen.dart
The engine adds the headless bitcoin option itself for the wallets it has a verified bip122 deep link for (Xverse and Phantom today), so HeadlessWallet.chains already contains 'bitcoin' for them. The wallet browser route is the one your list has to offer, gated on the flag the engine sets:
wallet_list.dart
Wire this helper into your picker alongside the Phantom check in section 8, and route its rows to FireblocksWalletBrowser.connect with chain: 'bitcoin'.
inAppBrowserChain is set only when the wallet’s catalogue entry names a single unambiguous chain for its browser (Xverse: one injected config, chain: "btc"). It is null for a wallet like Phantom whose template is overloaded, which is why Phantom’s EVM browser route stays hand-picked in section 8. Never infer a chain from inAppBrowserUrl alone.
Once the deep link opens, connect() waits up to 3 minutes for the user to approve in the wallet, then fails with ConnectFailure(code: 'timeout'). Switching apps, unlocking a wallet and reading an approval screen takes real time, and a WalletConnect proposal stays valid for about five minutes.
iOS only opens a custom scheme your app has declared. Add xverse to LSApplicationQueriesSchemes (see section 2) or the deep link silently fails to open and the connect falls back.

Sign a message with a Bitcoin wallet

sign() is unchanged. The engine routes the request over the bip122 session, and the wallet returns a base64 signature rather than EVM’s hex string.
example_screen.dart
Before each Bitcoin request the engine deep-links the bare wallet scheme (xverse://) to bring the wallet to the foreground. The request itself travels over the relay either way, but most wallets only render the approval sheet while they are open.

Send bitcoin

sendTransaction() takes a JSON send request instead of EVM transaction fields. The wallet signs and broadcasts, so SendSuccess.txHash is a transaction id that is already in the mempool.
example_screen.dart
No chainId applies, and there is no network-switch step: unlike EVM, one Bitcoin wallet session speaks one network.
A Bitcoin send is final and mainnet. Confirm with the user in your own UI before you call, the way the sample does with an alert dialog, and rehearse with a few thousand satoshis rather than a full balance.

Sign a PSBT

signTransaction() signs without broadcasting. For Bitcoin it takes a PSBT and returns the signed PSBT in base64. Finalizing and broadcasting are yours: the engine never submits it.
example_screen.dart
signature is not optional bookkeeping. Wallets derive the inputs to sign straight from it and sign nothing when it is empty, which returns a “signed” PSBT the wallet never touched. The engine parses no PSBTs of its own, so it rejects the request instead: every entry must name the connected wallet’s own address (its payment or ordinals address) with at least one index.

Bitcoin error codes

SignTxFailure.code and SendFailure.error.code carry these on the Bitcoin paths:

7. Connect Phantom

Connect Phantom on EVM or Solana with the same call as any other wallet: FireblocksHeadlessConnect.shared.connect(walletKey: 'phantom', chain: 'evm') or chain: 'solana'. Phantom has no WalletConnect entry in Dynamic’s wallet book, so the engine bridges through Phantom’s own in-app browser:
  1. The engine mints a WalletConnect pairing and sends a deeplink that opens your hosted page inside Phantom.
  2. The page approves the pairing with Phantom’s injected provider, and the engine receives the connection over the WalletConnect relay.
  3. Phantom’s “Return to app” button opens <scheme>://phantom-headless with no payload. FireblocksHeadlessConnect.shared.handleReturnUrl consumes it, and your connect future completes with the result from the engine.
No connection data comes back through your URL scheme. The engine offers a chain only when your Dynamic environment has a network enabled for it, and it fails with timeout if Phantom does not approve within 60 seconds.

8. Use a wallet’s own browser (Phantom on EVM)

Phantom injects its EVM provider (window.phantom.ethereum) only inside its own in-app browser. To sign or send with it, open your hosted page inside Phantom’s browser: connect there, then reopen the same browser for each sign or send. Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on <scheme>://wallet-browser.
The engine reports each wallet’s in-app-browser template in the wallets bridge message as inAppBrowser. The template contains {{encodedDappURI}}, and you replace every occurrence (Phantom’s uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.
fireblocks_wallet_browser.dart

Carry the two extra fields

HeadlessWallet needs the template, and WalletConnection needs to remember it. Add both to your models, and keep walletBrowserUrl in copyWith, or sign and send fall back to the engine. inAppBrowserChain marks a wallet’s in-app browser as Bitcoin-only, as described in section 6.
models.dart

Register the callback host

Add another host to your scheme, next to wallet-callback, wallet-return, and phantom-headless. On iOS your existing CFBundleURLSchemes entry already covers it.
AndroidManifest.xml
Route the link in your app_links listener, for both the cold start link and the stream. flutter_web_auth_2 never picks this up, because there is no session it belongs to.
main.dart

Offer the option only for Phantom

Do not derive EVM support from the presence of a template. Phantom’s template comes from its Sui wallet-book entry, so a template on its own says nothing about EVM. The evidence for Phantom specifically is phantomevm.injectedConfig.windowLocations: ["phantom.ethereum"], an EIP-1193 provider inside its browser.
wallet_list.dart
Show it as its own chain row with “Opens in the wallet’s own browser” underneath. The current engine already lists evm in Phantom’s chains and connects it headlessly (Connect Phantom), so the !wallet.chains.contains('evm') guard keeps the synthetic row from doubling it: it only appears for engines that predate the headless bridge, or if you want the row to route into the wallet-browser connect below instead, which is what signing needs. A headless Phantom connection is connect-only.

Connect, sign, and send

example_screen.dart

What travels on the URL

The callback carries address and chain (connect), signature (sign), or txHash (send), or error=1&code=&message=, always with the nonce echoed back. A send is already broadcast when the hash arrives. One request is in flight at a time, a new one supersedes the previous, and an abandoned one times out after five minutes.

Phantom pitfalls

  • Keep the template on the connection. Sign and send must reopen the same browser, because the account exists nowhere else. Losing the stored template sends the request to the engine, which reports no wallet connected.
  • Use a separate callback host. wallet-callback is claimed by the visible flow, so a link arriving from Phantom would be dropped or complete an unrelated request.
  • Watch where the template lands on Android. The template is an https app link and reaches Phantom only if its app links are verified. Otherwise Android can hand it to Chrome, where nothing is injected and the page correctly reports no EVM path. That message in a browser that is not Phantom means the hand-off went to the wrong app.
  • Offer the return anchor. The page renders a “Return to the app” link for browsers that ignore a programmatic redirect.

9. Disconnect

Clears localStorage in the hidden WebView and reloads the engine so stale SDK state does not bleed into the next connect.
example_screen.dart

10. The bridge (for reference)

Flutter uses addJavaScriptChannel('walletNative', …) which creates window.walletNative.postMessage(json). The channel name must match exactly.
bridge messages

Common pitfalls

  • Match the channel name exactly: walletNative. A mismatch is invisible; the channel just never receives anything.
  • Use app_links for Phantom returns, not onGenerateRoute.
  • Test on a physical device.
  • Serve over HTTPS.
Last modified on October 1, 2026