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

@smarthivelabs-devs/payments-expo

v1.2.0

Published

Expo and React Native SDK for SmartHive Payments — in-app checkout via WebBrowser

Downloads

457

Readme

@smarthivelabs-devs/payments-expo

Official Expo and React Native SDK for SmartHive Payments — in-app checkout using expo-web-browser, no extra native modules required.

Installation

npx expo install expo-web-browser
npm install @smarthivelabs-devs/payments-expo

Peer dependencies (you likely have these already):

npm install react react-native

Register a deep link scheme

In app.json / app.config.js, set a URL scheme so the in-app browser can return control to your app after payment:

{
  "expo": {
    "scheme": "myapp"
  }
}

Environment / Backend Setup

The Expo SDK never holds your API key. It talks to your own backend, which uses @smarthivelabs-devs/payments to call SmartHive. Your backend needs two endpoints:

| Endpoint | Method | Description | |---|---|---| | /api/payments/checkout/session | POST | Creates a checkout session, returns { checkoutUrl, sessionId, callerReference }. No payment reference yet — the transaction is created when the customer pays. Pass callerReference in the request body to attach your own ID. | | /api/payments/verify/:reference | GET | Returns { status, reference, amount, currency, paidAt }. Call after payment with onSuccess(result).reference, your callerReference, or a webhook event.data.reference. |

See @smarthivelabs-devs/payments or @smarthivelabs-devs/payments-next for backend setup.


useExpoCheckout — In-App Checkout

Opens a SmartHive hosted checkout inside the app (no browser tab switch) and handles the result.

import { useExpoCheckout } from '@smarthivelabs-devs/payments-expo';

export default function CheckoutScreen({ navigation }) {
  const { openCheckout, state, error, result, reset } = useExpoCheckout({
    backendUrl: 'https://yourapp.com',
    returnUrl: 'myapp://payment/complete',  // matches your app.json scheme
    onSuccess: (result) => {
      navigation.navigate('OrderComplete', { reference: result.reference });
    },
    onCancelled: () => {
      console.log('Customer cancelled');
    },
    onFailed: (err) => {
      console.error('Checkout error:', err.message);
    },
  });

  return (
    <View style={styles.container}>
      <Button
        title={state === 'creating' ? 'Please wait...' : 'Pay GHS 50'}
        disabled={state === 'creating' || state === 'opening'}
        onPress={() =>
          openCheckout({
            amount: 5000,
            currency: 'GHS',
            countryCode: 'GH',
            customerEmail: '[email protected]',
            customerName: 'Kwame Mensah',
          })
        }
      />

      {state === 'success' && <Text>Payment complete! Ref: {result?.reference}</Text>}
      {state === 'cancelled' && <Text>Payment cancelled. <Button title="Try again" onPress={reset} /></Text>}
      {state === 'error' && <Text>Error: {error?.message}</Text>}
    </View>
  );
}

State values

| State | Meaning | |---|---| | idle | No checkout in progress | | creating | Calling backend to create session | | opening | In-app browser is opening | | pending | Browser opened — awaiting webhook or return | | success | Browser returned a success URL | | cancelled | Customer dismissed/cancelled | | failed | Payment failed | | error | An unexpected error occurred |

useExpoCheckout options

| Option | Type | Default | Description | |---|---|---|---| | backendUrl | string | Required | Base URL of your backend | | sessionEndpoint | string | /api/payments/checkout/session | Override session creation path | | returnUrl | string | — | Deep link the browser returns to (e.g. myapp://payment/complete) | | onSuccess | (result) => void | — | Called when browser returns successfully | | onCancelled | () => void | — | Called when customer dismisses the browser | | onFailed | (err: Error) => void | — | Called on error |

openCheckout params

| Param | Type | Required | Description | |---|---|---|---| | amount | number | Yes | Amount in the smallest currency unit (e.g. pesewas) | | currency | string | Yes | ISO currency code (e.g. GHS, USD) | | countryCode | string | Yes | ISO country code (e.g. GH, NG) | | platform | string | No | 'expo' (default) or 'mobile' | | customerEmail | string | No | Pre-fill customer email | | customerName | string | No | Pre-fill customer name | | callerReference | string | No | Your own order/transaction ID — pass to backend so you can verify with it later | | mode | 'sandbox' \| 'live' | No | Override payment mode | | metadata | Record<string, unknown> | No | Custom metadata passed to your backend |


useExpoPaymentStatus — Polling Hook

Poll your backend for payment status after the in-app browser returns. Use this when you need to verify payment server-side (recommended alongside webhooks).

Where does reference come from? Two options:

  1. callerReference — pass callerReference: 'ORDER-001' in openCheckout params. Your backend stores it on the session, and after payment the transaction is verifiable by that ID. Use 'ORDER-001' directly as the reference.
  2. onSuccess(result).reference — when the customer pays, the SDK extracts ?reference=txn_xxx from the return URL and passes it as result.reference in the onSuccess callback. Store it in state and pass it here.

Do not use sessionId from session creation — it is chs_xxx, not a payment reference, and will always 404.

import { useExpoCheckout, useExpoPaymentStatus } from '@smarthivelabs-devs/payments-expo';
import { useState } from 'react';

// Typical pattern: get reference from useExpoCheckout.onSuccess, then start polling
function CheckoutAndConfirmScreen({ navigation }) {
  const [paymentReference, setPaymentReference] = useState<string | null>(null);

  const { openCheckout } = useExpoCheckout({
    backendUrl: 'https://yourapp.com',
    returnUrl: 'myapp://payment/complete',
    onSuccess: (result) => {
      // result.reference is the txn_xxx payment reference extracted from the return URL
      setPaymentReference(result.reference);
    },
  });

  const { status, data } = useExpoPaymentStatus(paymentReference, {
    backendUrl: 'https://yourapp.com',
    onSuccess: () => navigation.navigate('OrderComplete'),
    onFailed: () => navigation.navigate('OrderFailed'),
  });
  // ...
}

Full polling example

import { useExpoPaymentStatus } from '@smarthivelabs-devs/payments-expo';

function OrderConfirmScreen({ route }) {
  // reference must be result.reference from useExpoCheckout's onSuccess callback
  const { reference } = route.params;

  const { status, data, isLoading, error, stop, refetch } = useExpoPaymentStatus(reference, {
    backendUrl: 'https://yourapp.com',
    pollInterval: 3000,
    maxAttempts: 40,
    onSuccess: (data) => {
      console.log('Paid at:', data.paidAt);
    },
    onFailed: (data) => {
      console.log('Failed:', data.reference);
    },
    onTimeout: () => {
      Alert.alert('Timeout', 'Could not verify payment. Please check your order history.');
    },
  });

  if (isLoading && status === 'pending') {
    return <ActivityIndicator size="large" />;
  }

  if (status === 'success') {
    return (
      <View>
        <Text>Payment confirmed!</Text>
        <Text>Reference: {data?.reference}</Text>
        <Text>Amount: {data?.amount} {data?.currency}</Text>
      </View>
    );
  }

  if (status === 'failed') {
    return <Text>Payment failed. Please try again.</Text>;
  }

  if (error) {
    return (
      <View>
        <Text>Error: {error.message}</Text>
        <Button title="Retry" onPress={refetch} />
      </View>
    );
  }

  return <Text>Awaiting payment confirmation...</Text>;
}

Pass null to keep the hook idle:

// Only start polling after the browser closes
const { status } = useExpoPaymentStatus(hasReference ? reference : null, options);

useExpoPaymentStatus options

| Option | Type | Default | Description | |---|---|---|---| | backendUrl | string | Required | Base URL of your backend | | verifyEndpoint | string | /api/payments/verify/:reference | Override verify path | | pollInterval | number | 3000 | Polling interval in ms | | maxAttempts | number | 60 | Max polls before calling onTimeout | | onSuccess | (data) => void | — | Called when status is success | | onFailed | (data) => void | — | Called when status is failed | | onTimeout | () => void | — | Called when max attempts reached |

Return values

| Field | Type | Description | |---|---|---| | status | ExpoPaymentStatus | Current status: idle \| pending \| success \| failed \| cancelled | | data | ExpoPaymentStatusData \| null | Latest payment data from backend | | isLoading | boolean | true while a fetch is in-flight | | error | Error \| null | Last fetch error | | refetch | () => void | Manually trigger a status check | | stop | () => void | Stop polling |


ExpoCheckoutButton — Drop-in Component

A styled Pressable-based button that wraps useExpoCheckout. Works on iOS and Android with no extra setup.

import { ExpoCheckoutButton } from '@smarthivelabs-devs/payments-expo';

export default function ProductScreen({ navigation }) {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Premium Plan — GHS 200/month</Text>

      <ExpoCheckoutButton
        backendUrl="https://yourapp.com"
        returnUrl="myapp://payment/complete"
        params={{
          amount: 20000,
          currency: 'GHS',
          countryCode: 'GH',
          customerEmail: '[email protected]',
        }}
        label="Subscribe — GHS 200"
        loadingLabel="Opening payment..."
        onSuccess={(result) =>
          navigation.navigate('Dashboard', { reference: result.reference })
        }
        onCancelled={() => console.log('User cancelled')}
        onFailed={(err) => Alert.alert('Payment Error', err.message)}
        buttonStyle={{ backgroundColor: '#0a5c36' }}
        textStyle={{ fontSize: 18 }}
      />
    </View>
  );
}

ExpoCheckoutButton props

| Prop | Type | Default | Description | |---|---|---|---| | params | ExpoCheckoutParams | Required | Payment parameters | | backendUrl | string | Required | Your backend base URL | | returnUrl | string | — | Deep link scheme for app return | | label | string | 'Pay Now' | Button text | | loadingLabel | string | 'Opening...' | Text while creating/opening | | accessibilityLabel | string | Same as label | Screen reader label | | containerStyle | ViewStyle | — | Wrapper View style | | buttonStyle | ViewStyle | — | Pressable style | | textStyle | TextStyle | — | Label text style | | disabledStyle | ViewStyle | — | Style override when disabled | | onSuccess | (result) => void | — | Called on successful payment | | onCancelled | () => void | — | Called when customer cancels | | onFailed | (err: Error) => void | — | Called on error |


Full End-to-End Flow

import React, { useState } from 'react';
import { View, Text, Alert, StyleSheet } from 'react-native';
import {
  useExpoCheckout,
  useExpoPaymentStatus,
  ExpoCheckoutButton,
} from '@smarthivelabs-devs/payments-expo';

const BACKEND_URL = 'https://yourapp.com';
const RETURN_URL = 'myapp://payment/complete';

export default function CheckoutScreen({ navigation }) {
  const [reference, setReference] = useState<string | null>(null);

  const { openCheckout, state: checkoutState } = useExpoCheckout({
    backendUrl: BACKEND_URL,
    returnUrl: RETURN_URL,
    onSuccess: (result) => {
      // result.reference is the txn_xxx payment reference extracted from the return URL.
      // This is NOT available from createSession — it only exists after the customer pays.
      setReference(result.reference ?? null);
    },
    onCancelled: () => Alert.alert('Cancelled', 'Payment was cancelled.'),
    onFailed: (err) => Alert.alert('Error', err.message),
  });

  const { status, data } = useExpoPaymentStatus(reference, {
    backendUrl: BACKEND_URL,
    onSuccess: () => navigation.navigate('OrderComplete'),
    onFailed: () => navigation.navigate('OrderFailed'),
  });

  return (
    <View style={styles.container}>
      <Text style={styles.heading}>Complete Your Order</Text>

      {!reference && (
        <ExpoCheckoutButton
          backendUrl={BACKEND_URL}
          returnUrl={RETURN_URL}
          params={{ amount: 5000, currency: 'GHS', countryCode: 'GH' }}
          label="Pay GHS 50"
          onSuccess={(result) => setReference(result.reference ?? null)}
        />
      )}

      {reference && status === 'pending' && (
        <Text>Verifying payment...</Text>
      )}
    </View>
  );
}

Sandbox Testing

| Card / Network | Number | Notes | |---|---|---| | Paystack test card (success) | 4084 0840 8408 4081 | Any future expiry, CVV 408 | | Paystack test card (decline) | 4084 0840 8408 4099 | Always declined | | MTN Mobile Money (test) | 0244123456 | Sandbox OTP: 123456 |

Set mode: 'sandbox' in your backend when calling @smarthivelabs-devs/payments to use test providers.


TypeScript

All types are exported:

import type {
  ExpoCheckoutParams,
  ExpoCheckoutResult,
  ExpoCheckoutState,
  UseExpoCheckoutOptions,
  UseExpoCheckoutResult,
  ExpoPaymentStatus,
  ExpoPaymentStatusData,
  UseExpoPaymentStatusOptions,
  UseExpoPaymentStatusResult,
  ExpoCheckoutButtonProps,
  // Coupon types
  UseExpoCouponValidateOptions,
  UseExpoCouponValidateResult,
  ExpoCouponValidateParams,
  ExpoCouponValidateResult,
} from '@smarthivelabs-devs/payments-expo';

Subscriptions

All subscription hooks and components are AppState-aware — they automatically re-fetch when the user returns to the app (equivalent to polling, but battery-friendly).

ExpoSubscriptionGuard

Gate React Native screens or components behind an active subscription.

import { ExpoSubscriptionGuard, ExpoGracePeriodBanner } from '@smarthivelabs-devs/payments-expo';

export default function PremiumScreen({ userId }: { userId: string }) {
  return (
    <>
      {/* Shows only when payment has failed and grace period is active */}
      <ExpoGracePeriodBanner
        planCode="plan_pro"
        customerId={userId}
        apiBaseUrl="https://yourapp.com"
        ctaLabel="Update Payment"
        onCtaClick={() => navigation.navigate('Billing')}
      />

      {/* Guards children — shows fallback when not subscribed */}
      <ExpoSubscriptionGuard
        planCode="plan_pro"
        customerId={userId}
        apiBaseUrl="https://yourapp.com"
        fallback={<UpgradeScreen />}
        graceFallback={<PaymentFailedBanner />}
        loadingFallback={<ActivityIndicator />}
      >
        <ProFeatureScreen />
      </ExpoSubscriptionGuard>
    </>
  );
}

Multi-plan access (different tiers on the same screen)

// Gate content if user has ANY of these plans
<ExpoSubscriptionGuard
  planCodes={['plan_weekly_pro', 'plan_monthly_pro']}
  customerId={userId}
  apiBaseUrl="https://yourapp.com"
  fallback={<UpgradeScreen />}
>
  <ProContent />
</ExpoSubscriptionGuard>

// Gate by specific entitlement
<ExpoSubscriptionGuard
  planCode="plan_pro"
  entitlement="download"
  customerId={userId}
  apiBaseUrl="https://yourapp.com"
  fallback={<UpgradeForDownloads />}
>
  <DownloadButton />
</ExpoSubscriptionGuard>

useExpoSubscription

Fetch subscription state for a single plan.

import { useExpoSubscription } from '@smarthivelabs-devs/payments-expo';

function SubscriptionStatus({ userId }: { userId: string }) {
  const { status, hasAccess, isInGracePeriod, daysRemainingInGracePeriod, loading } = useExpoSubscription({
    planCode: 'plan_pro',
    customerId: userId,
    apiBaseUrl: 'https://yourapp.com',
    refetchOnForeground: true, // re-fetch when app returns to foreground (default: true)
  });

  if (loading) return <ActivityIndicator />;
  return (
    <Text>
      {hasAccess ? `Active (${status})` : 'No subscription'}
      {isInGracePeriod && ` — ${daysRemainingInGracePeriod} days remaining in grace period`}
    </Text>
  );
}

Return shape: | Field | Type | Description | |---|---|---| | subscription | Subscription \| null | Raw subscription record | | plan | SubscriptionPlan \| null | Plan details including entitlements | | status | SubscriptionStatus \| null | Current status string | | hasAccess | boolean | true when active, trialing, or in grace period | | isActive | boolean | status === 'active' | | isTrialing | boolean | status === 'trialing' | | isInGracePeriod | boolean | past_due AND within grace period window | | isPaused | boolean | status === 'paused' | | isCancelled | boolean | status === 'cancelled' | | isExpired | boolean | status === 'expired' | | entitlements | string[] | Features granted by this plan | | hasEntitlement(key) | fn | Check a specific entitlement | | daysRemainingInGracePeriod | number | Days left in grace period (0 if not in grace period) | | gracePeriodEndsAt | Date \| null | When grace period expires | | loading | boolean | — | | error | string \| null | — | | refetch | fn | Manually trigger a re-fetch |

useExpoMultiPlanAccess

Batch-fetch access for multiple plans in a single request.

import { useExpoMultiPlanAccess } from '@smarthivelabs-devs/payments-expo';

const { byPlan, hasAnyAccess, allEntitlements, loading } = useExpoMultiPlanAccess({
  planCodes: ['plan_basic', 'plan_pro', 'plan_enterprise'],
  customerId: userId,
  apiBaseUrl: 'https://yourapp.com',
});

const hasBasic = byPlan['plan_basic']?.hasAccess ?? false;
const hasPro   = byPlan['plan_pro']?.hasAccess ?? false;
const canDownload = allEntitlements.includes('download');

ExpoGracePeriodBanner props

| Prop | Type | Description | |---|---|---| | planCode | string | Plan to check | | customerId | string? | Customer ID | | customerEmail | string? | Customer email | | apiBaseUrl | string? | Your backend base URL | | message | string? | Custom message (default: days-remaining message) | | ctaLabel | string? | Button label | | onCtaClick | fn? | Button press handler | | style | ViewStyle? | Custom container style | | messageStyle | TextStyle? | Custom text style | | ctaStyle | TextStyle? | Custom CTA text style |



Coupons

useExpoCouponValidate — Dry-run validation hook

Preview a coupon's discount before the customer confirms checkout. This is a React Native-safe hook — no window references.

import { useExpoCouponValidate } from '@smarthivelabs-devs/payments-expo';

function CouponPreviewScreen() {
  const { validate, result, loading, error, reset } = useExpoCouponValidate({
    apiKey: process.env.EXPO_PUBLIC_SMARTHIVE_API_KEY!,
    baseUrl: 'https://yourapp.com',
  });

  const handleApply = async () => {
    const discount = await validate('SUMMER20', {
      amountMinor: 5000,        // GHS 50.00
      currency: 'GHS',
      applicableTo: 'checkout',
    });

    if (discount) {
      console.log('Saves:', discount.discountAmountMinor / 100);
      console.log('Pay:', discount.finalAmountMinor / 100);
    }
  };

  return (
    <View>
      <Button title="Preview SUMMER20" onPress={handleApply} />
      {loading && <ActivityIndicator />}
      {result && (
        <Text>Save {result.discountAmountMinor / 100} — pay {result.finalAmountMinor / 100}</Text>
      )}
      {error && <Text style={{ color: 'red' }}>{error}</Text>}
    </View>
  );
}

useExpoCouponValidate options

| Option | Type | Required | Description | |---|---|---|---| | apiKey | string | Yes | Your public-facing API key | | baseUrl | string | No | Backend base URL |

Return values

| Field | Type | Description | |---|---|---| | validate | (code, params) => Promise<ExpoCouponValidateResult \| null> | Dry-run a coupon code; returns breakdown or null on error | | result | ExpoCouponValidateResult \| null | Latest successful result | | loading | boolean | True while request is in-flight | | error | string \| null | Error message if validation failed | | reset | () => void | Clear result and error |


ExpoCouponInput — Drop-in component

A styled React Native TextInput + Apply button. Shows the discount on success and a Remove button to clear it.

import { ExpoCouponInput } from '@smarthivelabs-devs/payments-expo';

function CheckoutScreen() {
  const [coupon, setCoupon] = useState<{
    code: string;
    discount: number;
    final: number;
  } | null>(null);

  return (
    <View>
      <Text>Total: GHS {coupon ? (coupon.final / 100).toFixed(2) : '50.00'}</Text>

      <ExpoCouponInput
        amountMinor={5000}
        currency="GHS"
        apiKey={process.env.EXPO_PUBLIC_SMARTHIVE_API_KEY!}
        baseUrl="https://yourapp.com"
        onApply={(code, discountAmountMinor, finalAmountMinor) =>
          setCoupon({ code, discount: discountAmountMinor, final: finalAmountMinor })
        }
        onRemove={() => setCoupon(null)}
      />
      {/* Renders: [________] [Apply]                           */}
      {/* When valid: ✓ SUMMER20 — GHS 10.00 off   [Remove]   */}
    </View>
  );
}

ExpoCouponInput props

| Prop | Type | Required | Description | |---|---|---|---| | amountMinor | number | Yes | Order amount in minor units (for dry-run preview) | | currency | string | Yes | ISO currency code | | apiKey | string | Yes | Your public-facing API key | | baseUrl | string | No | Backend base URL | | onApply | (code, discountAmountMinor, finalAmountMinor) => void | Yes | Called when a valid coupon is applied | | onRemove | () => void | Yes | Called when the coupon is removed | | customerId | string | No | Customer ID (forwarded for per-customer limit checks) | | customerEmail | string | No | Customer email (forwarded for per-customer limit checks) | | style | ViewStyle | No | Custom container style |

Apply the coupon at checkout

After the customer validates a coupon, pass couponCode in openCheckout params. It is forwarded to your backend's createSession call where the discount is applied server-side.

Also pass allowCouponInput: true if you want the hosted checkout page to show a coupon entry field for the customer. Without this flag the coupon UI is hidden on the checkout page.

openCheckout({
  amount: 5000,
  currency: 'GHS',
  countryCode: 'GH',
  couponCode: 'SUMMER20',   // optional — pre-apply; discount stored server-side
  allowCouponInput: true,   // optional — show coupon input on hosted checkout page
  customerEmail: '[email protected]',
});

Troubleshooting

In-app browser opens but the app doesn't get the return URL

  • Make sure scheme is set in app.json
  • The returnUrl must start with your scheme (e.g. myapp://)
  • On Android, confirm intentFilters are set in app.json for bare workflow

Polling never resolves to success

  • Confirm your backend's verify endpoint returns { status: 'success' } (case-insensitive match)
  • Check that backendUrl doesn't have a trailing slash

"Session creation failed (403)"

  • Your backend API key may be invalid or in the wrong mode — check SMARTHIVE_PAYMENTS_API_KEY

Sandbox & Live Mode

Pass mode in openCheckout() — it is forwarded to your backend and must match the API key your server holds (sk_test_'sandbox', sk_live_'live'):

const { openCheckout, state } = useExpoCheckout({
  apiBaseUrl: 'https://your-backend.com',
  apiKey: process.env.EXPO_PUBLIC_SMARTHIVE_API_KEY!,
  onSuccess: (data) => router.push('/success'),
});

openCheckout({
  amount: 5000,
  currency: 'GHS',
  countryCode: 'GH',
  mode: 'sandbox', // use 'live' in production — must match the server-side key prefix
  returnUrl: Linking.createURL('payment/callback'),
  customerEmail: '[email protected]',
});

To keep mode in sync with your server key automatically:

// Derive from env — no risk of key/mode mismatch
const apiKey = process.env.SMARTHIVE_API_KEY!; // server-side only
const mode = apiKey.startsWith('sk_live_') ? 'live' : 'sandbox';

For useExpoSubscription / ExpoSubscriptionGuard, the apiKey prop is your backend's public access key — not the SmartHive key. SmartHive keys are server-side only and never shipped in the app bundle.


Support

Contact Smart Hive Labs or open an issue in the workspace repository.