@unifold/headless-react
v0.1.80
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 lifecycle state machine:
idle → creating_addresses → ready ⇄ processing
↘ error (fatal: address creation / invalid recipient)processing is a live hint — “an execution is currently in flight” — and
toggles back to ready when everything settles. Outcomes never
appear on the session status: a session is an ongoing watcher that can
observe many executions, so a session-level "succeeded"/"failed" would
mislead the moment a second deposit arrives. Read outcomes from
executions/latestExecution statuses, the direct_execution.succeeded /
.failed events, or waitForSuccess().
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 lifecycle state machine:
await session.waitForStatus('processing'); // live activity detected
await session.waitForStatus('ready'); // addresses ready / all settled
// 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
});Buy with card — a custom fiat onramp screen
useOnramp is the no-UI equivalent of
beginDeposit({ initialScreen: 'card' }): provider quotes + a hosted
checkout URL + the same settlement watching as a transfer. You render the
amount input, the provider list, and the progress UI.
import { useOnramp, useFiatCurrencies } from '@unifold/headless-react';
function BuyUSDC({ externalUserId }: Props) {
const [amount, setAmount] = useState('100');
const { data: fiat } = useFiatCurrencies(); // min/max limits, suggested amounts
const buy = useOnramp({
externalUserId,
// Payer country is auto-detected from IP; pass `countryCode` to override.
sourceAmount: amount, // live: changes refetch quotes (debounced 500ms)
destination: {
chainType: 'ethereum',
chainId: '8453',
tokenAddress: USDC_BASE,
recipientAddress: userTreasuryAddress,
},
onSuccess: (execution) => toast.success(`Received $${execution.destinationAmountUsd}`),
});
return (
<div>
<AmountInput value={amount} onChange={setAmount} />
{/* canSelectProvider is false under fiat-onramp smart routing (the
backend routes to a single provider) or when only one provider
quotes — hide the picker then, like the modal does. */}
{buy.canSelectProvider && (
<ProviderList
quotes={buy.quotes}
selected={buy.selectedQuote}
onSelect={(q) => buy.selectQuote(q.serviceProvider)}
/>
)}
<button
disabled={buy.status !== 'ready' || !buy.selectedQuote}
onClick={() => {
// Synchronous URL build — safe to open inside the click handler.
const checkout = buy.createCheckout();
if (checkout) window.open(checkout.url, '_blank');
}}
>
Continue
</button>
{buy.status === 'awaiting_payment' && <WaitingForProvider checkout={buy.checkout!} />}
{buy.status === 'processing' && <MyProgress execution={buy.latestExecution!} />}
</div>
);
}The lifecycle state machine:
idle → preparing → quoting → ready → awaiting_payment ⇄ processing
↘ error (fatal: addresses / no onramp route / invalid recipient)A full reference screen — amount modes, currency picker, provider list,
checkout redirect, settlement — lives in apps/ui-demo/app/onramp/headless
(/demo/onramp/headless), and docs/recipes/deposits/headless-card-onramp.mdx
walks through building the flow step by step (amount input and prefills,
quoting, checkout, polling, crediting, error handling).
Same outcome philosophy as useDeposit: outcomes never appear on the
session status — read them from executions/latestExecution, the
direct_execution.succeeded/.failed events, or session.waitForSuccess().
Under the hood the settlement phase is a DepositSession (method 'card')
composed over the same deposit addresses, so detection polling, the scan
nudge, and the lookback window behave identically across rails.
Worth knowing:
- Quotes auto-refresh every 60s until checkout (configure with
quoteRefreshIntervalMs, 0 disables);quotesUpdatedAtdrives your own countdown.selectQuote()is sticky across refreshes and falls back to the backend's top quote if the provider stops quoting. - Provider choice has three modes. Default: the backend's top quote
(
quotes[0]— the order encodes provider priority) is auto-selected. Host choice: renderquotesand callselectQuote(), or pass a one-shotcreateCheckout({ serviceProvider })without touching the sticky selection. Backend choice: projects with fiat-onramp smart routing enabled get exactly one routed quote —canSelectProviderisfalseand there is nothing to pick. - Quote failures are non-fatal (
error.code === 'QUOTES_FAILED') — quotes are cleared rather than left stale, and refresh keeps trying. - Payer country is auto-detected from the user's IP when
countryCodeis omitted ('US' fallback if detection fails — modal parity); the effective value is exposed ascountryCodeon the result. PasscountryCode(live) to override with your own geo signal. This is the one deliberate exception to the headless SDK's not-IP-aware stance: for the onramp, country is a functional input of quoting, not a policy gate. Changing it live re-resolves the onramp route as well as the quotes (routing is geo-dependent), sodestinationTokenandquotesare briefly empty while that settles. - Amount validation is host-side: check
minimum_amount/maximum_amountfromuseFiatCurrenciesbefore quoting; the session only requires a parseable amount > 0. - Two amount modes — provide exactly one.
sourceAmount("spend 100 USD", fees included;quote.destinationAmountis what you receive) ordestinationAmount("receive exactly 100 USDC", provider fees added on top;quote.sourceAmountis the fiat the user pays). Both are live inputs; swapping which one you pass switches modes in place. In destination mode only providers that support fixed-destination quoting appear inquotes— the backend filters the rest — and the destination token has to be a stablecoin (destinationToken.isStablecoin), because the amount prices the provider-side currency. For anything else, quoting stops with a non-fatalDESTINATION_AMOUNT_UNSUPPORTEDerror until you switch back tosourceAmount(the modal shows an error screen for the same case). onramp_session.createdanddirect_execution.*events are byte-compatible with the modal SDK'sonEvent, so one handler can serve both surfaces.- Other rails ride the same hook via
paymentMethodType('card' | 'apple_pay' | 'sepa' | 'us_bank_account'; default'card').
Vanilla JS (no React)
The controllers are 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();The onramp follows the same shape:
const card = unifold.createOnrampSession({
externalUserId,
destination,
quoteRequest: { sourceAmount: '100' }, // country auto-detected; override with countryCode
});
await card.start(); // addresses + onramp route + first quotes
const checkout = card.createCheckout(); // sync — build URL from the selected quote
openUrl(checkout.url);
const execution = await card.waitForSuccess();
card.destroy();Hooks
| Hook | Purpose |
| --------------------------- | ------------------------------------------------------------------------------------------- |
| useDeposit | Flagship flow hook: deposit addresses + execution detection + status state machine + events |
| useOnramp | Card onramp flow hook: quotes + provider checkout URL + settlement watching |
| useDepositAddresses | Addresses without a live session (cached, idempotent create) |
| useSupportedDepositTokens | Source token/chain list for custom pickers |
| useFiatCurrencies | Fiat currencies + amount limits/suggestions for card onramp screens |
| 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:
Envelope fields mirror the webhook payload, so created is a Unix timestamp in
seconds (multiply by 1000 before handing it to Date).
deposit_session.started/.addresses_created/.confirmation_started/.stopped/.erroredonramp_session.started/.addresses_created/.quotes_updated/.created/.stopped/.errored(onramp_session.created= checkout built — same name andexternalIdpayload as the modal)direct_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
