npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 DepositSession controller 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's onEvent. 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/headless instead. Install exactly one of the two packages.

Install

npm install @unifold/headless-react
# or
pnpm add @unifold/headless-react

Setup

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-awareuseDeposit 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); quotesUpdatedAt drives 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: render quotes and call selectQuote(), or pass a one-shot createCheckout({ serviceProvider }) without touching the sticky selection. Backend choice: projects with fiat-onramp smart routing enabled get exactly one routed quote — canSelectProvider is false and 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 countryCode is omitted ('US' fallback if detection fails — modal parity); the effective value is exposed as countryCode on the result. Pass countryCode (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), so destinationToken and quotes are briefly empty while that settles.
  • Amount validation is host-side: check minimum_amount/maximum_amount from useFiatCurrencies before quoting; the session only requires a parseable amount > 0.
  • Two amount modes — provide exactly one. sourceAmount ("spend 100 USD", fees included; quote.destinationAmount is what you receive) or destinationAmount ("receive exactly 100 USDC", provider fees added on top; quote.sourceAmount is 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 in quotes — 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-fatal DESTINATION_AMOUNT_UNSUPPORTED error until you switch back to sourceAmount (the modal shows an error screen for the same case).
  • onramp_session.created and direct_execution.* events are byte-compatible with the modal SDK's onEvent, 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 / .errored
  • onramp_session.started / .addresses_created / .quotes_updated / .created / .stopped / .errored (onramp_session.created = checkout built — same name and externalId payload 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