Mobile WebView
Overview
This guide shows how to embed the Nimbbl Web checkout inside a mobile WebView and handle the two things a WebView cannot do on its own: launching UPI apps natively and returning the payment result to your app. Each platform below mirrors an official open-source DIY sample app you can clone and run.
- Android and iOS implement the full checkout flow — call
update-orderbefore loading, hand installed UPI apps to the checkout page over a JavaScript bridge, launch the selected app, intercept the mobile redirect, and render a native result screen. - React Native and Flutter implement the lighter-weight flow — intercept UPI deep-links and handle bank 3DS / net-banking popups.
We strongly recommend using the platform-specific SDKs (Android SDK, iOS SDK, React Native SDK, Flutter SDK) for better performance, seamless UPI handling, and an optimized user experience. Use this WebView approach only if you cannot adopt a client SDK.
Reference sample apps
Each platform section is a walkthrough of its open-source DIY sample. Clone the one you need for the complete, runnable source:
- Android — View on GitHub
- iOS — View on GitHub
- React Native — View on GitHub
- Flutter — View on GitHub
How the full flow works
Android and iOS follow the same sequence — only the platform APIs differ:
- Collect the order token. The checkout URL is
https://sonic.nimbbl.tech/?token=<ORDER_TOKEN>. The screen refuses to open without a non-emptytoken. - Update the order. Before loading, call
PATCH /api/v3/orderwithcallback_mode=callback_mobileso the backend routes the result to the mobile redirect URL instead of a browser one. You can skip this step if you pass the same parameters (callback_mode,referrer_platform,referrer_platform_version) to the create-order API when the order is created. - Load the checkout in the WebView.
- Offer installed UPI apps. After the first page load, the app discovers installed UPI apps and injects them into the page via
window.nimbbl_web.UPIIntentAvailable(...). - Launch on selection. When the user picks an app, the page calls the native bridge (
openUpiIntent); the app launches the UPI app and, once the user returns, notifies the page viapostMessage. - Handle the result. The checkout redirects to
<sonic-host>/mobile/redirect?response=...; the app intercepts that URL and shows a native result screen. - Device back is forwarded to the checkout page via
postMessage({ sdk_action: "device_back_initiated" })rather than navigating WebView history — the page decides what to do.
Platform Integration
- Android
- iOS
- React Native
- Flutter
Complete, runnable app: nimbbl_mobile_android_webview_diy_sample_app. Snippets below are the key pieces of WebViewActivity.kt and UpiAppUtils.kt.
1. Require a token, then open the WebView
// HomeActivity.kt
private fun isValidUrl(url: String): Boolean {
if (url.isEmpty()) return false
return try {
val formatted = if (!url.startsWith("http")) "https://$url" else url
val uri = Uri.parse(formatted)
uri.scheme != null && !uri.host.isNullOrEmpty() &&
!uri.getQueryParameter("token").isNullOrEmpty()
} catch (e: Exception) { false }
}
2. Call update-order before loading
Decode the order_id from the token's JWT payload and tell the backend this is a mobile WebView session.
// PATCH https://<api-host>/api/v3/order (Authorization: Bearer <token>)
val body = JSONObject().apply {
put("callback_mode", "callback_mobile")
put("referrer_platform", "android")
put("order_id", orderId)
put("referrer_platform_version", "1.0.0")
}.toString()
This call is optional. If you pass the same parameters — callback_mode, referrer_platform, and referrer_platform_version — to the create-order API when the order is created, the order is already set up for mobile WebView and you can skip the PATCH /api/v3/order step entirely, loading the checkout URL directly.
3. Discover installed UPI apps and hand them to the page
Query the apps that can handle upi://pay, then inject the list.
// UpiAppUtils.kt — query installed UPI apps
val upiIntent = Intent(Intent.ACTION_VIEW,
Uri.Builder().scheme("upi").authority("pay").build())
val resolved = packageManager.queryIntentActivities(upiIntent, 0)
// build { "UPIApps": [ { "upi_app_name", "package_name" }, … ] }
// WebViewActivity.kt — after onPageFinished
val jsonStr = JSONObject.quote(upiAppsJson.toString())
webView.evaluateJavascript("window.nimbbl_web.UPIIntentAvailable($jsonStr)", null)
Register the JavaScript bridge the checkout page calls when a UPI app is selected:
webView.addJavascriptInterface(SonicJsInterface(this), "NimbblSDK")
private inner class SonicJsInterface(private val ctx: Context) {
@JavascriptInterface
fun openUpiIntent(parameters: String) = runOnUiThread {
val json = JSONObject(parameters)
val upiUrl = json.getString("url")
val packageName = json.getString("package_name")
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(upiUrl)).apply {
setPackage(packageName)
}
if (intent.resolveActivity(packageManager) != null) {
upiResultLauncher.launch(intent) // launch the chosen UPI app
} else {
sendUpiBackToWebView() // notify the page to resume
}
}
}
When the user returns from the UPI app, notify the page so it can resume polling:
private fun sendUpiBackToWebView() {
val payload = JSONObject().apply {
put("sdk_upi_intent_response", "close")
put("sdk_transaction_enquiry_response", "")
}
val js = JSONObject.quote(payload.toString())
webView.evaluateJavascript("window.postMessage($js, '$checkoutOrigin');", null)
}
4. Intercept the redirect and show the result
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
if (url.startsWith(callbackUrlBase)) { // <sonic-host>/mobile/redirect
val response = Uri.parse(url).getQueryParameter("response")
startActivity(Intent(this, PaymentResultActivity::class.java).apply {
putExtra(PaymentResultActivity.EXTRA_RESPONSE, response ?: "")
})
finish()
return true
}
return false
}
5. Forward device back to the page
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val payload = JSONObject().apply {
put("sdk_action", "device_back_initiated")
put("source", "nimbbl_android_sdk")
}
val js = JSONObject.quote(payload.toString())
webView.evaluateJavascript("window.postMessage($js, '$checkoutOrigin');", null)
}
})
Manifest
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required on Android 11+ to query installed UPI apps -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="upi" android:host="pay" />
</intent>
</queries>
Complete, runnable app: nimbbl_mobile_ios_webview_diy_sample_app. Snippets below are the key pieces of ViewController.swift.
1. Require a token, then open the WebView
func isValidCheckoutUrl(_ urlString: String) -> Bool {
guard isValidUrl(urlString) else { return false }
return !orderToken(from: urlString).isEmpty // reject URLs without ?token=
}
2. Call update-order before loading
// PATCH https://<api-host>/api/v3/order (Authorization: Bearer <token>)
let body: [String: Any] = [
"callback_mode": "callback_mobile",
"referrer_platform": "ios",
"order_id": orderId,
"referrer_platform_version": "1.0.0"
]
This call is optional. If you pass the same parameters — callback_mode, referrer_platform, and referrer_platform_version — to the create-order API when the order is created, you can skip the PATCH /api/v3/order step entirely and load the checkout URL directly.
3. Register the bridge and offer installed UPI apps
iOS cannot enumerate installed apps, so the sample starts from a known catalog and keeps the ones canOpenURL resolves (each scheme is declared in LSApplicationQueriesSchemes).
// The page calls window.webkit.messageHandlers.openUpiIntent.postMessage(...)
config.userContentController.add(WeakScriptMessageHandler(self), name: "openUpiIntent")
// After didFinish, inject the installed apps
let js = "window.nimbbl_web.UPIIntentAvailable(\(jsStringLiteral(jsonString)));"
webView.evaluateJavaScript(js)
Handle the selection and launch the UPI app (routed by URL scheme):
func userContentController(_ c: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "openUpiIntent", let body = message.body as? String else { return }
// validate url / package_name / transaction_id, then:
guard let url = URL(string: upiUrlString), UIApplication.shared.canOpenURL(url) else {
sendUpiBackToWebView(); return
}
pendingUpiReturn = true
UIApplication.shared.open(url)
}
// When the app becomes active again, notify the page:
@objc private func appDidBecomeActive() {
guard pendingUpiReturn else { return }
pendingUpiReturn = false
let payload = #"{"sdk_upi_intent_response":"close","sdk_transaction_enquiry_response":""}"#
webView.evaluateJavaScript("window.postMessage(\(jsStringLiteral(payload)), '*');")
}
4. Intercept the redirect and show the result
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url?.absoluteString ?? ""
if url.hasPrefix(callbackUrlBase) { // <sonic-host>/mobile/redirect
let response = URLComponents(string: url)?
.queryItems?.first(where: { $0.name == "response" })?.value ?? ""
let resultVC = PaymentResultViewController(response: response, orderId: orderIdValue)
navigationController?.setViewControllers([...], animated: true)
decisionHandler(.cancel); return
}
decisionHandler(.allow)
}
5. Forward device back to the page
@objc private func backTapped() {
let payload = #"{"sdk_action":"device_back_initiated","source":"ios"}"#
webView.evaluateJavaScript("window.postMessage(\(jsStringLiteral(payload)), '*');")
}
Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>upi</string>
<string>gpay</string>
<string>phonepe</string>
<string>paytmmp</string>
<string>bhim</string>
<string>credpay</string>
<string>amazonpay</string>
<string>mobikwik</string>
<string>jupiter</string>
<string>bajajpayupi</string>
<string>navipay</string>
<string>supermoney</string>
<string>kotak811</string>
<string>popclubapp</string>
</array>
Complete, runnable app: nimbbl_mobile_reactnative_webview_diy_sample_app. Snippets below are the key pieces of App.tsx.
The React Native sample uses react-native-webview. It intercepts UPI deep-links with onShouldStartLoadWithRequest, and handles bank 3DS / net-banking popups by intercepting window.open with injected JavaScript and rendering the popup in a modal WebView.
Intercept UPI deep-links
const UPI_SCHEMES = [
'upi://', 'gpay://', 'phonepe://', 'paytm://', 'bhim://', 'amazonpay://',
'mobikwik://', 'freecharge://', 'jupiter://', 'slice://', 'cred://',
];
const isUpiUrl = (url: string) => {
const lower = url.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
return UPI_SCHEMES.some(s => lower.startsWith(s));
};
const handleShouldStartLoad = (request: ShouldStartLoadRequest): boolean => {
if (isUpiUrl(request.url)) {
Linking.openURL(request.url).catch(() =>
Alert.alert('', 'No UPI app found to handle this link'),
);
return false; // don't load the scheme in the WebView
}
return true;
};
Handle bank 3DS / net-banking popups
Inject JavaScript before content loads that routes window.open() to React Native as a message, then show the popup in a modal WebView.
const POPUP_INTERCEPT_JS = `
(function() {
window.open = function(url, name) {
window.ReactNativeWebView.postMessage(
JSON.stringify({ type: 'POPUP_OPEN', url: String(url || ''), name: String(name || '') }));
// return a proxy so the page's popup.location.href assignments are captured too
...
};
})(); true;
`;
<WebView
source={{ uri: checkoutUrl }}
injectedJavaScriptBeforeContentLoaded={POPUP_INTERCEPT_JS}
setSupportMultipleWindows={true}
onMessage={handleMessage} // opens the popup modal
onShouldStartLoadWithRequest={handleShouldStartLoad}
/>
See the repo for the full popup proxy, FORM_SUBMIT handling, and the popup modal WebView.
Complete, runnable app: nimbbl_mobile_flutter_webview_diy_sample_app. Snippets below are the key pieces of main.dart.
The Flutter sample uses flutter_inappwebview with url_launcher. It intercepts UPI deep-links in shouldOverrideUrlLoading, and handles bank 3DS / net-banking popups with onCreateWindow, binding the popup to a windowId so the checkout page's JS context stays alive.
Intercept UPI deep-links
const upiSchemes = [
'upi://', 'gpay://', 'phonepe://', 'paytm://', 'bhim://', 'amazonpay://',
'mobikwik://', 'freecharge://', 'jupiter://', 'slice://', 'cred://',
];
bool isUpiUrl(String url) {
final lower = url.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
return upiSchemes.any((s) => lower.startsWith(s));
}
shouldOverrideUrlLoading: (controller, navigationAction) async {
final url = navigationAction.request.url?.toString() ?? '';
if (isUpiUrl(url)) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
Handle bank 3DS / net-banking popups
onCreateWindow: (controller, createWindowAction) async {
final url = createWindowAction.request.url?.toString() ?? '';
if (isUpiUrl(url)) {
await launchUpi(url, context);
return false;
}
// Show the popup in a separate InAppWebView bound to the windowId.
Navigator.push(context, MaterialPageRoute(
builder: (_) => PopupWebViewScreen(windowId: createWindowAction.windowId),
));
return true;
},
Dependencies
dependencies:
flutter_inappwebview: ^6.1.5
url_launcher: ^6.3.0
Supported UPI App Schemes
Representative UPI app URL schemes used across the samples. Android discovers apps dynamically via PackageManager; iOS declares each scheme in LSApplicationQueriesSchemes.
| UPI App | URL scheme |
|---|---|
| Google Pay | gpay://upi/ |
| PhonePe | phonepe:// |
| Paytm | paytmmp:// |
| BHIM | bhim://upi/ |
| Amazon Pay | amazonpay:// |
| CRED | credpay://upi/ |
| MobiKwik | mobikwik://upi/ |
| Jupiter | jupiter:// |
| Bajaj Pay | bajajpayupi:// |
| Navi | navipay:// |
| Super.Money | supermoney:// |
| Kotak811 | kotak811:// |
| POP | popclubapp:// |
| Generic UPI | upi://pay |
Best Practices
- Error Handling: Always handle the case when a UPI app is not installed — notify the checkout page (
sendUpiBackToWebView) so it can resume, and show clear feedback to the user. - Update order first: On Android and iOS, call
update-order(or pass the parameters at create-order time) so the checkout returns to the mobile redirect URL. - Forward device back: Post
device_back_initiatedto the page rather than navigating WebView history — the checkout decides whether to close or step back. - Testing: Test on real devices with real UPI apps installed; simulators/emulators won't have them.
- Security: Block mixed content and validate URLs before opening them.
Troubleshooting
UPI App Not Opening
- Ensure the scheme is declared (
LSApplicationQueriesSchemeson iOS,<queries>on Android) - Check that the UPI app is installed on the device
- Verify the payload passed to
openUpiIntenthasurl,package_name, andtransaction_id
WebView Not Loading Checkout
- Ensure JavaScript and DOM storage are enabled
- Confirm the checkout URL includes a valid
?token= - Verify network permissions are granted
Payment Result Not Showing
- Confirm
update-orderran withcallback_mode=callback_mobile - Verify the redirect URL (
<sonic-host>/mobile/redirect) is being intercepted - Check that the
responsequery parameter is parsed correctly
- Always test UPI redirects on real devices, as simulators may not have UPI apps installed
- Ensure proper error handling for cases when UPI apps are not available
- Keep your WebView implementation updated to handle new UPI app schemes