Skip to content

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, and the payer never sees a WebView.

@wajub/react-native

Stable · GA

npm

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

It ships TypeScript sources and declares pusher-js as its only real dependency. Three peers are yours to provide.

PeerRangeNeeded for
react>=18Always
react-native>=0.74Always
@stripe/stripe-react-native>=0.38.0Card payments. Declared optional

Stripe is an optional peer, so Mobile Money works without it

Leave @stripe/stripe-react-native out and the package installs cleanly and takes Mobile Money payments. Only the card tab needs it, and it needs StripeProvider at the root of your app to work at all.

Add Stripe when you take cards
npm install @stripe/stripe-react-native

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.

Your own endpoint, your own client
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.

The whole integration
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 'processing':
        setInstruction(result.instruction ?? 'Approve the payment on your phone.');
        break;
      case 'requires_action':
        // The sheet already opened the browser for you.
        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>
  );
}

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 four outcomes

PaymentResult is a discriminated union on status, so a switch narrows each branch.

statusWhat happened
completeSettled. transaction carries the reference
processingSent to the operator. instruction is the text to show the payer
requires_action3DS or a bank redirect. action_url is already opened for you
failederror carries code, decline_code and retryable

present can also resolve with { cancelled: true }, which is why the guard comes before the switch.

Processing is the normal Mobile Money outcome

A Mobile Money payment almost never returns complete from the sheet. The payer still has to approve on their handset, so you get processing with an instruction such as dialling a USSD code. 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.

Read the session, then pay
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',
});
MethodWhat 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(slug, paymentMethodId, name?)Processes a Stripe PaymentMethod you already created
process(channel, data)The raw escape hatch for any channel
handleRedirectAction(result)Opens a 3DS or bank 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

Follow the outcome

watchStatus returns its own unsubscribe
useEffect(() => {
  const unsubscribe = session.watchStatus((result) => {
    if (result.status === 'complete') setOrderState('paid');
    if (result.status === 'failed') setOrderState('failed');
  });

  return unsubscribe;
}, [session]);

It subscribes over Pusher when the session carries realtime settings and falls back to polling every five seconds otherwise. 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.

StripeProvider at the root, key from the session
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>
  );
}

Errors

Anything the session throws is a WajubError. The failed result carries the same shape as a plain object.

Two places the same error appears
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;
}
FieldWhat it is for
typeOne of api_error, authentication_error, invalid_request_error, payment_error, rate_limit_error
codeThe machine readable reason
decline_codePresent on a 402, why the provider refused
retryableWhether offering another attempt makes sense
paramThe field at fault on a validation error
correlation_idQuote this to support
retry_after_secondsThe wait on a rate limit

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.

What did you think of this content?