Key takeaways

  • Use the official SDK when you can; when you cannot, load the signed offerwall link from your server and let only the offerwall host stay inside the WebView.
  • Offers that open an app store or a new tab must leave the WebView: shouldOverrideUrlLoading on Android, decidePolicyFor and createWebViewWith on iOS.
  • Turn on JavaScript, keep file access and JavaScript bridges off, handle back and close, and reload the balance from your server when the user returns.

How do you open an offerwall in a WebView safely?

To open an offerwall in a WebView safely, build the signed offerwall link on your server, load it in a WebView with JavaScript on and file access and JavaScript bridges off, and let only pages on the offerwall’s own host load inside it. Every other link, including app store pages and links that try to open a new tab, goes to the system browser or the store app. Rewards never come through the WebView: your server credits them from postbacks, and your app reloads the balance when the user comes back.

If you can add a native SDK, do that instead: Sharklio’s Android SDK and iOS SDK already do everything in this guide. This page is for when an SDK does not fit, such as a hybrid app, a white-label shell, or a framework without a plugin.

WebView, Custom Tabs, Safari View Controller or SDK?

There are five ways to put a web-based offerwall on a phone screen. They differ in how much you control and how much you have to get right yourself:

OptionUser stays in your appStore and new-tab linksYour UI around itFits an offerwall?
Official SDK (Android, iOS, Flutter, React Native, Unity)YesHandled for youBranded top barBest choice
Android WebView or iOS WKWebViewYesYou handle themFull controlYes, with the setup below
Chrome Custom Tabs (Android)Yes, in a browser viewHandled by the browserToolbar color and titleYes, least code
SFSafariViewController (iOS)Yes, in a browser viewHandled by SafariLimitedYes, least code
ASWebAuthenticationSession (iOS)Yes, in a sign-in sheetNot designed for itNoneNo, it is for sign-in

Google’s web content overview recommends Custom Tabs for an out-of-the-box browser experience, and the Custom Tabs guide says they share the browser’s cookie jar and permissions, while WebViews do not share state with the browser. Apple’s SFSafariViewController documentation says interactions with the page are not visible to your app, and points to WKWebView when you need to customize the content. ASWebAuthenticationSession is for signing a user in through a web service and first asks the user whether to proceed, so it is the wrong tool here.

A browser view is the quickest safe option. A WebView is the right pick when you want your own header and close button, and the rest of this guide is about doing that well. The broader trade-offs between a link, an iframe and an API are in Offerwall integration: iframe, link or API?

Who does what

PartDoesNever does
Your appAsks your server for the link, shows it, sends outside links out, reloads the balanceHolds the link hash salt, builds the hash, adds coins by itself
Your serverSigns in the user, builds the signed link, receives postbacks, owns the walletTrusts the app about rewards
SharklioShows offers for the user’s country and device, tracks results, sends signed postbacksPays your users directly

The offerwall link is https://wall.sharklio.com/{APP_ID}?user_id={USER_ID}&hash={HASH}, where the hash is an HMAC-SHA256 of the user ID with your link hash salt, in lowercase hex. The Offerwall link docs say the wall only loads inside frames on the website you registered for the app, but opening the link directly in a tab, a new window, or a mobile WebView works everywhere. So a native app needs no website at all.

Step 1: return the signed link from your server

Anything inside an app bundle can be read, so the salt stays on your server. Add one endpoint behind your normal sign-in that returns the link for the signed-in user. In Node.js:

const crypto = require("crypto");

app.get("/offerwall-link", requireAuth, (req, res) => {
  const userId = String(req.user.id);
  const hash = crypto.createHmac("sha256", process.env.SHARKLIO_LINK_SALT).update(userId).digest("hex");
  res.json({
    url: `https://wall.sharklio.com/${process.env.SHARKLIO_APP_ID}?user_id=${encodeURIComponent(userId)}&hash=${hash}`,
  });
});
  • Take the user ID from your own session, never from the request, and use your database ID, not an email or a device ID.
  • The hash depends only on the user ID, so a signed link keeps working until you change the salt. Treat it as private to that user: do not log it in analytics or put it in a share sheet. If one leaks, see how to rotate your keys and link salt.
  • Fetch a fresh link each time the user taps Earn, so it always belongs to whoever is signed in now. The SDKs go further with single-use session tokens that last 10 minutes.

PHP and Python versions of the hash are in the Offerwall link docs.

Step 2: an Android WebView that sends outside links out

The Android rules come from three places in Google’s documentation. The Build web apps in WebView guide says JavaScript is disabled by default and warns not to use addJavascriptInterface() unless you wrote all the HTML and JavaScript in the view. The WebViewClient reference says shouldOverrideUrlLoading() runs for navigations started by the page, by the user, or by an HTTP redirect, but not for the URL you pass to loadUrl() yourself. And the WebSettings reference says that with multiple-window support off (the default), links with target="_blank" and window.open() are treated as top-level navigations in the same WebView.

That matters because offer buttons usually open a new tab and then redirect, through tracking links, to a store page. With multiple windows off, every hop reaches shouldOverrideUrlLoading(), so one rule handles them all: pages on wall.sharklio.com stay, everything else leaves.

import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.addCallback

class OfferwallActivity : ComponentActivity() {

    private var webView: WebView? = null

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val wallUrl = intent.getStringExtra(EXTRA_WALL_URL)
        if (wallUrl == null || !isWallUrl(Uri.parse(wallUrl))) {
            finish()
            return
        }

        val web = WebView(this)
        web.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
            allowFileAccess = false
            allowContentAccess = false
            mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
            setSupportMultipleWindows(false)
        }
        web.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                val url = request.url
                if (!request.isForMainFrame) {
                    return url.scheme != "https" && url.scheme != "http"
                }
                if (isWallUrl(url)) return false
                openOutside(url)
                return true
            }
        }
        setContentView(web)
        webView = web

        onBackPressedDispatcher.addCallback(this) {
            if (web.canGoBack()) web.goBack() else finish()
        }

        if (savedInstanceState == null) {
            web.loadUrl(wallUrl)
        } else {
            web.restoreState(savedInstanceState)
        }
    }

    private fun openOutside(uri: Uri) {
        when (uri.scheme?.lowercase()) {
            "https", "http" -> {
                if (uri.host == "play.google.com") {
                    val play = Intent(Intent.ACTION_VIEW, uri).setPackage("com.android.vending")
                    if (launch(play)) return
                }
                launch(Intent(Intent.ACTION_VIEW, uri))
            }
            "market" -> launch(Intent(Intent.ACTION_VIEW, uri))
            "intent" -> openIntentUri(uri.toString())
        }
    }

    private fun openIntentUri(raw: String) {
        val parsed = try {
            Intent.parseUri(raw, Intent.URI_INTENT_SCHEME)
        } catch (e: Exception) {
            return
        }
        val safe = Intent(parsed).apply {
            component = null
            selector = null
            flags = 0
        }
        if (launch(safe)) return
        val fallback = parsed.getStringExtra("browser_fallback_url") ?: return
        if (fallback.startsWith("https://") || fallback.startsWith("http://")) {
            launch(Intent(Intent.ACTION_VIEW, Uri.parse(fallback)))
        }
    }

    private fun launch(intent: Intent): Boolean = try {
        startActivity(intent.addCategory(Intent.CATEGORY_BROWSABLE))
        true
    } catch (e: ActivityNotFoundException) {
        false
    } catch (e: SecurityException) {
        false
    }

    override fun onResume() {
        super.onResume()
        webView?.onResume()
    }

    override fun onPause() {
        webView?.onPause()
        super.onPause()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        webView?.saveState(outState)
    }

    override fun onDestroy() {
        webView?.destroy()
        webView = null
        super.onDestroy()
    }

    companion object {
        const val EXTRA_WALL_URL = "wall_url"

        fun isWallUrl(uri: Uri): Boolean =
            uri.scheme == "https" &&
                uri.host == "wall.sharklio.com" &&
                uri.userInfo == null &&
                (uri.port == -1 || uri.port == 443)
    }
}

What each part does:

  • Exact host match. isWallUrl() compares the whole host, so wall.sharklio.com.example.net does not count. It also checks the server’s link before the first loadUrl(), which never reaches shouldOverrideUrlLoading().
  • Play Store links. Google’s Linking to Google Play page opens a play.google.com URL with setPackage("com.android.vending") so users land in the Play Store app. If the store app is missing, the code falls back to the browser.
  • intent: links. Some tracking links end in an intent: URI. The code clears its component, selector and permission flags before starting it, then tries its browser_fallback_url. Any other scheme is dropped.
  • Back. The back gesture goes back inside the offerwall first and closes the screen when there is no history left, as the WebView guide shows with canGoBack().

The code needs androidx.activity and compiles with Kotlin 2.2 against Android API 35 (we compile-checked it on October 22, 2026). The WebResourceRequest overload exists from API 24; if your minSdk is lower, override the older shouldOverrideUrlLoading(WebView, String) with the same rule as well. For the SDK route on Android, see How to add an offerwall to an Android app (Kotlin), and for how SDKs compare, the best offerwall SDKs for Android.

Open it from your Earn button

suspend fun fetchOfferwallLink(authToken: String): String = withContext(Dispatchers.IO) {
    val conn = URL("https://api.your-app.example/offerwall-link").openConnection() as HttpURLConnection
    try {
        conn.connectTimeout = 10_000
        conn.readTimeout = 10_000
        conn.setRequestProperty("Authorization", "Bearer $authToken")
        if (conn.responseCode != 200) throw IOException("HTTP ${conn.responseCode}")
        val body = conn.inputStream.bufferedReader().use { it.readText() }
        JSONObject(body).getString("url")
    } finally {
        conn.disconnect()
    }
}

// In your Earn screen:
lifecycleScope.launch {
    val link = try {
        fetchOfferwallLink(currentUserAuthToken())
    } catch (e: Exception) {
        return@launch
    }
    startActivity(
        Intent(this@EarnActivity, OfferwallActivity::class.java)
            .putExtra(OfferwallActivity.EXTRA_WALL_URL, link)
    )
}

Prefer a browser view? With androidx.browser:browser:1.10.0, the whole integration is CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(this, Uri.parse(link)), and the browser handles store links itself.

Step 3: a WKWebView that sends outside links out

On iOS, two delegate methods do the work. webView(_:decidePolicyFor:decisionHandler:) on WKNavigationDelegate asks your app to allow or cancel every navigation before content loads. webView(_:createWebViewWith:for:windowFeatures:) on WKUIDelegate is how a page asks for a new window. A WKWebView does nothing with a target="_blank" link unless you handle it there: Apple’s targetFrame documentation says the target frame is nil when the navigation targets a new window, and createWebViewWith returns either a new web view or nil.

import UIKit
import WebKit

final class OfferwallViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {

    private let wallURL: URL
    private let webView: WKWebView
    var onClose: (() -> Void)?

    init(wallURL: URL) {
        self.wallURL = wallURL
        let config = WKWebViewConfiguration()
        config.websiteDataStore = .default()
        config.preferences.javaScriptCanOpenWindowsAutomatically = false
        webView = WKWebView(frame: .zero, configuration: config)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) is not supported")
    }

    override func loadView() {
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.allowsBackForwardNavigationGestures = true
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        navigationItem.rightBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .done, target: self, action: #selector(closeTapped))
        webView.load(URLRequest(url: wallURL))
    }

    @objc private func closeTapped() {
        dismiss(animated: true)
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        if isBeingDismissed || navigationController?.isBeingDismissed == true {
            onClose?()
        }
    }

    static func isWallURL(_ url: URL) -> Bool {
        return url.scheme?.lowercased() == "https"
            && url.host?.lowercased() == "wall.sharklio.com"
            && url.user == nil
            && (url.port == nil || url.port == 443)
    }

    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }
        if let frame = navigationAction.targetFrame, !frame.isMainFrame {
            let scheme = url.scheme?.lowercased() ?? ""
            let allowed = scheme == "https" || scheme == "http" || scheme == "about"
            decisionHandler(allowed ? .allow : .cancel)
            return
        }
        if Self.isWallURL(url) {
            decisionHandler(.allow)
        } else {
            decisionHandler(.cancel)
            openOutside(url)
        }
    }

    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = navigationAction.request.url, Self.isWallURL(url) {
            webView.load(navigationAction.request)
        }
        return nil
    }

    private func openOutside(_ url: URL) {
        guard let scheme = url.scheme?.lowercased(),
              ["https", "http", "itms-apps"].contains(scheme) else { return }
        UIApplication.shared.open(url)
    }
}
  • New-tab links. A new-window request first reaches decidePolicyFor with no target frame. A link off the offerwall host is cancelled there and opened outside. A link on the host is allowed, and createWebViewWith loads it in the same web view and returns nil, so no second window ever exists.
  • App Store links. UIApplication.shared.open hands apps.apple.com and itms-apps: links to the system, which opens the App Store app. Other web links open in Safari, or in the app that owns a universal link.
  • Pop-ups. Apple documents javaScriptCanOpenWindowsAutomatically as false by default on iOS; setting it just makes the intent clear.
  • Back and close. allowsBackForwardNavigationGestures is off by default; turning it on lets users swipe back inside the offerwall. Present the controller in a UINavigationController so Done shows. onClose runs on Done and on a swipe-down dismiss.

Present it with a fresh link from your server:

let wall = OfferwallViewController(wallURL: link)
wall.onClose = { [weak self] in self?.refreshBalance() }
present(UINavigationController(rootViewController: wall), animated: true)

We reviewed the Swift code by hand against Apple’s WebKit reference rather than compiling it, so build it in Xcode before you rely on it. For the simpler browser-view route, present SFSafariViewController(url: link) and refresh the balance in safariViewControllerDidFinish(_:). The SDK route is in How to add an offerwall to an iOS app (Swift).

JavaScript, storage, cookies and bridges

The Android SDK docs describe the SDK’s own web view as locked down: no file access, no mixed content, Safe Browsing on, and no JavaScript bridge into your app. The iOS SDK docs add that only offerwall pages load inside it and camera and microphone requests are denied. Copy that posture:

SettingDefaultFor an offerwall
JavaScript (Android)OffOn. The offerwall needs it
DOM storage (Android)OffOn, like most in-app web screens
File access (Android)On when targeting Android 10 or lower, off from Android 11Off, set explicitly
Mixed content (Android)Never allowed since Android 5.0Never allow
Safe Browsing (Android)On where the device supports itLeave it on
Third-party cookies (Android)Off for apps targeting Android 5.0 or laterLeave the default; the offerwall is the top-level page
Website data store (iOS)default() persists to diskKeep it, and clear it on sign-out
JavaScript bridgeNoneNone. Never add one for the offerwall

Defaults from Google’s WebSettings and CookieManager references and Apple’s WebKit docs, checked on October 22, 2026.

The bridge rule matters most. An offerwall shows pages you did not write, and a @JavascriptInterface method or a WKScriptMessageHandler would let them call into your app. You do not need one, because the offerwall never tells your app about rewards, and you should not scrape wall pages with evaluateJavascript to detect completions either.

On a shared device, clear web data when a user signs out. On Android, CookieManager.getInstance().removeAllCookies(null) and WebStorage.getInstance().deleteAllData(). On iOS, WKWebsiteDataStore.default().removeData(ofTypes:modifiedSince:completionHandler:) with all data types and Date.distantPast.

WebView mistakes that break offers or leak access

  • Loading every URL inside the WebView, so store pages open as web pages and installs are not tracked as installs.
  • Matching the host with contains("sharklio") or a prefix check instead of an exact match.
  • Building the hash in the app, which ships the salt to anyone who unpacks it.
  • Adding a JavaScript bridge “just for the close button”. Use a native button instead.
  • Ignoring SSL errors with handler.proceed() to get past a test setup.

Step 4: refresh the balance when the user returns

Rewards reach your server as a signed server-to-server postback, never through the page, so ask your server for the balance when the user comes back. Catch both returns:

  • The user closes the offerwall screen. On Android, reload in onResume() of the screen that shows the balance. On iOS, use onClose from the controller above.
  • The user comes back from the store, an advertiser app, or the browser. On Android the same onResume() fires. On iOS, also listen for UIApplication.willEnterForegroundNotification, because returning from another app does not dismiss your offerwall screen.

Many offers credit only after a later step or a review, so a reward is not always there on the first return. On your server, verify the postback hash, credit status 1 once per transaction ID, take back status 2 only for transactions you credited, and answer with a 2xx status within 6 seconds. The full checklist is in Postback security: verify every reward call, and duplicate handling in How to prevent duplicate reward credits.

Store policies, in general terms

Apple and Google both have rules about incentives, and both update them, so read the current versions before you submit. A WebView does not change what the rules allow. Do not reward ratings or reviews, keep the offerwall an optional extra, and remember that end users of a Sharklio offerwall must be at least 16 (with parental permission under 18). The details for each store are in offerwall rules for iOS apps and Google Play’s incentivized installs policy.

Step 5: test on real devices

  1. Create a test account in your app and note its user ID. In Integration status on your app’s Integration tab, use Check a link, then Test a link with that ID, and compare the result with what your endpoint returns. They must match exactly.
  2. Open the offerwall on a real phone on a normal home or mobile connection. On a VPN, a proxy or a cloud test device you see a block page such as VPN detected instead of offers.
  3. Start an offer that leads to a store. On Android it should open the Play Store app; on iOS, the App Store app. The offerwall screen should still be there when you come back.
  4. Press back (or swipe back) inside the offerwall, then close it, and check that the balance reloads.
  5. Use Send a test postback for the test user with status 1. The balance should go up once. Test transaction IDs start with test_, so a status 2 test must change nothing.
  6. Rotate the device mid-offer and check that the user stays where they were.

Every postback attempt is listed under Reports, Postbacks. The Testing docs list common errors, and Test your app before launch is the full pre-launch checklist. Building a desktop app instead? The Electron guide covers the same rules for BrowserWindow.

A Sharklio offerwall in your app, by SDK or by link

Sharklio is a pay-per-result affiliate network. Each app you add gets a signed offerwall link that opens in a WebView, Custom Tabs or Safari View Controller, plus an Android SDK, an iOS SDK, and Flutter, React Native and Unity plugins that open the same wall as a native screen. You set your own currency name and icon, rate and wall design, and every credit and reversal reaches your server as a signed postback. Publisher applications are reviewed by hand, and once your account is approved, every app you add is live right away. See how the wall works or read how to get approved as a publisher.

Frequently asked questions

Can I show an offerwall in a WebView?

Yes. A web-based offerwall such as Sharklio’s opens from a signed link in an Android WebView or an iOS WKWebView. You need to turn on JavaScript, send store and outside links to the system, and refresh the balance from your server when the user returns.

Why do offers not open the Play Store or App Store from my WebView?

Your WebView is loading the store link as a web page, or on iOS ignoring a link that targets a new window. Intercept navigation with shouldOverrideUrlLoading() on Android, or decidePolicyFor and createWebViewWith on iOS, and hand every non-offerwall link to the system.

Why do target=“_blank” links do nothing in WKWebView?

A link that targets a new window has a nil target frame, and WKWebView only opens it if your WKUIDelegate implements createWebViewWith. Load allowed links in the same web view there and return nil, and open the rest outside.

Is a WebView or Chrome Custom Tabs better for an offerwall?

Custom Tabs need the least code, because the browser handles store links and cookies. A WebView gives you your own header and close button but you handle links and settings yourself. If you can add an SDK, it gives you the WebView experience with the hard parts done.

Do I need a JavaScript bridge to know when a user finishes an offer?

No, and you should not add one. Completions reach your server through signed postbacks, and your app reads the balance from your server.