@unifold/headless-react
v0.1.70-beta.1
Published
Unifold Headless React SDK - hooks-only (no UI) crypto deposit flows
Readme
@unifold/headless-react
Hooks-only (no UI) React SDK for Unifold crypto deposits. You own 100% of the rendering; the SDK owns the data flow, session lifecycle, polling, and events.
- Zero UI, zero CSS — no components, no Tailwind, no portals.
- Layered like Stripe.js — the flow logic lives in a framework-agnostic
DepositSessioncontroller in@unifold/core; this package is a thin React binding over it. - Events first — every state transition is observable as a typed event
using the same webhook-style envelope (
{ id, type, created, data: { object } }) as@unifold/connect-react'sonEvent. Callbacks (onSuccess, …) are sugar over the event stream.
Already using
@unifold/connect-react(the modal SDK)? Don't install this package — import the same hooks from@unifold/connect-react/headlessinstead. Install exactly one of the two packages.
Install
npm install @unifold/headless-react
# or
pnpm add @unifold/headless-reactSetup
Same provider as the modal SDK — the two SDKs can coexist in one app, sharing the provider and QueryClient:
import { UnifoldProvider } from '@unifold/headless-react';
function App() {
return (
<UnifoldProvider publishableKey="pk_live_...">
<YourApp />
</UnifoldProvider>
);
}Quickstart — a custom deposit screen
import { useDeposit } from '@unifold/headless-react';
function DepositUSDC({ externalUserId }: { externalUserId: string }) {
const { status, getAddress, latestExecution } = useDeposit({
externalUserId,
destination: {
chainType: 'ethereum',
chainId: '8453',
tokenAddress: USDC_BASE,
recipientAddress: userTreasuryAddress,
},
onSuccess: (execution) => toast.success(`Received $${execution.destinationAmountUsd}`),
});
const eth = getAddress({ chainType: 'ethereum' });
if (!eth) return <Spinner />;
return (
<div>
<MyQrCode value={eth.address} />
<CopyField value={eth.address} />
{status === 'processing' && <MyProgress execution={latestExecution!} />}
</div>
);
}useDeposit drives an explicit status state machine:
idle → creating_addresses → awaiting_funds → processing → succeeded | failed
↘ error (fatal: address creation / invalid recipient)The headless SDK is deliberately not IP-aware — useDeposit never geo-gates
address creation. If you want the modal's region gate, opt in with
useAllowedCountry and gate your own UI on isAllowed.
Manual confirmation + full event wiring
const deposit = useDeposit({
externalUserId,
destination,
confirmationMode: 'manual',
onEvent: (event) => analytics.track(event.type, event),
onExecutionUpdated: (execution) => setTimeline((t) => [...t, execution]),
onError: (error) => {
if (error.code === 'POLLING_ERROR') showBanner('Connection hiccup — still watching');
else showFailure(error);
},
});
<Button onClick={deposit.confirmFundsSent} disabled={deposit.isCheckingDeposit}>
I've made the transfer
</Button>;Promise waiters
For imperative flows, the session exposes await-style sugar over the event
stream (they only listen — neither starts nor stops the session):
// Generic primitive over the status state machine:
await session.waitForStatus('processing'); // deposit detected
await session.waitForStatus(['succeeded', 'failed']); // terminal outcome
// The 90% case — mirrors beginDeposit()'s promise contract
// (resolve on success, reject on failure):
try {
const execution = await session.waitForSuccess();
creditUser(execution.destinationAmountUsd);
} catch (error) {
// DepositSessionWaitError: DEPOSIT_FAILED | SESSION_ERROR | ABORTED | DESTROYED
}Both accept { signal } — an AbortSignal cancels the wait, never the
session (a deposit isn't cancelable: once the user sends funds, they arrive
whether or not anyone is awaiting). For a deadline, compose the platform
primitive — waitForSuccess({ signal: AbortSignal.timeout(60_000) }) — and
treat it as "outcome still unknown", not failure: keep the session (and your
UI) watching. From useDeposit, reach the waiters via the session escape
hatch.
One session, many executions. Unlike quote-scoped models (e.g. Privy's,
where one address maps to one order), a Unifold session can observe multiple
executions — the user may send twice, or on two different chains, to the same
universal addresses. waitForSuccess is one-shot first-completion
detection; the session keeps polling afterwards, and every settlement fires
its own direct_execution.succeeded event.
Live outcomes only. The 60s lookback window exists to catch deposits sent
moments before the session started — it only admits executions still
in-flight at first sight (their settlement then fires live). An execution
that already settled before the session began is history and never re-fires
success/failure events, so reopening a deposit screen right after a success
cannot double-credit. Render history with useExecutions instead. To credit each deposit, subscribe
to events (dedupe on execution.id) or render executions from the hook:
session.on(DepositSessionEventType.EXECUTION_SUCCEEDED, ({ data }) => {
creditUser(data.object); // fires once per settled execution
});Vanilla JS (no React)
The controller is usable without React from @unifold/core:
import { createUnifoldClient } from '@unifold/core';
const unifold = createUnifoldClient({ publishableKey: 'pk_live_...' });
const session = unifold.createDepositSession({ externalUserId, destination });
await session.start();
renderQr(session.getSnapshot().addresses);
const execution = await session.waitForSuccess();
showSuccess(execution);
session.destroy();Hooks
| Hook | Purpose |
| --- | --- |
| useDeposit | Flagship flow hook: deposit addresses + execution detection + status state machine + events |
| useDepositAddresses | Addresses without a live session (cached, idempotent create) |
| useSupportedDepositTokens | Source token/chain list for custom pickers |
| useExecutions | Deposit history (tracker screens) |
| useAllowedCountry | Geo gate the modal uses; decide what to render when blocked |
| useAddressValidation | Inline recipient validation (e.g. Algorand opt-in) |
| useUnifoldClient | Escape hatch to the configured vanilla client |
Events
resource.action names with webhook-mirroring envelopes; direct_execution.succeeded
is byte-compatible with the modal SDK's onEvent:
deposit_session.started/.addresses_created/.confirmation_started/.stopped/.erroreddirect_execution.detected/.updated/.succeeded/.failed
When mixing the modal and headless surfaces, dedupe on execution.id (envelope
sevt_ ids are minted per emitter), and keep one active surface per flow at a
time.
License
Apache-2.0
