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 when Stripe carries the card, 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 @react-native-community/netinfo
iOS: link the native modules
cd ios && pod install

It ships TypeScript sources and declares pusher-js as its only real dependency. The rest is yours to provide.

ModuleRangeNeeded for
react>=18Always
react-native>=0.74Always
@react-native-community/netinfo>=11.0.0Always. pusher-js requires it in a React Native bundle; the package declares it as a peer from 1.4.1
@stripe/stripe-react-native>=0.38.0Cards through Stripe only, with StripeProvider at the root of your app

Stripe is optional from 1.4.1

From 1.4.1 the package loads @stripe/stripe-react-native only if your app installed it, so an app that only takes Mobile Money bundles without it, and the sheet simply offers no Stripe card form. Up to 1.4.0 the entry point imported it and Metro stopped on Unable to resolve module without it: on those versions, install it in every case.

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 'requires_action':
        if (result.action === 'push_approval') {
          setInstruction(
            result.transaction.processing_context?.payer_instruction ??
              'Approve the payment on your phone.',
          );
        }
        // Any other action is a page the sheet already opened in the browser.
        break;
      case 'processing':
        // Accepted, not confirmed yet. Watch the status or wait on your webhook.
        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 sheet speaks English and French. present({ sessionToken, locale: 'fr' }) chooses; without locale the sheet follows the payment's own language (from 1.4.1; earlier versions are English only).

The four outcomes

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

statusWhat happened
completeSettled. transaction carries the reference
requires_actionSomething is left for the payer to do, and action says what. push_approval: approve on the handset, nothing to open. redirect, confirm, confirm_3ds or client_session: action_url is already opened for you
processingAccepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer
failederror carries code, decline_code and retryable

present can also resolve with { cancelled: true }, which is why the guard comes before the switch. That is the payer closing the sheet. From 1.4.1, when they had not submitted anything, the payment is also cancelled on Wajub, as the hosted checkout does. When an attempt was under way it is left alone: a Mobile Money approval can still land after the sheet is gone, so follow it with watchStatus() or your webhook.

Mobile Money comes back as an action to approve

A Mobile Money payment almost never returns complete from the sheet. The payer still has to approve on their handset, so you get requires_action with action set to push_approval, and the instruction to show them, dialling a USSD code for instance, in transaction.processing_context?.payer_instruction. 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(channelSlug, paymentMethodId, cardholderName?)Processes a Stripe PaymentMethod you already created
payCardHosted(channel, options?)A card on a provider's own checkout, in one call: opens it, waits for the payer, verifies
startClientSession(channel, options?)The first half of the same. Returns a requires_action result whose client_session carries the page to open
completeClientSession(id, channel?)The second half, once the payer is back
payCardHostedRedirect(channel, billing?)Starts a card on a provider's own page, with the contact fields the channel requires
payWallet(channel)A one-tap wallet such as Djamo: returns the redirect that opens its app
process(channel, data)The raw escape hatch for any channel
handleRedirectAction(result)Opens a 3DS, bank or provider 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 to realtime updates when they are available and falls back to polling every five seconds otherwise. From 1.4.1 the polling runs in both cases, next to the socket, so a missed event costs at most one interval. 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>
  );
}

That is the Stripe case. When the card channel's entry in getSdkConfig() has client_session set, or sdk equal to hosted_redirect, the provider collects the card on its own page and your code only opens it.

A card the provider collects itself
const config = await session.getSdkConfig();
const slug = session.cardChannelSlug()!;
const channel = config.channels[slug];

if (channel?.client_session) {
  // Opens the provider's checkout, waits for the payer to come back, then verifies.
  const result = await session.payCardHosted(slug, { email: '[email protected]' });
  // 'complete', or 'processing' until the provider confirms.
} else if (channel?.sdk === 'hosted_redirect') {
  const result = await session.payCardHostedRedirect(slug, {
    email: '[email protected]', // every field in channel.required_fields
  });
  await session.handleRedirectAction(result);
}

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?