React Native Mobile Money
A mobile checkout with no key in the bundle, and a return that actually works.
A mobile app is a public place. Anything shipped in the binary can be read, so the checkout has to be built around a backend that holds the key and decides the price.
This recipe shows the two ways to do that in React Native: the native payment sheet, which is what
you want, and the hosted page in a WebView, which is what you fall back to. Both share the same
server half.
The rule the whole recipe follows
No key of any kind belongs in the bundle
A secret key (sk.) in an app is a compromised key: never ship one, and when Wajub detects it
used from a browser it refuses the request and emails the key owner a security alert. A public key (pk.)
would technically work for creating a payment, and still must not be used here for a different
reason: whoever holds it can create payments with any amount they like. Your backend creates the
payment, and the app never talks to api.wajub.com directly.
1. The backend endpoint, shared by both approaches
One route, and it reads the amount from your own database. An amount that arrives from a phone is a suggestion, not an instruction.
import express from 'express';
import { Wajub } from '@wajub/node';
// Your own order store and session check.
import { orders, requireAuth } from './app-helpers';
const app = express();
app.use(express.json());
const wajub = new Wajub({ apiKey: process.env.WAJUB_API_KEY! });
app.post('/api/checkout', requireAuth, async (req, res) => {
const order = await orders.findForUser(req.user.id, req.body.orderId);
if (!order) return res.status(404).json({ error: 'Unknown order' });
const payment = await wajub.payments.create(
{
amount: order.amount,
currency: order.currency,
customer: { email: req.user.email, phone: order.phone },
reference: order.id,
description: `Order ${order.id}`,
callback: `${process.env.API_URL}/orders/${order.id}/return`,
},
{ idempotencyKey: order.id },
);
await orders.update(order.id, { payment_id: payment.id });
res.json({
sessionToken: payment.authorization_token,
authorizationUrl: payment.authorization_url,
returnUrl: `${process.env.API_URL}/orders/${order.id}/return`,
});
});The response carries both. sessionToken drives the native sheet, authorizationUrl drives the
WebView, and returnUrl is what the app watches for. Neither of the first two can list your
payments, refund anything, or touch another customer: they are scoped to this one payment and expire
with it.
`callback` has to be an `https` URL
A custom scheme such as myapp://payment/return is rejected at creation with 422, because the
field is validated as a real URL. Point callback at an https route on your own backend, and let
that route redirect into the app if you want a deep link. Everything below is built that way.
2. The native sheet, which is the one to build
@wajub/react-native presents a payment sheet natively. The app never renders a form, never handles
a phone number, and never sees a key.
npm install @wajub/react-native @react-native-community/netinfoNetInfo is not optional: the package's realtime client needs it. Stripe is only needed to take
cards through Stripe, from @wajub/react-native 1.4.1; up to 1.4.0 the entry point imported it,
so add @stripe/stripe-react-native on those versions even for Mobile Money only. On iOS, run
pod install in ios/ afterwards.
Wrap the tree once.
import { WajubProvider } from '@wajub/react-native';
export default function App() {
return (
<WajubProvider>
<RootNavigator />
</WajubProvider>
);
}Then the checkout screen is a button and a hook.
import { useState } from 'react';
import { Button, Text, View } from 'react-native';
import { usePayment } from '@wajub/react-native';
// Your HTTP client for your own backend.
import { api } from '../lib/api';
export function CheckoutScreen({ order }) {
const { present, PaymentSheet } = usePayment();
const [busy, setBusy] = useState(false);
async function pay() {
setBusy(true);
try {
const { sessionToken } = await api.post('/api/checkout', { orderId: order.id });
const result = await present({ sessionToken });
if ('cancelled' in result) return;
if (result.status === 'failed') {
showError(result.error.message);
return;
}
navigation.navigate('Confirming', { orderId: order.id });
} finally {
setBusy(false);
}
}
return (
<View>
<Text>
{order.amount} {order.currency}
</Text>
<Button title="Pay with Mobile Money" onPress={pay} disabled={busy} />
{PaymentSheet}
</View>
);
}Note where everything but failed goes. Not to a confirmation screen, but to a Confirming
screen. complete means the sheet closed cleanly, which is not the same as the money having
arrived. processing means the operator is still waiting for the PIN. requires_action means
the SDK has already opened the page that finishes the payment in the system browser (a redirect, a
confirmation page or a 3-D Secure check), or that the payer approves on their phone (push_approval). In all three
cases the outcome arrives later, through your webhook.
3. The waiting screen nobody builds, and everybody needs
A Mobile Money payment is confirmed by a human typing a PIN. The sheet can close while the operator
is still deciding, so the app very often returns to a payment that is still processing.
Ask your own backend, not Wajub, and ask it a few times rather than continuously.
import { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import { api } from '../lib/api';
export function ConfirmingScreen({ route, navigation }) {
const { orderId } = route.params;
const [status, setStatus] = useState('pending');
useEffect(() => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
const { status } = await api.get(`/api/orders/${orderId}`);
setStatus(status);
if (status !== 'pending' || attempts >= 20) {
clearInterval(timer);
if (status === 'fulfilled') navigation.replace('Success', { orderId });
}
}, 3000);
return () => clearInterval(timer);
}, [orderId]);
return (
<View>
<ActivityIndicator />
<Text>
Check your phone. Confirm the prompt with your PIN and this screen will update on its own.
</Text>
<Text>You can close the app. We will send you a confirmation either way.</Text>
</View>
);
}Twenty attempts at three seconds is a minute of polling, then the screen stops and tells the customer they will be emailed. That last sentence is what stops a support ticket: the order is not lost, it is waiting on somebody's handset.
The app never decides an order is paid
Polling your own backend is a user-experience convenience. The order becomes fulfilled in one
place only: the payment.succeeded webhook on your server, as built in
Accept a payment.
4. The WebView fallback
If you cannot install the native module, open the hosted page in a WebView. It works, and the only
subtlety is how you detect the end.
The page ends by navigating to the callback you set, which is an https URL on your own backend.
Watch for it in onNavigationStateChange and close the view.
import { WebView } from 'react-native-webview';
export function HostedCheckoutScreen({ route, navigation }) {
const { authorizationUrl, returnUrl, orderId } = route.params;
function onNavigationStateChange({ url }) {
if (!url.startsWith(returnUrl)) return;
navigation.replace('Confirming', { orderId });
}
return (
<WebView
source={{ uri: authorizationUrl }}
onNavigationStateChange={onNavigationStateChange}
startInLoadingState
/>
);
}Compare against returnUrl with startsWith, because Wajub appends its own query parameters to that
address. Their names are a trap worth knowing: reference holds the Wajub payment id, trxref holds
the reference you sent, and status is whatever the browser was told. Read none of them, and send the
customer to the same waiting screen instead.
5. If you want a deep link back into the app
Some flows leave the app entirely, for instance when the operator opens its own application to confirm. A deep link brings the customer home.
Do not point callback at your scheme. Point it at your backend, and let that route redirect.
app.get('/orders/:id/return', async (req, res) => {
const order = await orders.find(req.params.id);
if (!order) return res.status(404).send('Unknown order');
if (req.get('user-agent')?.includes('MyStoreApp')) {
return res.redirect(`mystore://orders/${order.id}`);
}
res.render('order-status', { order });
});On the app side, listen for the scheme and parse it with Linking.parse. The global URL class does
not reliably parse a custom scheme under Hermes, which is why the obvious version of this code fails
on device and works in a simulator.
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
export function useOrderDeepLink(onOrder: (orderId: string) => void) {
useEffect(() => {
function handle(url: string | null) {
if (!url) return;
const { path } = Linking.parse(url);
const match = path?.match(/^orders\/(.+)$/);
if (match) onOrder(match[1]);
}
const subscription = Linking.addEventListener('url', ({ url }) => handle(url));
Linking.getInitialURL().then(handle);
return () => subscription.remove();
}, [onOrder]);
}Declare the scheme so the operating system routes it to you.
6. Test it on a device
Sandbox decides the outcome from the last six digits of the number, which lets you walk every screen above without an operator.
| Number | What the app should do |
|---|---|
+237670000000 | Sheet closes, waiting screen resolves, success screen appears |
+237670000001 | Insufficient funds, error shown, order still retryable |
+237670000003 | Operator timeout, the payment ends expired and the waiting screen stops on the failure |
+237670000004 | Customer declines, nothing charged, back to the cart |
The third row is the one to spend time on. A timeout is not silence: it reaches your server as
payment.failed a few seconds later, so the waiting screen has to leave on a failure too.
Point the app at a tunnel, not at localhost
A device cannot reach localhost on your machine. Expose your backend with a tunnel, set API_URL
to that address so callback is reachable, and forward Wajub's events with
wajub listen --forward-to localhost:3001/webhooks/wajub.
Related pages
- React Native SDKThe provider, the hook and the payment sheet in full.
- Mobile Money checkoutWhat happens on the handset, and every way it fails.
- Accept a paymentThe backend half this recipe leans on.
- Sessions & securityWhat a session token can and cannot do.
- Hosted CheckoutEverything the page in the WebView decides on its own.