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
| Item | Requirement |
|---|---|
| React Native | 0.72 or later |
| React | 18 or 19 |
| Android | minSdk 24, AndroidX, Kotlin 1.9+, a browser with Custom Tabs (falls back to the default browser) |
| iOS | 13.4 or later, Swift 5.9. ASWebAuthenticationSession with an https return URL needs iOS 17.4. |
| Optional | react-native-svg 13+ for the riyal symbol and logo in QasimhaPromo |
| Server | A 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 installAutolinking 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_...orsk_live_..., creates the session and returnspaymentUrland themerchantUrlit used. The app passes both tostartCheckoutunchanged. There is no environment setting:paymentUrlalready points at the environment your server used. - Publishable key, for the promo only.
QasimhaPromoneedspk_test_...(UAT, callshttps://api-uat.qasimha.com) orpk_live_...(production, callshttps://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.
- Declare the custom return scheme the module's
QasimhaReturnActivitylistens on, inandroid/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"]
}
}
}- For App Links (
httpsreturn URLs), add a verified intent filter to your main activity and publishhttps://shop.example/.well-known/assetlinks.jsonwith 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:..."]
}
}]- Keep
android:launchMode="singleTask"on the main activity (the React Native template default). The return reaches your app throughonNewIntentand React Native'sLinking, which also closes the Custom Tab.
- Custom scheme: add it to
Info.plist. For a scheme per build configuration, defineQASIMHA_RETURN_SCHEMEin each configuration's.xcconfigand write$(QASIMHA_RETURN_SCHEME)here.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myshop</string></array>
</dict>
</array>- Universal Links: add the Associated Domains capability (
applinks:shop.example) and publishhttps://shop.example/.well-known/apple-app-site-association(JSON, no extension):
{
"applinks": {
"details": [
{ "appIDs": ["TEAMID.com.example.shop"], "components": [ { "/": "/qasimha/*" } ] }
]
}
}- Forward links to React Native's
LinkinginAppDelegate(the Objective-C equivalent calls the sameRCTLinkingManagermethods):
import React
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
}SFSafariViewController opens a Universal Link into your app only from a user tap. The hosted checkout shows a "Return to store" button on every final screen, so the shopper's tap brings the result back. Custom-scheme URLs and presentation: 'webAuthenticationSession' return without a tap.
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>
startCheckout(options): Promise<CheckoutResult>| Option | Type | Default | Notes |
|---|---|---|---|
paymentUrl | string | required | The paymentUrl from session creation. Absolute https, no credentials. |
merchantUrl | { success, failure, cancel } | required | All 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' | none | Sent 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
handleReturn(url: string): booleanCall it from Linking's url event and with getInitialURL(). true when the URL matched the pending checkout's return URLs; false otherwise.
isCheckoutInProgress(): boolean
isCheckoutInProgress(): booleantrue while a checkout is presented. Use it to disable your pay button.
parseReturnUrl(url, merchantUrl) and validateCheckoutOptions(options)
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
CheckoutResulttype CheckoutResult =
| { type: 'success'; orderId: string; orderReferenceId?: string }
| { type: 'failure'; status: string; orderId?: string; orderReferenceId?: string }
| { type: 'cancelled' };| On the return URL | Result |
|---|---|
status=confirmed with an orderId | success |
status=confirmed without an orderId | failure, status unknown |
status=declined, expired or failed | failure with that status |
status=cancelled (or canceled) | cancelled |
no status on the cancel URL | cancelled |
no status on the success or failure URL | failure, status unknown |
any other status | failure with that status, lower-cased |
| the shopper dismissed the sheet | cancelled |
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
QasimhaCheckoutErrorstartCheckout rejects with QasimhaCheckoutError (code, message, and field for INVALID_OPTIONS):
code | When |
|---|---|
INVALID_OPTIONS | An option breaks the rules above. field names it (paymentUrl, merchantUrl.success, presentation, language, ...). |
IN_PROGRESS | A checkout is already open. |
NATIVE_MODULE_MISSING | The native module is not linked. |
INVALID_URL | The native side refused the URL, or webAuthenticationSession had no callback scheme or host. |
NO_ACTIVITY | Android: no foreground activity to present from. |
NO_BROWSER | Android: no browser on the device. |
HOST_DESTROYED | Android: the activity was destroyed while the checkout was open. |
NO_VIEW_CONTROLLER | iOS: no view controller to present from. |
UNSUPPORTED | iOS below 17.4 with webAuthenticationSession and https return URLs. |
PRESENTATION_FAILED | iOS: the authentication session could not start. |
NATIVE_ERROR | Any 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.
| Prop | Type | Default | Notes |
|---|---|---|---|
amount | number | string | required | Positive, at most two decimals, up to 999999999.99. Strings must match ^\d{1,9}(\.\d{1,2})?$. |
publicKey | string | required | pk_test_... or pk_live_.... |
lang | 'ar' | 'en' | 'ar' | Line language and direction. |
apiBaseUrl | string | from the key prefix | pk_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 }) => ReactNode | none | Your own currency symbol node (an Image, an icon-font glyph, your SVG). |
showLogo | boolean | true | The Qasimha logo at the start of the line, 1.45 em high. |
onLearnMore | () => void | none | Adds a "Learn more" link (colour PROMO_LINK_COLOR, #b9431f) that opens your own explainer. |
onPlan | (plan: Plan | null) => void | none | Called with the headline plan, or null when nothing renders. Use it to hide a surrounding card. |
style | StyleProp<ViewStyle> | none | Container style. |
textStyle | StyleProp<TextStyle> | 14 pt, #1f2937 | Text style. The symbol takes its size (0.82 em) and colour from it. |
testID | string | 'qasimha-promo' | Test id of the container. |
- Calls
GET /api/v1/widget/plans?amount=1200.00¤cy=SAR&lang=arwithX-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
apiBaseUrlis invalid, the request fails or times out, the status is not 2xx, the response is notsuccess: truewithcurrency: "SAR"and a valid plan, or the amount is outsideminAmounttomaxAmount. - 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
| Export | Meaning |
|---|---|
QasimhaLogo | The logo alone. Props: height (default 20), lang, style, testID. Falls back to a text pill without react-native-svg. |
RiyalSymbol | The 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_RATIO | The symbol's vector paths, for your own drawing. |
LOGO_PATHS, LOGO_VIEWBOX, LOGO_ASPECT_RATIO, LOGO_PRIMARY | The logo's vector paths and colour. |
| Types | CheckoutOptions, 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
- Point a UAT build at a server using your
sk_test_...key. Use apk_test_...key for the promo. - 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".
- 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. - If you use
webAuthenticationSessionon iOS, test it on your oldest supported iOS version, with the return URLs you ship. - 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 loadsreact-native-svgwith an optionalrequire. Metro'sresolver.allowOptionalDependenciesisfalseby default, so an app withoutreact-native-svgmay fail to bundle. Installreact-native-svgor set that option totrue.
Go-live checklist
- The production build uses
pk_live_...; the server usessk_live_...and sends the production return URLs. assetlinks.jsonandapple-app-site-associationare served from the production return domain.qasimhaReturnSchemeand 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
Updated about 19 hours ago
