A Paystack payment SDK for React Native & Expo that solves the "what happens after payment?" problem.
Backend-initialized, WebView-powered checkout with proper lifecycle management — success detection, redirect interception, automatic verification, and clean state handling.
This package requires a backend server. It does not support client-side (public key) initialization.
Before the checkout can open, your server must call Paystack's POST /transaction/initialize endpoint using your secret key and return the result to your app. The SDK takes it from there.
You need:
- A backend (Node.js, Python, Go, etc.) that can call Paystack's API
- A Paystack secret key (
sk_live_.../sk_test_...) stored securely on your server — never in the app - The backend to expose at minimum two endpoints: initialize and verify
This package does NOT support:
- Passing your public key directly to the SDK
- Client-side transaction initialization
- Paystack's Inline JS / popup embed
If you want a quick client-side integration without a backend, this is not the right package. If you want a secure, production-grade integration where your backend owns the transaction lifecycle, read on.
Every existing Paystack React Native package has the same gaps:
- Blank screens after payment (especially on iOS with newer Expo SDKs)
- No clear success/failure detection — the WebView loads a redirect URL and you're left guessing
- Public key on the frontend — initializing transactions client-side exposes your keys
- No verification flow — payment "succeeds" in the WebView but nobody confirms it
- Redirect hell — callback URLs load in the WebView instead of triggering app logic
This package takes a different approach: your backend owns the transaction lifecycle, and the frontend is just a secure checkout window with proper event detection.
┌─────────────┐ ┌──────────────┐ ┌───────────┐
│ Your App │────▶│ Your Backend │────▶│ Paystack │
│ (RN/Expo) │ │ (Express) │ │ API │
└──────┬───────┘ └──────┬────────┘ └─────┬─────┘
│ │ │
│ 1. "Pay ₦5,000" │ │
│───────────────────▶│ │
│ │ 2. POST /initialize│
│ │────────────────────▶│
│ │ │
│ │ 3. { access_code, │
│ │ authorization_url,
│ │ reference } │
│ │◀────────────────────│
│ 4. transaction │ │
│ data │ │
│◀───────────────────│ │
│ │ │
│ 5. Opens WebView │ │
│ checkout ──────────────────────────▶ │
│ │ │
│ 6. Customer pays │ │
│ in WebView │ │
│ │ │
│ 7. SDK detects │ │
│ success via │ │
│ redirect + │ │
│ postMessage │ │
│ │ │
│ 8. onSuccess() │ │
│ fires │ │
│ │ │
│ 9. Auto-verify │ │
│───────────────────▶│ 10. GET /verify │
│ │────────────────────▶│
│ │ │
│ 11. onVerified() │ 12. { status: │
│ fires ◀────────│ "success" } │
│ │◀────────────────────│
│ │ │
│ │ 13. Webhook: │
│ │ charge.success │
│ │◀────────────────────│
│ │ │
│ │ 14. Deliver value │
│ │ (fulfill order) │
npm install @funpm/paystack-rn-checkout-server react-native-webview react-native-safe-area-context
# Expo (resolves compatible versions automatically)
npx expo install react-native-webview react-native-safe-area-context
# Bare React Native — link native modules
cd ios && pod installYour backend handles all Paystack API communication. Here's the minimal Express setup:
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
// Initialize a transaction
app.post('/payments/initialize', async (req, res) => {
const { email, amount, channels } = req.body;
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${PAYSTACK_SECRET}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount, // in kobo (₦100 = 10000)
channels, // optional — restrict payment channels, e.g. ['card', 'bank_transfer']
callback_url: 'https://paystack-rn-checkout.localhost/callback',
}),
});
const data = await response.json();
res.json(data);
});
// Verify a transaction
app.get('/payments/verify', async (req, res) => {
const response = await fetch(
`https://api.paystack.co/transaction/verify/${req.query.reference}`,
{ headers: { Authorization: `Bearer ${PAYSTACK_SECRET}` } }
);
const data = await response.json();
res.json(data);
});
// Webhook — the REAL source of truth
app.post('/payments/webhook', (req, res) => {
const hash = crypto
.createHmac('sha512', PAYSTACK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.sendStatus(400);
}
if (req.body.event === 'charge.success') {
// Deliver value here — update DB, send emails, etc.
console.log('Payment confirmed:', req.body.data.reference);
}
res.sendStatus(200);
});Wrap your app with SafeAreaProvider and PaystackProvider once at the root:
// App.tsx
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { PaystackProvider } from '@funpm/paystack-rn-checkout-server';
export default function App() {
return (
<SafeAreaProvider>
<PaystackProvider config={{
verifyEndpoint: 'https://api.yourapp.com/payments/verify',
callbackUrl: 'https://paystack-rn-checkout.localhost/callback',
}}>
<YourNavigator />
</PaystackProvider>
</SafeAreaProvider>
);
}Then in your payment screen, use the usePaystackCheckout hook:
import {
PaystackCheckout,
usePaystackCheckout,
type InitializeTransactionBody,
} from '@funpm/paystack-rn-checkout-server';
export default function PaymentScreen() {
const checkout = usePaystackCheckout({
verifyEndpoint: 'https://api.yourapp.com/payments/verify',
onSuccess: (data) => {
// Checkout UI completed. Don't deliver value yet — wait for onVerified.
console.log('Reference:', data.reference);
},
onVerified: (data) => {
if (data.success) {
// Payment confirmed by your backend — safe to deliver value.
// Navigate, show a success screen, update UI, etc.
}
},
onCancel: () => {
// User closed without paying — or autoClose fired after success.
},
onError: (error) => {
console.error(error.code, error.message);
},
});
const handlePay = async () => {
const body: InitializeTransactionBody = {
email: 'user@example.com',
amount: 500000, // ₦5,000 in kobo
};
const res = await fetch('https://api.yourapp.com/payments/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { data } = await res.json();
checkout.open(data);
};
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Button title="Pay ₦5,000" onPress={handlePay} />
{checkout.currentTransaction && (
<PaystackCheckout
visible={checkout.isVisible}
transaction={checkout.currentTransaction}
onSuccess={checkout.handleSuccess}
onCancel={checkout.handleCancel}
onError={checkout.handleError}
/>
)}
</View>
);
}The SDK fires callbacks and exposes a state value — what you render after payment is entirely your own UI. There is no post-payment overlay built into the SDK.
const [verifiedData, setVerifiedData] = useState(null);
const checkout = usePaystackCheckout({
onVerified: (data) => setVerifiedData(data),
});
if (verifiedData?.success) {
return <SuccessScreen reference={verifiedData.reference} />;
}
if (verifiedData && !verifiedData.success) {
return <FailureScreen status={verifiedData.status} />;
}const [result, setResult] = useState(null);
const checkout = usePaystackCheckout({
onVerified: (data) => setResult(data),
});
// Then render your own <Modal visible={!!result} ...>const checkout = usePaystackCheckout({
onVerified: (data) => {
if (data.success) {
navigation.replace('PaymentSuccess', { reference: data.reference });
} else {
navigation.replace('PaymentFailed');
}
},
onCancel: () => {
navigation.goBack();
},
});Channels (card, bank transfer, USSD, etc.) are set server-side during transaction initialization — they cannot be changed from the frontend after a transaction is created.
Pass them in your initialization request body:
import type { InitializeTransactionBody } from '@funpm/paystack-rn-checkout-server';
const body: InitializeTransactionBody = {
email: 'user@example.com',
amount: 500000,
channels: ['card', 'bank_transfer'], // only show these two in checkout
};Omit channels to allow all channels enabled on your Paystack account.
The main checkout component. Renders a full-screen modal with a WebView.
| Prop | Type | Required | Description |
|---|---|---|---|
visible |
boolean |
Yes | Controls modal visibility |
transaction |
InitializedTransaction |
Yes | The { access_code, authorization_url, reference } from your backend |
onSuccess |
(data: CheckoutSuccessData) => void |
Yes | Fires when payment completes in the WebView |
onCancel |
(data: CheckoutCancelData) => void |
Yes | Fires when the checkout closes without completed payment, or when autoClose dismisses after success (data.reason === 'auto_closed') |
onError |
(error: CheckoutError) => void |
No | Fires on WebView or verification errors |
verifyEndpoint |
string |
No | Your backend's verify URL — enables auto-verification |
onVerified |
(data: VerifiedTransactionData) => void |
No | Fires after verification completes |
verifyDelay |
number |
No | Ms to wait before first verify attempt (default: 2000) |
verifyRetries |
number |
No | Max verification attempts on failure (default: 3) |
callbackUrl |
string |
No | URL the SDK intercepts as the "done" signal (default: https://paystack-rn-checkout.localhost/callback) |
autoClose |
boolean |
No | Auto-dismiss modal after success. Fires onCancel with reason: 'auto_closed' (default: false) |
autoCloseDelay |
number |
No | Ms before auto-close fires (default: 1500) |
showCloseButton |
boolean |
No | Show the default ✕ button (default: true) |
renderCloseButton |
(onClose: () => void) => ReactNode |
No | Replace the default close button |
renderLoading |
() => ReactNode |
No | Replace the default loading state |
renderError |
(error: CheckoutError, onRetry: () => void) => ReactNode |
No | Replace the default error state |
Hook for imperative control over the checkout flow.
const checkout = usePaystackCheckout({
onSuccess: (data) => { /* checkout completed */ },
onVerified: (data) => { /* verification result */ },
onCancel: () => { /* user cancelled */ },
onError: (error) => { /* error occurred */ },
verifyEndpoint: 'https://api.yourapp.com/payments/verify',
autoVerify: true, // default true when verifyEndpoint is set
});
// Open checkout with a transaction from your backend
checkout.open(transaction);
// Close programmatically (fires onCancel if not already completed)
checkout.close();
// State machine
checkout.state; // 'idle' | 'checkout' | 'verifying' | 'success' | 'failed' | 'cancelled' | 'error'
checkout.isVisible; // boolean
checkout.reference; // string | null
checkout.error; // CheckoutError | null
checkout.verifiedData; // VerifiedTransactionData | null
checkout.currentTransaction; // InitializedTransaction | null
// Wire these directly to <PaystackCheckout />
checkout.handleSuccess; // (data: CheckoutSuccessData) => void
checkout.handleCancel; // () => void
checkout.handleError; // (error: CheckoutError) => voidContext provider for default configuration. Wrap your app (or just the payment section) once.
<PaystackProvider config={{
verifyEndpoint: 'https://api.yourapp.com/payments/verify',
callbackUrl: 'https://paystack-rn-checkout.localhost/callback',
verifyDelay: 2000,
verifyRetries: 3,
}}>
<App />
</PaystackProvider>The SDK uses three layers to detect payment completion, so it never misses:
-
URL Interception — Monitors WebView navigation for the callback URL redirect. When Paystack redirects to your
callback_urlwith?trxref=xxx, the SDK intercepts it before the page loads. -
Injected JavaScript — A script injected into the WebView listens for Paystack's
postMessageevents (success,close,payment.success). This catches completions that don't trigger a full redirect. -
DOM Observation — A MutationObserver watches for Paystack's cancellation UI text, catching edge cases where neither redirect nor postMessage fires.
Never trust the frontend alone. The onSuccess callback means the customer completed the checkout flow — it does NOT mean money landed in your account.
Always verify, and prefer webhooks:
| Method | When to Use | Reliability |
|---|---|---|
| Webhooks (recommended) | Always set up | Paystack pushes to you — most reliable |
Auto-verify via verifyEndpoint |
For instant UI feedback | SDK calls your backend after success |
| Manual verify | Fallback / reconciliation | Call your backend's verify endpoint yourself |
The recommended pattern: use onSuccess to show a "confirming..." state, use onVerified to update your UI, and use webhooks on your backend to actually deliver value.
The callback_url is how Paystack tells your app "payment is done." In a web app, this redirects the browser. In a mobile app, the SDK intercepts this URL before it loads.
Your backend's callback_url and the SDK's callbackUrl prop must match.
The default https://paystack-rn-checkout.localhost/callback is intentionally unresolvable — the WebView catches it before any network request goes out.
<PaystackCheckout
renderLoading={() => (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<YourSpinner />
<Text>Preparing secure checkout...</Text>
</View>
)}
/><PaystackCheckout
renderError={(error, onRetry) => (
<View>
<Text>{error.message}</Text>
<Button title="Retry" onPress={onRetry} />
</View>
)}
/><PaystackCheckout
renderCloseButton={(onClose) => (
<TouchableOpacity onPress={onClose}>
<Icon name="arrow-left" />
</TouchableOpacity>
)}
/>Fully typed. All types are exported:
import type {
// Transaction
InitializedTransaction,
InitializeTransactionBody,
// Callbacks
CheckoutSuccessData,
CheckoutCancelData,
CheckoutError,
VerifiedTransactionData,
// Hook
UsePaystackCheckoutOptions,
UsePaystackCheckoutReturn,
CheckoutState,
// Config
PaystackCheckoutConfig,
PaystackProviderConfig,
// Primitives
PaymentChannel,
PaystackCurrency,
TransactionStatus,
} from '@funpm/paystack-rn-checkout-server';MIT