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. 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 only ever holds the session token.
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/netinfo @react-native-async-storage/async-storageNetInfo and AsyncStorage are required. @stripe/stripe-react-native is only needed to take cards
through Stripe. On iOS, run pod install in ios/ afterwards. This recipe follows
@wajub/react-native 1.5.
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 });
switch (result.status) {
case 'cancelled':
return;
case 'failed':
showError(result.error.message);
return;
default:
// complete or processing: your webhook decides.
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 complete and processing go. Not to a confirmation screen, but to a Confirming
screen. The sheet itself shows the approval screen, the confirmation steps and the provider's
pages, and resolves only when it closes. complete means it closed on its success screen, which is
not the same as your backend having recorded the payment. processing means the payer closed it
while the operator was still waiting for the PIN. In both cases the outcome reaches your backend
through the webhook. cancelled means the payer left before paying: the next press of the button
creates a new payment. The React Native page has every result.
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 adds query parameters to that address.
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 has a test number for each outcome, listed on Test scenarios, which lets you walk every screen above without an operator.
| Scenario | What the app should do |
|---|---|
| Success | Sheet closes, waiting screen resolves, success screen appears |
| Insufficient funds | Error shown, order still retryable |
| Operator timeout | The payment ends and the waiting screen stops on the failure |
| Customer declines | Nothing charged, back to the cart |
The third row is the one to spend time on. A timeout is not silence: your webhook hears that the payment ended, 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 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.