React Native SDK

@qasimha/react-native-sdk brings the full checkout into your app without building any of it. The SDK presents the Qasimha hosted checkout in the system browser sheet (SFSafariViewController or Chrome Custom Tab), watches for the customer's return to your app, and resolves with a typed result your code can act on.

The flow stays server-driven: your server creates the payment session and hands the app a paymentUrl, which the SDK opens. Alongside checkout, the module ships QasimhaPromo, a ready-made instalment summary you can drop into product, cart, and checkout screens.

Security stays where it belongs. The app holds no secret key and makes no API call other than the publishable-key promo request, so nothing sensitive lives in your binary. And whatever result the SDK reports, the outcome is always confirmed by your server, same as a web integration.

Requirements

ItemRequirement
React Native0.72 or later
React18 or 19
AndroidminSdk 24, AndroidX, Kotlin 1.9+, a browser with Custom Tabs (falls back to the default browser)
iOS13.4 or later, Swift 5.9. ASWebAuthenticationSession with an https return URL needs iOS 17.4.
Optionalreact-native-svg 13+ for the riyal symbol and logo in QasimhaPromo
ServerA Qasimha server SDK that creates the checkout session. See Your server endpoint.

Install

npm install @qasimha/react-native-sdk
npm install react-native-svg        # optional, recommended for QasimhaPromo
cd ios && pod install

Autolinking handles the native setup on both platforms. Installing the package registers the Android library and the iOS pod automatically. Rebuild the app after installing; a JavaScript reload won't pick up the native code.

Under the hood, the module is a NativeModules bridge module. On the New Architecture it runs through React Native's interop layer, which is enabled by default from React Native 0.74, so no extra configuration is needed. JavaScript locates the module through TurboModuleRegistry with a NativeModules fallback, which means the SDK works in bridge, bridgeless, and New Architecture apps without you touching any of it.

Configuration

  • No secret key in the app. Your server holds sk_test_... or sk_live_..., creates the session and returns paymentUrl and the merchantUrl it used. The app passes both to startCheckout unchanged. There is no environment setting: paymentUrl already points at the environment your server used.
  • Publishable key, for the promo only. QasimhaPromo needs pk_test_... (UAT, calls https://api-uat.qasimha.com) or pk_live_... (production, calls https://api.qasimha.com). Have your backend return it, so the app always matches the backend's environment, or set it per build with your usual environment-config tool.

Give UAT and production their own build configs. Build each environment as a separate Android flavour or iOS build configuration, and give each its own return scheme (such as myshop-uat://) or link host. That way a build can only ever be returned to itself, and a UAT checkout can never land in your production app.

Keep the pairing on the server: when an app build requests a payment session, your server must create it with that build's return URLs. The simplest approach is to send the build's environment or scheme with the session request and have the server look up the matching URLs.

Set up the return into your app

Use App Links (Android) and Universal Links (iOS) for the return. They're the secure choice because the OS verifies that your app owns the link host, so no other app can intercept the checkout return.

Custom schemes (myshop://) are simpler to set up since they need no domain verification, but any app can register the same scheme and claim your return. If you use one anyway, keep it unique to your app and treat it as a convenience for development, not the return path for a production release.

  1. Declare the custom return scheme the module's QasimhaReturnActivity listens on, in android/app/build.gradle. The build fails without it; set any unused value if you only use App Links.
android {
  defaultConfig {
    manifestPlaceholders = [qasimhaReturnScheme: "myshop"]
  }
}

myshop://qasimha/return then reaches your app. With flavours, set it per flavour:

android {
  flavorDimensions "environment"
  productFlavors {
    uat {
      dimension "environment"
      applicationIdSuffix ".uat"
      manifestPlaceholders = [qasimhaReturnScheme: "myshop-uat"]
    }
    production {
      dimension "environment"
      manifestPlaceholders = [qasimhaReturnScheme: "myshop"]
    }
  }
}
  1. For App Links (https return URLs), add a verified intent filter to your main activity and publish https://shop.example/.well-known/assetlinks.json with your release signing certificate's SHA-256 fingerprint:
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="https" android:host="shop.example" android:pathPrefix="/qasimha/" />
</intent-filter>
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.shop",
    "sha256_cert_fingerprints": ["AB:CD:EF:..."]
  }
}]
  1. Keep android:launchMode="singleTask" on the main activity (the React Native template default). The return reaches your app through onNewIntent and React Native's Linking, which also closes the Custom Tab.

Then pass every incoming link to handleReturn, once, near the root of your app:

import { useEffect } from 'react';
import { Linking } from 'react-native';
import { handleReturn } from '@qasimha/react-native-sdk';

export function useQasimhaReturn() {
  useEffect(() => {
    const subscription = Linking.addEventListener('url', ({ url }) => {
      if (!handleReturn(url)) routeMyOwnDeepLink(url);
    });
    Linking.getInitialURL().then((url) => url && handleReturn(url));
    return () => subscription.remove();
  }, []);
}

handleReturn(url) returns true when the URL matched the pending checkout (the checkout resolves and the sheet closes), and false otherwise, including when no checkout is pending. Keep your own deep-link handling for the false case. With presentation: 'webAuthenticationSession' on iOS, the return does not need this wiring.

Start a checkout

Get paymentUrl and merchantUrl from your server and pass the same three return URLs your server sent to POST /api/v1/pay/session/create.

import { startCheckout } from '@qasimha/react-native-sdk';

async function pay(paymentUrl: string) {
  const result = await startCheckout({
    paymentUrl,
    merchantUrl: {
      success: 'https://shop.example/qasimha/return',
      failure: 'https://shop.example/qasimha/return',
      cancel: 'https://shop.example/qasimha/cancel',
    },
    language: 'ar',
  });

  switch (result.type) {
    case 'success':
      await confirmOnServer(result.orderId);
      break;
    case 'failure':
      showDeclined(result.status);
      break;
    case 'cancelled':
      break;
  }
}

API reference

startCheckout(options): Promise<CheckoutResult>

OptionTypeDefaultNotes
paymentUrlstringrequiredThe paymentUrl from session creation. Absolute https, no credentials.
merchantUrl{ success, failure, cancel }requiredAll three required. Each absolute, at most 1024 characters, no fragment; https with a host, http on localhost / 127.0.0.1, or a custom app scheme of at least 3 characters (javascript, data, file, blob, vbscript refused).
presentation'systemBrowser' | 'webAuthenticationSession''systemBrowser'systemBrowser: Android Custom Tabs, iOS SFSafariViewController. webAuthenticationSession: iOS only (Android uses Custom Tabs); ephemeral, no shared cookies, needs no Linking wiring; the three URLs must share one custom scheme, or one https host and path on iOS 17.4+.
language'ar' | 'en'noneSent as Accept-Language to the Android Custom Tab; not used on iOS. The hosted checkout follows the shopper's browser language and offers an in-page toggle; this hint does not override it today.

Only one checkout runs at a time. When the shopper dismisses the sheet, startCheckout waits 300 ms before it resolves cancelled, so a return URL still in flight through Linking wins.

handleReturn(url: string): boolean

Call it from Linking's url event and with getInitialURL(). true when the URL matched the pending checkout's return URLs; false otherwise.

isCheckoutInProgress(): boolean

true while a checkout is presented. Use it to disable your pay button.

parseReturnUrl(url, merchantUrl) and validateCheckoutOptions(options)

The pure functions behind the module, for tests and for apps that route the return themselves. parseReturnUrl(url: string, merchantUrl: MerchantUrl): CheckoutResult | null returns null when url is not one of the merchant URLs. validateCheckoutOptions(options) throws QasimhaCheckoutError with INVALID_OPTIONS, or returns the normalised options.

CheckoutResult

type CheckoutResult =
  | { type: 'success'; orderId: string; orderReferenceId?: string }
  | { type: 'failure'; status: string; orderId?: string; orderReferenceId?: string }
  | { type: 'cancelled' };
On the return URLResult
status=confirmed with an orderIdsuccess
status=confirmed without an orderIdfailure, status unknown
status=declined, expired or failedfailure with that status
status=cancelled (or canceled)cancelled
no status on the cancel URLcancelled
no status on the success or failure URLfailure, status unknown
any other statusfailure with that status, lower-cased
the shopper dismissed the sheetcancelled

The URL matches when its scheme, host (with port) and path prefix, at a segment boundary, match one of your merchant URLs, checked in the order success, failure, cancel. Give the cancel URL a dedicated path: a return URL with an empty path matches every link on that host.

QasimhaCheckoutError

startCheckout rejects with QasimhaCheckoutError (code, message, and field for INVALID_OPTIONS):

codeWhen
INVALID_OPTIONSAn option breaks the rules above. field names it (paymentUrl, merchantUrl.success, presentation, language, ...).
IN_PROGRESSA checkout is already open.
NATIVE_MODULE_MISSINGThe native module is not linked.
INVALID_URLThe native side refused the URL, or webAuthenticationSession had no callback scheme or host.
NO_ACTIVITYAndroid: no foreground activity to present from.
NO_BROWSERAndroid: no browser on the device.
HOST_DESTROYEDAndroid: the activity was destroyed while the checkout was open.
NO_VIEW_CONTROLLERiOS: no view controller to present from.
UNSUPPORTEDiOS below 17.4 with webAuthenticationSession and https return URLs.
PRESENTATION_FAILEDiOS: the authentication session could not start.
NATIVE_ERRORAny other native failure.

Handle the result and confirm on your server

A return URL is navigation, not proof. Before showing "paid" or fulfilling, your server reads the order with its secret key (orders.get in the server SDKs, GET /api/v1/merchant/orders/{orderId}) and acts on the status it returns. Your server also receives the webhook, the durable record. Never change an order from the return URL parameters alone.

Keep the orderId your server returned when it created the session. cancelled carries no id, and a payment can complete even when the return link was lost, so confirm with the session's orderId when the result has none. A cancelled order stays AwaitingCheckout and expires on its own; your server may cancel it.

Promo component

import { QasimhaPromo } from '@qasimha/react-native-sdk';

<QasimhaPromo amount={1200} publicKey={publishableKey} lang="ar" textStyle={{ fontSize: 16, color: '#1f2937' }} />

It renders the Qasimha logo and "Split into 4 monthly payments of [symbol] 300", with "Interest-free · Sharia-compliant" under it for Sharia-compliant plans.

PropTypeDefaultNotes
amountnumber | stringrequiredPositive, at most two decimals, up to 999999999.99. Strings must match ^\d{1,9}(\.\d{1,2})?$.
publicKeystringrequiredpk_test_... or pk_live_....
lang'ar' | 'en''ar'Line language and direction.
apiBaseUrlstringfrom the key prefixpk_test_ to UAT, pk_live_ to production. https; http for localhost / 127.0.0.1 only. For a local test server only.
renderSymbol({ size, color }) => ReactNodenoneYour own currency symbol node (an Image, an icon-font glyph, your SVG).
showLogobooleantrueThe Qasimha logo at the start of the line, 1.45 em high.
onLearnMore() => voidnoneAdds a "Learn more" link (colour PROMO_LINK_COLOR, #b9431f) that opens your own explainer.
onPlan(plan: Plan | null) => voidnoneCalled with the headline plan, or null when nothing renders. Use it to hide a surrounding card.
styleStyleProp<ViewStyle>noneContainer style.
textStyleStyleProp<TextStyle>14 pt, #1f2937Text style. The symbol takes its size (0.82 em) and colour from it.
testIDstring'qasimha-promo'Test id of the container.
  • Calls GET /api/v1/widget/plans?amount=1200.00&currency=SAR&lang=ar with X-Public-Key, credentials: 'omit', a 5-second timeout, no cookies, no storage. It reloads when the amount, key, language or host changes.
  • Fails closed: renders nothing, never a computed number, when the key, amount or apiBaseUrl is invalid, the request fails or times out, the status is not 2xx, the response is not success: true with currency: "SAR" and a valid plan, or the amount is outside minAmount to maxAmount.
  • Place it on product, cart and checkout screens, not in every list row: each mounted instance makes one request.

How the currency symbol is drawn, in order: your renderSymbol; otherwise react-native-svg when it is installed; otherwise an empty box of the symbol's size, hidden from accessibility, so the layout stays stable. The amount is always to the right of the symbol in a left-to-right row, with Latin digits and thousands separators. Screen readers hear the currency name.

Other exports

ExportMeaning
QasimhaLogoThe logo alone. Props: height (default 20), lang, style, testID. Falls back to a text pill without react-native-svg.
RiyalSymbolThe currency symbol alone. Props: size (default 14), color (default #1f2937), style. Without react-native-svg it renders an empty box of the same size.
formatAmount(value: number)1,200, 300.50: Latin digits, thousands separator, decimals only when not zero.
RIYAL_PATHS, RIYAL_VIEWBOX, RIYAL_ASPECT_RATIOThe symbol's vector paths, for your own drawing.
LOGO_PATHS, LOGO_VIEWBOX, LOGO_ASPECT_RATIO, LOGO_PRIMARYThe logo's vector paths and colour.
TypesCheckoutOptions, CheckoutResult, MerchantUrl, Presentation, Language, ReturnKind, QasimhaCheckoutErrorCode, QasimhaPromoProps, RenderSymbol, QasimhaLogoProps, RiyalSymbolProps, Plan, PlansData, NormalizedOptions

Plan has instalments, frequency, instalmentAmount, firstInstalmentAmount, lastInstalmentAmount, feesIncludingVat, totalPayable and shariaCompliant.

Complete example

Your server creates the session and returns paymentUrl and merchantUrl (server endpoint). The app presents it and confirms on the server.

import React, { useState } from 'react';
import { Button, Text, View } from 'react-native';
import {
  QasimhaCheckoutError, QasimhaPromo, isCheckoutInProgress, startCheckout,
  type CheckoutResult, type MerchantUrl,
} from '@qasimha/react-native-sdk';

const API = 'https://shop.example';

type Session = { orderId: string; orderReferenceId: string; paymentUrl: string; merchantUrl: MerchantUrl };

export function CheckoutScreen({ items, total, publishableKey, lang }: {
  items: { productId: string; quantity: number }[];
  total: number;
  publishableKey: string;
  lang: 'ar' | 'en';
}) {
  const [message, setMessage] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  const confirm = async (orderId: string) => {
    const response = await fetch(`${API}/api/orders/${encodeURIComponent(orderId)}`);
    const order = (await response.json()) as { status: string; paid: boolean };
    setMessage(order.paid ? 'Order confirmed. Thank you.' : `Payment not completed (${order.status}).`);
  };

  const pay = async () => {
    if (isCheckoutInProgress()) return;
    setBusy(true);
    setMessage(null);
    try {
      const response = await fetch(`${API}/api/checkout`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify({ items }),
      });
      if (response.status !== 201) throw new Error(`HTTP ${response.status}`);
      const session = (await response.json()) as Session;

      const result: CheckoutResult = await startCheckout({
        paymentUrl: session.paymentUrl,
        merchantUrl: session.merchantUrl,
        language: lang,
      });
      // Confirm every outcome on the server; a cancelled result carries no order id.
      const orderId = result.type !== 'cancelled' && result.orderId ? result.orderId : session.orderId;
      await confirm(orderId);
    } catch (error) {
      setMessage(error instanceof QasimhaCheckoutError ? `Could not open the checkout (${error.code}).` : 'Could not start the payment.');
    } finally {
      setBusy(false);
    }
  };

  return (
    <View>
      <QasimhaPromo amount={total} publicKey={publishableKey} lang={lang} />
      <Button title="Pay with Qasimha" onPress={pay} disabled={busy} />
      {message ? <Text>{message}</Text> : null}
    </View>
  );
}

Call useQasimhaReturn() (above) once in your root component.

Test in UAT

  1. Point a UAT build at a server using your sk_test_... key. Use a pk_test_... key for the promo.
  2. Run every path on a real Android device and a real iPhone: finish, decline, cancel on the hosted page, and dismiss the sheet. Check each result and that the app asks your server before it shows "paid".
  3. Kill the app while the checkout is open, then finish the payment. Check that your server still finds the order through the webhook or the session's orderId.
  4. If you use webAuthenticationSession on iOS, test it on your oldest supported iOS version, with the return URLs you ship.
  5. Check the promo shows for an amount in your range and is hidden for one outside it, with and without react-native-svg.

Release builds

  • Android: consumer R8 rules are shipped; nothing to add.
  • iOS: symbols are stripped in your release build with the rest of the app. The iOS module ships a privacy manifest declaring no tracking.
  • Metro and react-native-svg: the module loads react-native-svg with an optional require. Metro's resolver.allowOptionalDependencies is false by default, so an app without react-native-svg may fail to bundle. Install react-native-svg or set that option to true.

Go-live checklist

  • The production build uses pk_live_...; the server uses sk_live_... and sends the production return URLs.
  • assetlinks.json and apple-app-site-association are served from the production return domain.
  • qasimhaReturnScheme and the iOS URL scheme are unique to your app.
  • useQasimhaReturn() runs at the root, so every screen hands return links over.
  • The app confirms every order with your server; your server also processes the webhook.

Security

  • No secret on the device. The only credential in the app is the publishable key for the promo, which the API scopes to reading plans.
  • TLS validation is the platform's; no option to disable it and no certificate pinning.
  • No storage, analytics or fingerprinting. Nothing is written to logs.
  • PCI DSS: card entry happens on the hosted checkout and the payment provider. You stay in SAQ-A scope.
  • App hardening: root/jailbreak detection, anti-tamper and runtime protection are recommended for apps that present payments. Use a RASP product or the platform's integrity APIs; the module does not perform them.

Troubleshooting

Android build fails on qasimhaReturnScheme

Add manifestPlaceholders = [qasimhaReturnScheme: "..."] to defaultConfig (or every flavour) in android/app/build.gradle, even if you only use App Links.

Bundling fails on react-native-svg

Install react-native-svg 13+, or set Metro's resolver.allowOptionalDependencies to true. Without it, use renderSymbol to draw the currency symbol.

The checkout resolves as cancelled after the shopper paid

The return link did not reach handleReturn. Check the Linking listener and getInitialURL() call, the AppDelegate forwarding on iOS, and singleTask on Android. On iOS with Universal Links, the shopper must tap Return to store. Confirm the order on your server anyway.

Error code IN_PROGRESS

A checkout is already open. Only one can run at a time. Check isCheckoutInProgress() before you start.

Error code INVALID_OPTIONS

Read field on the error. Usually a return URL has a fragment, uses http outside localhost, uses a refused scheme, or cancel is missing; or, with webAuthenticationSession, the three URLs do not share one scheme (and, for https, one host and path).

Error code NATIVE_MODULE_MISSING

The native module is not linked. Rebuild the app after install, and run pod install in ios/.

Error code UNSUPPORTED on iOS

webAuthenticationSession with https return URLs needs iOS 17.4. Use a custom scheme, or systemBrowser.

The language hint is ignored

Expected today. The hosted checkout follows the shopper's browser language and offers an in-page toggle.

Related


Did this page help you?