Program × Developer: how the Paylead WebApp (MFP) calls native iOS/Android capabilities from inside your WebView.
The Mobile Bridge lets the Paylead WebApp (MFP) running inside your native
WebView invoke native device capabilities: opening a URL in the system browser,
writing to the clipboard, downloading a file. It uses the JSON-RPC 2.0
protocol over postMessage.Your native app receives requests from the WebApp and responds by calling back
into JavaScript.
The bridge JavaScript is versioned (v1, v2, …). The machine-readable
contract for each version is published as a schema.json. See the
Methods reference below for the current version, and the
Versions & download page to download a
schema or browse older versions.
Never interpolate JSON strings into JavaScript source. Use argument binding
(callAsyncJavaScript on iOS) or JSONObject.quote() (Android). Direct
interpolation such as "response('${json}')" creates a JS injection vector.
This rule applies every time you call back into the WebApp.
jsonrpc must equal "2.0". Reject any other value with error code -32600. If the envelope cannot be parsed at all, respond with "id": null and error code -32600.
id must be a UUID v4 string. The WebApp always sends a randomly generated UUID v4; non-string ids are silently dropped by the WebApp.
The id in the response must match the id from the request.
result and error are mutually exclusive. On success include result and omit error; on failure include error and omit result. If the WebApp receives both, error takes precedence and the call rejects.
Timeouts. The WebApp rejects requests that don’t receive a response within 5 seconds for browser.open and clipboard.write, and 30 seconds for file.download. For file.download, respond once the download has been triggered (consent granted, transfer started), not once it completes.
Suggested filename. Allowed characters: letters, digits, underscore, hyphen, dot. Must start with a letter or digit. The native app must still sanitise before writing to disk.Pattern ^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$ · max length 255
When a method fails, the native app responds with a JSON-RPC 2.0 error object
carrying one of these codes in error.code. error.message must never contain
stack traces, filesystem paths, or other sensitive information.
Code
Description
-32600
Invalid JSON-RPC 2.0 request
-32601
Method not found
-32602
Invalid or missing params
-32000
Runtime error (scheme blocked, download failed, etc.) — error.message must not contain stack traces or filesystem paths
-32001
Permission denied (location, etc.) — error.message must not contain stack traces or filesystem paths
Register a WKScriptMessageHandler named payleadBridge.
2
Receive the request
Receive JSON-RPC requests in userContentController(_:didReceive:).
3
Execute and respond
Execute the action and call back with callAsyncJavaScript using named argument binding.
let config = WKWebViewConfiguration()config.userContentController.add(self, name: "payleadBridge")// Recommended (iOS 14+): restrict navigation to your app's domainsconfig.limitsNavigationsToAppBoundDomains = true// Hosts allowed to reach the bridge: any sub-domain of paylead.fr / .tech / .eulet allowedHost = #"\.paylead\.(fr|tech|eu)$"#func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { // Reject messages from sub-frames or unknown origins guard message.frameInfo.isMainFrame, let host = message.frameInfo.request.url?.host, host.range(of: allowedHost, options: .regularExpression) != nil else { return } guard message.name == "payleadBridge", let body = message.body as? [String: Any], let jsonrpc = body["jsonrpc"] as? String, jsonrpc == "2.0", let method = body["method"] as? String, let id = body["id"] as? String else { return } let params = body["params"] as? [String: Any] ?? [:] // Validate params against the method's JSON Schema (schema.json). // On failure respond with error code -32602 (Invalid params). guard validateParams(method: method, params: params) else { return } handleBridgeMethod(method, params: params) { result in let response: [String: Any] = ["jsonrpc": "2.0", "result": result, "id": id] guard let data = try? JSONSerialization.data(withJSONObject: response), let jsonStr = String(data: data, encoding: .utf8) else { return } // Safe: jsonStr is passed as a named argument, no interpolation into JS webView.callAsyncJavaScript( "window.payleadBridge.response(jsonString)", arguments: ["jsonString": jsonStr], in: nil, in: .defaultClient, completionHandler: nil ) }}
Check message.frameInfo.isMainFrame and verify the host against your allowlist before processing any message. Cross-origin iframes inside the WebView otherwise share access to window.webkit.messageHandlers.payleadBridge.
Use limitsNavigationsToAppBoundDomains = true and declare your domains under WKAppBoundDomains in Info.plist (iOS 14+).
Block navigations to hosts outside your allowlist in decidePolicyFor.
Android’s addJavascriptInterface only takes effect on the next page load and,
once registered, is exposed to every frame until removed and reloaded. The
safest approach is to block navigations to untrusted origins entirely,
register the interface once, and re-check the origin on every call as defense
in depth.
// Hosts allowed to reach the bridge: any sub-domain of paylead.fr / .tech / .euval allowedHost = Regex("""\.paylead\.(fr|tech|eu)$""")webView.addJavascriptInterface(PayleadBridgeInterface(webView), "PayleadBridge")webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { val host = request.url.host ?: return true return !allowedHost.containsMatchIn(host) // true = navigation blocked }}class PayleadBridgeInterface(private val webView: WebView) { @JavascriptInterface fun postMessage(json: String) { webView.post { // Defense in depth: re-check the current URL on every call val host = Uri.parse(webView.url).host ?: return@post if (!allowedHost.containsMatchIn(host)) return@post val request = try { JSONObject(json) } catch (_: Exception) { return@post } if (request.optString("jsonrpc") != "2.0") return@post val method = request.optString("method").ifEmpty { return@post } val id = request.optString("id").ifEmpty { return@post } val params = request.optJSONObject("params") ?: JSONObject() // Validate params against the method's JSON Schema (schema.json). // On failure respond with error code -32602 (Invalid params). if (!validateParams(method, params)) return@post handleBridgeMethod(method, params) { result -> val response = JSONObject().apply { put("jsonrpc", "2.0"); put("result", result); put("id", id) } webView.post { // Safe: JSONObject.quote() properly escapes the JSON string webView.evaluateJavascript( "window.payleadBridge.response(${JSONObject.quote(response.toString())})", null ) } } } }}
Block navigations outside your allowlist in shouldOverrideUrlLoading. Removing the interface in onPageStarted/onPageFinished does not take effect until the next reload.
Re-check the current URL from inside postMessage itself.
Before shipping your integration, verify each item:
HTTPS only: every URL received from the bridge is validated to start with https:// before opening or downloading. javascript:, data:, file:, and intent:// schemes are explicitly rejected.
Filename sanitisation: the schema restricts filenames to a safe character set (^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$); as defense in depth, the native app re-validates and strips null bytes / OS-reserved names before writing to disk.
Origin allowlist: the bridge is only reachable from pages whose host matches your trusted allowlist (*.paylead.fr, *.paylead.tech, *.paylead.eu). Navigations outside the allowlist are blocked.
User consent:file.download triggers a native prompt so the user can confirm or cancel.
App-bound domains (iOS 14+): limitsNavigationsToAppBoundDomains = true and WKAppBoundDomains are configured in Info.plist.
Back/forward gestures: disable allowsBackForwardNavigationGestures (iOS) and handle the back button explicitly (Android) to avoid accidental off-domain navigations.
Loading indicators:file.download can take up to 30 seconds; surface progress in your native UI.
Correlation logging: when logging bridge errors natively, include the request id so failures can be cross-referenced with the WebApp’s client-side logs.