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 @react-native-async-storage/async-storage

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

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 });

      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.

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 adds query parameters to that address. 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 has a test number for each outcome, listed on Test scenarios, which lets you walk every screen above without an operator.

ScenarioWhat the app should do
SuccessSheet closes, waiting screen resolves, success screen appears
Insufficient fundsError shown, order still retryable
Operator timeoutThe payment ends and the waiting screen stops on the failure
Customer declinesNothing 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.

What did you think of this content?