Skip to content

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

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.

server/checkout.ts
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.

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

NetInfo 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.

App.tsx
import { WajubProvider } from '@wajub/react-native';

export default function App() {
  return (
    <WajubProvider>
      <RootNavigator />
    </WajubProvider>
  );
}

Then the checkout screen is a button and a hook.

screens/CheckoutScreen.tsx
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.

screens/ConfirmingScreen.tsx
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.

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.

screens/HostedCheckoutScreen.tsx
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.

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.

server/return.ts
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.

hooks/useOrderDeepLink.ts
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.

app.json
{
"expo": {
"scheme": "mystore"
}
}

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.

NumberWhat the app should do
+237670000000Sheet closes, waiting screen resolves, success screen appears
+237670000001Insufficient funds, error shown, order still retryable
+237670000003Operator timeout, the payment ends expired and the waiting screen stops on the failure
+237670000004Customer 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.

What did you think of this content?