React Native
The @wajub/react-native package, a hook, a sheet, and no WebView.
@wajub/react-native collects payments inside your React Native application. Mobile Money fields
are native inputs, card fields come from @stripe/stripe-react-native, and the payer never sees a
WebView.
@wajub/react-native
Stable · GAnpm
- Version
- 1.1.1
- Runtime
- React 18+, React Native 0.74+
- Frameworks
- Android and iOS
Covers
- Payments
- Mobile Money
- Cards
Install
npm install @wajub/react-nativeIt ships TypeScript sources and declares pusher-js as its only real dependency. Three peers are
yours to provide.
| Peer | Range | Needed for |
|---|---|---|
react | >=18 | Always |
react-native | >=0.74 | Always |
@stripe/stripe-react-native | >=0.38.0 | Card payments. Declared optional |
Stripe is an optional peer, so Mobile Money works without it
Leave @stripe/stripe-react-native out and the package installs cleanly and takes Mobile Money
payments. Only the card tab needs it, and it needs StripeProvider at the root of your app to
work at all.
npm install @stripe/stripe-react-nativeGet a token from your server
Nothing in this package talks to /payments, and it holds no secret key. Your backend creates the
payment and returns the authorization_token.
const response = await fetch('https://api.yourshop.com/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId }),
});
const { authorization_token: token } = await response.json();Show the sheet
usePayment gives you a function that opens the sheet and a promise that resolves with the
outcome.
import { WajubProvider, usePayment } from '@wajub/react-native';
function Checkout({ token }: { token: string }) {
const { present, PaymentSheet } = usePayment();
const pay = async () => {
const result = await present({ sessionToken: token });
if ('cancelled' in result) return;
switch (result.status) {
case 'complete':
navigation.navigate('OrderPaid', { ref: result.transaction.reference });
break;
case 'processing':
setInstruction(result.instruction ?? 'Approve the payment on your phone.');
break;
case 'requires_action':
// The sheet already opened the browser for you.
break;
case 'failed':
Alert.alert('Payment failed', result.error.message);
break;
}
};
return (
<>
<Button title="Pay" onPress={pay} />
{PaymentSheet}
</>
);
}
export default function App() {
return (
<WajubProvider>
<Checkout token={token} />
</WajubProvider>
);
}The PaymentSheet from usePayment is an element, render it
usePayment() returns { present, PaymentSheet } where PaymentSheet is already a rendered
element, or null. Put {PaymentSheet} in your JSX. Writing <PaymentSheet /> throws, because
it is not a component.
The package also exports a PaymentSheet at the top level, and that one is a component,
taking visible, session, onDismiss and onResult. Two different things share the name.
Use the hook's unless you are driving the modal yourself.
WajubProvider is optional. Leave it out and usePayment falls back to the default factory, so
the hook works anywhere. Add it when you want one place to swap the session factory in tests.
The four outcomes
PaymentResult is a discriminated union on status, so a switch narrows each branch.
status | What happened |
|---|---|
complete | Settled. transaction carries the reference |
processing | Sent to the operator. instruction is the text to show the payer |
requires_action | 3DS or a bank redirect. action_url is already opened for you |
failed | error carries code, decline_code and retryable |
present can also resolve with { cancelled: true }, which is why the guard comes before the
switch.
Processing is the normal Mobile Money outcome
A Mobile Money payment almost never returns complete from the sheet. The payer still has to
approve on their handset, so you get processing with an instruction such as dialling a USSD
code. Show that text, then watch the status or wait on your webhook.
Your own screens instead of the sheet
createSession returns the same object the sheet uses. Reach for it when the payment has to sit
inside a design you already have.
import { createSession } from '@wajub/react-native';
const session = createSession(token);
const data = await session.loadSession();
// data.transaction.amount, data.transaction.currency, data.branding, data.locale
const operators = data.channels.filter(
(c) => c.type.toLowerCase() === 'mobile_money' || c.type.toLowerCase() === 'mobile',
);
const result = await session.payMobileMoney({
channel_slug: operators[0].slug,
phone: '+237670000000',
country: 'CM',
});| Method | What it does |
|---|---|
loadSession(forceRefresh?) | Amount, currency, channels, branding, locale. Cached after the first call |
getSdkConfig() | Per channel provider settings, including the Stripe publishable key |
payMobileMoney(input) | Sends the push to the operator |
payCard(slug, paymentMethodId, name?) | Processes a Stripe PaymentMethod you already created |
process(channel, data) | The raw escape hatch for any channel |
handleRedirectAction(result) | Opens a 3DS or bank URL in the system browser |
watchStatus(onUpdate, intervalMs?) | Subscribes, and returns the unsubscribe function |
cancel() | Abandons the session, returns the redirect URL |
cardChannelSlug() | The card channel of the loaded session, if there is one |
Input fields are snake_case, unlike the method names
payMobileMoney takes { channel_slug, phone, country }, and result.error.decline_code and
result.action_url keep the wire spelling too. Only the methods are camelCase. That is
deliberate, the payloads are the API's own shapes.
Follow the outcome
useEffect(() => {
const unsubscribe = session.watchStatus((result) => {
if (result.status === 'complete') setOrderState('paid');
if (result.status === 'failed') setOrderState('failed');
});
return unsubscribe;
}, [session]);It subscribes over Pusher when the session carries realtime settings and falls back to polling every five seconds otherwise. You do not choose.
Cards without the sheet
The publishable key comes from the session, not from your code, because it differs between sandbox and live and between merchants.
import { StripeProvider } from '@stripe/stripe-react-native';
import { createSession, StripeCardSection } from '@wajub/react-native';
function CardCheckout({ token }: { token: string }) {
const session = useMemo(() => createSession(token), [token]);
const [publishableKey, setKey] = useState<string | null>(null);
const [name, setName] = useState('');
useEffect(() => {
session.loadSession().then(async () => {
const config = await session.getSdkConfig();
const slug = session.cardChannelSlug();
setKey(slug ? (config.channels[slug]?.publishable_key ?? null) : null);
});
}, [session]);
if (!publishableKey) return <ActivityIndicator />;
return (
<StripeProvider publishableKey={publishableKey}>
<StripeCardSection
cardholderName={name}
onCardholderNameChange={setName}
submitting={false}
error={null}
onPay={async (paymentMethodId) => {
const slug = session.cardChannelSlug()!;
const result = await session.payCard(slug, paymentMethodId, name);
await session.handleRedirectAction(result);
}}
/>
</StripeProvider>
);
}handleRedirectAction opens the system browser, not a WebView
It leaves your app for Chrome or Safari and comes back through your callback URL. That is
deliberate: a 3DS challenge inside a WebView is refused by a growing number of banks. Make sure
the callback on POST /payments points at a deep link your app handles.
Errors
Anything the session throws is a WajubError. The failed result carries the same shape as a
plain object.
import { WajubError } from '@wajub/react-native';
try {
const result = await session.payMobileMoney(input);
if (result.status === 'failed') {
// result.error is a plain WajubErrorShape, not a thrown instance
showRetry(result.error.message, result.error.retryable);
}
} catch (error) {
if (error instanceof WajubError && error.type === 'rate_limit_error') {
showRetryIn(error.retry_after_seconds ?? 60);
return;
}
throw error;
}| Field | What it is for |
|---|---|
type | One of api_error, authentication_error, invalid_request_error, payment_error, rate_limit_error |
code | The machine readable reason |
decline_code | Present on a 402, why the provider refused |
retryable | Whether offering another attempt makes sense |
param | The field at fault on a validation error |
correlation_id | Quote this to support |
retry_after_seconds | The wait on a rate limit |
Every error field is snake_case here, and camelCase in Flutter
WajubError in this package keeps the wire spelling throughout, so it is
error.retry_after_seconds and error.decline_code. The Flutter SDK converts the same fields to
retryAfterSeconds and declineCode. Porting a handler between the two means renaming them.
Testing
The API origin is a constant, so there is no base URL to redirect. Point your backend at a sandbox key instead and the token it mints puts the whole flow in sandbox, sheet included. Numbers that produce each outcome are on Test scenarios.
For a unit test, createSession takes a second argument, a PayClient, which is the seam to stub.