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 when Stripe carries the
card, 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-native @react-native-community/netinfocd ios && pod installIt ships TypeScript sources and declares pusher-js as its only real dependency. The rest is yours
to provide.
| Module | Range | Needed for |
|---|---|---|
react | >=18 | Always |
react-native | >=0.74 | Always |
@react-native-community/netinfo | >=11.0.0 | Always. pusher-js requires it in a React Native bundle; the package declares it as a peer from 1.4.1 |
@stripe/stripe-react-native | >=0.38.0 | Cards through Stripe only, with StripeProvider at the root of your app |
Stripe is optional from 1.4.1
From 1.4.1 the package loads @stripe/stripe-react-native only if your app installed it, so an
app that only takes Mobile Money bundles without it, and the sheet simply offers no Stripe card
form. Up to 1.4.0 the entry point imported it and Metro stopped on Unable to resolve module
without it: on those versions, install it in every case.
Get 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 'requires_action':
if (result.action === 'push_approval') {
setInstruction(
result.transaction.processing_context?.payer_instruction ??
'Approve the payment on your phone.',
);
}
// Any other action is a page the sheet already opened in the browser.
break;
case 'processing':
// Accepted, not confirmed yet. Watch the status or wait on your webhook.
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 sheet speaks English and French. present({ sessionToken, locale: 'fr' }) chooses; without
locale the sheet follows the payment's own language (from 1.4.1; earlier versions are English
only).
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 |
requires_action | Something is left for the payer to do, and action says what. push_approval: approve on the handset, nothing to open. redirect, confirm, confirm_3ds or client_session: action_url is already opened for you |
processing | Accepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer |
failed | error carries code, decline_code and retryable |
present can also resolve with { cancelled: true }, which is why the guard comes before the
switch. That is the payer closing the sheet. From 1.4.1, when they had not submitted anything, the
payment is also cancelled on Wajub, as the hosted checkout does. When an attempt was under way it
is left alone: a Mobile Money approval can still land after the sheet is gone, so follow it with
watchStatus() or your webhook.
Mobile Money comes back as an action to approve
A Mobile Money payment almost never returns complete from the sheet. The payer still has to
approve on their handset, so you get requires_action with action set to push_approval, and
the instruction to show them, dialling a USSD code for instance, in
transaction.processing_context?.payer_instruction. 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(channelSlug, paymentMethodId, cardholderName?) | Processes a Stripe PaymentMethod you already created |
payCardHosted(channel, options?) | A card on a provider's own checkout, in one call: opens it, waits for the payer, verifies |
startClientSession(channel, options?) | The first half of the same. Returns a requires_action result whose client_session carries the page to open |
completeClientSession(id, channel?) | The second half, once the payer is back |
payCardHostedRedirect(channel, billing?) | Starts a card on a provider's own page, with the contact fields the channel requires |
payWallet(channel) | A one-tap wallet such as Djamo: returns the redirect that opens its app |
process(channel, data) | The raw escape hatch for any channel |
handleRedirectAction(result) | Opens a 3DS, bank or provider 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 to realtime updates when they are available and falls back to polling every five seconds otherwise. From 1.4.1 the polling runs in both cases, next to the socket, so a missed event costs at most one interval. 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>
);
}That is the Stripe case. When the card channel's entry in getSdkConfig() has client_session
set, or sdk equal to hosted_redirect, the provider collects the card on its own page and your
code only opens it.
const config = await session.getSdkConfig();
const slug = session.cardChannelSlug()!;
const channel = config.channels[slug];
if (channel?.client_session) {
// Opens the provider's checkout, waits for the payer to come back, then verifies.
const result = await session.payCardHosted(slug, { email: '[email protected]' });
// 'complete', or 'processing' until the provider confirms.
} else if (channel?.sdk === 'hosted_redirect') {
const result = await session.payCardHostedRedirect(slug, {
email: '[email protected]', // every field in channel.required_fields
});
await session.handleRedirectAction(result);
}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, written as an https
App Link (Android) or Universal Link (iOS): the API refuses any other scheme, so a custom one
such as myapp://paid answers 422.
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.