@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-expoPeer dependencies (you likely have these already):
npm install react react-nativeRegister 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
referencecome from? Two options:
callerReference— passcallerReference: 'ORDER-001'inopenCheckoutparams. Your backend stores it on the session, and after payment the transaction is verifiable by that ID. Use'ORDER-001'directly as the reference.onSuccess(result).reference— when the customer pays, the SDK extracts?reference=txn_xxxfrom the return URL and passes it asresult.referencein theonSuccesscallback. Store it in state and pass it here.Do not use
sessionIdfrom session creation — it ischs_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
schemeis set inapp.json - The
returnUrlmust start with your scheme (e.g.myapp://) - On Android, confirm
intentFiltersare set inapp.jsonfor bare workflow
Polling never resolves to success
- Confirm your backend's verify endpoint returns
{ status: 'success' }(case-insensitive match) - Check that
backendUrldoesn'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.
