@heliofi/checkout-react-native
v0.2.1
Published
MoonPay Commerce SDK for React Native — accept crypto payments in your app with one hook.
Maintainers
Readme
@heliofi/checkout-react-native
Accept crypto payments in your React Native app with MoonPay Commerce. Drop-in provider with composable UI components — from zero-config to fully custom.
Installation
npm install @heliofi/checkout-react-nativePeer dependencies
npm install \
react-native-gesture-handler \
react-native-reanimated \
react-native-safe-area-context \
react-native-svg \
lucide-react-nativeQuick Start
The simplest integration — 4 lines of code:
import {
MoonpayCommerceProvider,
usePayWithCrypto,
} from "@heliofi/checkout-react-native";
// 1. Wrap your app
export default function App() {
return (
<MoonpayCommerceProvider chargeToken="your-charge-token" network="main">
<Checkout />
</MoonpayCommerceProvider>
);
}
// 2. Use the hook
function Checkout() {
const { payWithCrypto } = usePayWithCrypto();
return <Button onPress={() => payWithCrypto()} title="Pay with Crypto" />;
}That's it. The SDK shows a wallet selector, deeplinks to the chosen wallet, polls the charge for confirmation, and displays success/error states — all automatically.
Creating a Charge
Charges are single-use checkout sessions. Always create them on your backend — never expose your API key to the client. See the full Charges API docs: https://docs.hel.io/docs/charges. The response contains a token (the charge token). Send that to your client and pass it to the provider:
<MoonpayCommerceProvider chargeToken={chargeTokenFromBackend} ...>Why a charge instead of a pay link? A charge is one-time-use, tied to a specific user/order, and produces a polling endpoint the SDK uses to confirm the on-chain transaction. The SDK does not create charges — that's a server responsibility.
Provider API
<MoonpayCommerceProvider
chargeToken="your-charge-token" // Required — charge token from your backend
network="main" // Optional — "main" (default) or "test" for devnet
theme="system" // Optional — "light" | "dark" | "system" (default)
onProcessing={({ showDefaultUI }) => {}} // Optional — suppresses built-in processing UI
onVerifying={({ showDefaultUI }) => {}} // Optional — suppresses built-in verifying UI
onSuccess={(result, { showDefaultUI }) => {}} // Optional — suppresses built-in success UI
onError={(result, { showDefaultUI }) => {}} // Optional — suppresses built-in error UI
>
{children}
</MoonpayCommerceProvider>Props
| Prop | Type | Required | Default | Description |
| -------------- | ------------------------------- | -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| chargeToken | string | Yes | — | Single-use charge token created on your backend via the Charges API. The post-payment redirect target is owned by the charge — set successRedirectUrl (and optionally cancelRedirectUrl) when you create it. |
| network | "main" \| "test" | No | "main" | API environment. Use "test" for development |
| theme | "light" \| "dark" \| "system" | No | "system" | Theme for SDK UI components |
| onProcessing | StatusCallback | No | — | Called when wallet deeplink opens. Suppresses built-in processing UI. |
| onVerifying | StatusCallback | No | — | Called when polling starts. Suppresses built-in verifying UI. |
| onSuccess | PaymentCallback | No | — | Called when payment is confirmed. Suppresses built-in success UI. |
| onError | PaymentCallback | No | — | Called when payment fails. Suppresses built-in error UI. |
Note: The SDK does not depend on
expo-linkingor any Expo-specific modules. It uses React Native's built-inLinkingAPI. The redirect after payment is controlled by the charge'ssuccessRedirectUrl/cancelRedirectUrl(set on your backend when you create the charge) — your app just needs to register the matching URL scheme so the OS routes the deeplink back.
Note on
chargeToken: Because the SDK polls for transaction status (and may need to re-poll after the user closes and reopens the app), the charge token must live on the Provider so it's stable across the entire payment flow. It's not a per-call option.
Hook API
const {
payWithCrypto, // Trigger a payment
wallets, // Available wallets (from API)
status, // Current payment status
isLoading, // True while wallet list is being fetched
reset, // Reset status to idle
transactionResult, // Transaction details after completion
resolvedTheme, // Current resolved theme ("light" | "dark")
} = usePayWithCrypto({
onProcessing: ({ showDefaultUI }) => {}, // Optional — suppresses built-in processing UI
onVerifying: ({ showDefaultUI }) => {}, // Optional — suppresses built-in verifying UI
onSuccess: (result, { showDefaultUI }) => {}, // Optional — suppresses built-in success UI
onError: (result, { showDefaultUI }) => {}, // Optional — suppresses built-in error UI
});Return values
| Field | Type | Description |
| ------------------- | --------------------------- | ------------------------------------------------------------------------------ |
| payWithCrypto | (options?) => void | Triggers the payment flow. Without options, shows the built-in wallet selector |
| wallets | Wallet[] | Available wallets, filtered by the charge's blockchain. Prefetched on mount |
| status | PaymentStatus | "idle" | "processing" | "verifying" | "success" | "error" |
| isLoading | boolean | true while the wallet list is being fetched from the API |
| reset | () => void | Resets status back to "idle" |
| transactionResult | TransactionResult \| null | Contains status and optional transactionSignature after completion |
| resolvedTheme | "light" \| "dark" | The resolved theme (accounts for "system" preference) |
payWithCrypto() options
// Show built-in wallet selector
payWithCrypto();
// Skip wallet selector — go directly to a specific wallet
// (use the `id` from the wallets list returned by usePayWithCrypto())
payWithCrypto({ wallet: "PHANTOM" });Components
The SDK exports composable UI components. Mix and match to build your ideal checkout experience.
PaymentDrawer
A bottom sheet that handles the full payment lifecycle. Shows your wallet selection UI when idle, then automatically transitions to status displays (processing, verifying, success, error) during payment.
import {
PaymentDrawer,
DrawerHeader,
WalletButtons,
} from "@heliofi/checkout-react-native";
function Checkout() {
const [visible, setVisible] = useState(false);
return (
<>
<Button onPress={() => setVisible(true)} title="Pay" />
<PaymentDrawer visible={visible} onClose={() => setVisible(false)}>
<DrawerHeader
title="Connect Wallet"
onClose={() => setVisible(false)}
/>
<WalletButtons />
</PaymentDrawer>
</>
);
}DrawerHeader
Styled header with title and close button. Use inside PaymentDrawer or your own modal.
<DrawerHeader title="Pay with Crypto" onClose={handleClose} />WalletButtons
Renders all available wallets as a scrollable list with detection badges. Reads wallet data from context.
<WalletButtons />WalletButton
Renders a single wallet row. Use when you want to filter, reorder, or wrap individual wallet items.
import { WalletButton } from "@heliofi/checkout-react-native";
const { wallets } = usePayWithCrypto();
{
wallets
.filter((w) => w.enabled)
.map((wallet) => <WalletButton key={wallet.id} wallet={wallet} />);
}StatusContent
Pre-built status display with icons, titles, and action buttons. Use it in your own modal, inline on your page, or anywhere you want status UI.
import { StatusContent } from "@heliofi/checkout-react-native";
const { status, reset } = usePayWithCrypto();
{
status !== "idle" && (
<StatusContent status={status} onDone={reset} onRetry={reset} />
);
}Composition Examples
Level 1: Zero Config
The SDK handles everything — wallet modal, deeplinks, polling, status UI.
const { payWithCrypto } = usePayWithCrypto();
<Button onPress={() => payWithCrypto()} title="Pay with Crypto" />;Level 2: Custom Drawer with SDK Components
Use PaymentDrawer + WalletButtons for a custom bottom sheet that still handles status transitions automatically.
const [visible, setVisible] = useState(false);
<Button onPress={() => setVisible(true)} title="Pay" />
<PaymentDrawer visible={visible} onClose={() => setVisible(false)}>
<DrawerHeader title="Choose Wallet" onClose={() => setVisible(false)} />
<WalletButtons />
</PaymentDrawer>Level 3: Individual Wallet Buttons
Cherry-pick which wallets to show, filter by enabled status, or add your own UI around each wallet.
const { wallets } = usePayWithCrypto();
<PaymentDrawer visible={visible} onClose={close}>
<DrawerHeader title="Choose Wallet" onClose={close} />
{wallets
.filter((w) => w.enabled)
.map((wallet) => (
<WalletButton key={wallet.id} wallet={wallet} />
))}
</PaymentDrawer>;Level 4: Fully Custom UI
Build your own wallet list using the wallets array and payWithCrypto({ wallet }).
const { payWithCrypto, wallets } = usePayWithCrypto({
onSuccess: (result) => console.log("Paid!", result.transactionSignature),
});
<Modal visible={visible}>
{wallets.map((wallet) => (
<Pressable
key={wallet.id}
onPress={() => payWithCrypto({ wallet: wallet.id })}
>
<Text>{wallet.name}</Text>
</Pressable>
))}
</Modal>;Level 5: Custom Status Display
Use StatusContent in your own layout, or read status directly for a completely custom experience.
const { status, reset } = usePayWithCrypto();
// Option A: Use the pre-built StatusContent component
{
status !== "idle" && (
<StatusContent status={status} onDone={reset} onRetry={reset} />
);
}
// Option B: Build your own from the status value
{
status === "verifying" && <MySpinner text="Checking blockchain..." />;
}
{
status === "success" && <MyCheckmark text="Payment confirmed!" />;
}
{
status === "error" && (
<MyError
onRetry={() => {
reset();
payWithCrypto();
}}
/>
);
}Theming
The SDK supports light, dark, and system-auto themes. Pass the theme prop to the provider:
<MoonpayCommerceProvider theme="dark" chargeToken="..." network="main">All SDK components (WalletButtons, DrawerHeader, PaymentDrawer, StatusContent) automatically adapt to the active theme using internal design tokens (plain StyleSheet.create() under the hood — no nativewind, no CSS, no setup).
Theme-matching custom UI
If you're building custom UI alongside the SDK and want it to visually match, use the useTokens hook to read the same color/spacing/radius values the SDK uses:
import { useTokens } from "@heliofi/checkout-react-native";
function MyButton() {
const t = useTokens();
return (
<Pressable
style={{
backgroundColor: t.colors.primary,
paddingHorizontal: t.spacing.lg,
paddingVertical: t.spacing.md,
borderRadius: t.radii.md,
}}
>
<Text
style={{ color: t.colors.textInverse, fontWeight: t.fontWeight.bold }}
>
Pay
</Text>
</Pressable>
);
}useTokens() automatically returns the right tokens for the current theme prop (light/dark). The Tokens type is also exported if you need to type a styles factory.
Callbacks
Callbacks can be set at two levels (Provider and hook). Both fire if both are set.
Key behavior: Providing a callback for a status suppresses the built-in UI for that specific status. Each status is independently controllable — cherry-pick which ones you handle and which ones the SDK handles.
// Global (Provider level)
<MoonpayCommerceProvider
onVerifying={({ showDefaultUI }) => {
analytics.track("payment_verifying");
showDefaultUI(); // still show the spinner
}}
onSuccess={(result) => analytics.track("payment_success")}
onError={(result) => Sentry.captureMessage("payment_failed")}
>
// Per-hook
const { payWithCrypto } = usePayWithCrypto({
onSuccess: (result) => router.push("/order-confirmed"),
onError: (result) => Alert.alert("Failed", result.status),
});Available callbacks
| Callback | Signature | When it fires |
| -------------- | --------------------------- | ---------------------------------------- |
| onProcessing | (helpers) => void | Wallet deeplink opened, waiting for user |
| onVerifying | (helpers) => void | User returned, polling charge status |
| onSuccess | (result, helpers) => void | Transaction confirmed on-chain |
| onError | (result, helpers) => void | Transaction failed or timed out |
Showing built-in UI from a callback
Each callback receives a helpers object with showDefaultUI(). Call it to show the SDK's built-in drawer for that status even when you have a custom callback:
const { payWithCrypto } = usePayWithCrypto({
// Track + still show built-in verifying spinner
onVerifying: ({ showDefaultUI }) => {
analytics.track("payment_verifying");
showDefaultUI();
},
// Custom success — built-in success UI suppressed
onSuccess: (result) => {
router.push("/order-confirmed");
},
// Custom logic + still show built-in error drawer
onError: (result, { showDefaultUI }) => {
Sentry.captureException(result);
showDefaultUI();
},
});Summary
| Scenario | Built-in UI |
| -------------------------------- | ----------------------- |
| No callback provided | Shown automatically |
| Callback provided | Suppressed — you own it |
| Callback calls showDefaultUI() | Shown on demand |
| Callback is () => {} (no-op) | Suppressed |
Types
type TransactionResult = {
status: "SUCCESS" | "FAILED" | "UNKNOWN";
transactionSignature?: string;
};
type CallbackHelpers = {
showDefaultUI: () => void;
};
// For terminal states (success/error) — receives the result
type PaymentCallback = (
result: TransactionResult,
helpers: CallbackHelpers,
) => void;
// For transient states (processing/verifying) — no result yet
type StatusCallback = (helpers: CallbackHelpers) => void;Polling Behaviour
The SDK polls GET /charge/{chargeToken} to determine when a payment has been confirmed on-chain.
| Trigger | Behaviour |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Provider mounts (or chargeToken changes) | One-shot poll. Catches the case where the charge was already paid (e.g., user closed the app after paying). |
| Status is processing or verifying AND app is foregrounded | Continuous polling with staged backoff: every 3s for the first minute (catches Solana, Base, Arbitrum, BSC), then every 15s up to 10 minutes (catches Ethereum, Tron, Polygon), then every 60s up to 30 minutes (catches Bitcoin, Doge, deep ETH confirmations). |
| Status leaves processing/verifying (terminal/idle/reset) | Polling stops. |
| App goes to background | Polling stops; resumes on next foreground. |
| 30-minute total timeout | Polling stops with UNKNOWN result. Note: this does NOT fire onError (the transaction may still confirm on-chain after this point — it's just no longer being watched). The status returns to idle and the user can retry by calling payWithCrypto again. |
Hydration vs witnessed transitions
Because charges are persistent (the same chargeToken may be paid hours before the app is opened again), the SDK distinguishes between witnessed payments and hydrated state:
- Witnessed payment: the SDK saw the user start the flow in this session —
statuswent throughprocessingand/orverifying. When the poll then returns SUCCESS/FAILED,onSuccess/onErrorfires. - Hydrated state: the SDK boots, polls, and discovers the charge is already in a terminal state without any user action in this session.
statusupdates so you can read it (andtransactionResultis populated), butonSuccess/onErrordoes not fire.
Without this distinction, onSuccess would fire on every cold launch with a paid charge, causing duplicate route pushes, analytics events, or order fulfilment.
If you need to react to a hydrated terminal state (e.g., navigate the user to the order confirmation page), read status in your own effect:
const { status, transactionResult } = usePayWithCrypto();
useEffect(() => {
if (status === "success") {
router.replace("/order-confirmed");
}
}, [status]);Deeplink returns are always treated as witnessed signals — ?status=error from the dashboard fires onError even on a cold start.
Best practice: rotate
chargeTokenbetween checkouts. A charge represents a single checkout session, and the SDK's behaviour is cleanest when each session has its own token.
Platform Support
| Platform | Behavior |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| iOS / Android | Opens the wallet via deeplink (Phantom, MetaMask, Solflare, etc.). After payment the hosted page redirects to the charge's successRedirectUrl — your app catches that deeplink and the SDK polls the on-chain result. |
| Web | Redirects to the hosted payment page. After payment the page navigates to the charge's successRedirectUrl. |
Supported Wallets
Wallet availability is determined by the charge's blockchain configuration and returned dynamically from the API.
Solana: Phantom, Solflare, Backpack, Coinbase Wallet, Trust Wallet, OKX Wallet
EVM: MetaMask, Coinbase Wallet, Trust Wallet, Rainbow, OKX Wallet, Phantom
TypeScript
All types are exported:
import type {
CallbackHelpers,
MoonpayCommerceProviderProps,
Network,
PaymentCallback,
PaymentStatus,
PayWithCryptoHookOptions,
PayWithCryptoOptions,
StatusCallback,
ThemeMode,
TransactionResult,
Wallet,
} from "@heliofi/checkout-react-native";License
MIT
