How to Add an Offerwall to a Chrome Extension (MV3)

Key takeaways

  • The extension never holds the link hash salt or the postback key: it asks your backend for a signed link and opens it in a new tab with chrome.tabs.create.
  • Do not frame the wall inside the popup or load embed.js there: the popup is small, closes when it loses focus, and Manifest V3 only runs scripts from your package.
  • Points change only on your server, from verified postbacks, and the popup reads the balance from your API every time it opens.

How do you add an offerwall to a Chrome extension in Manifest V3?

To add an offerwall to a Chrome extension, give your popup or options page an “Earn” button that asks your backend for a signed offerwall link and opens it in a new tab with chrome.tabs.create. Users sign in to your own account system, your server builds the link for that account, and your server credits points when the offerwall sends a postback. The extension holds no secrets, ships no remote code, and only reads the balance back from your API.

This guide is the implementation. Whether an offerwall fits your extension, what to sell for points, and how the store rules shape the business model are covered in How to monetize a browser extension with an offerwall, so read that first if you have not decided yet.

Who does what

An MV3 extension splits cleanly into three parts. Keep each one in its lane:

PartDoesNever does
Extension (popup or options page)Signs the user in, asks for the link, opens it in a tab, shows the balanceHolds the link hash salt or postback key, adds points by itself
Your backendOwns accounts and the points balance, signs the link, receives postbacksTrusts the extension about rewards
SharklioShows offers for the user’s country and device, tracks results, sends signed postbacksPays your users directly

No background service worker is needed for any of this.

Step 1: sign users in to your own account

An offerwall pays per user, so every wall link carries a user ID that never changes. In an extension, that ID should come from an account on your server, not from the browser:

  • Use your own login. A small email and password form in the popup that posts to your API is enough. If you already run OAuth for your website, Chrome’s identity API documents chrome.identity.launchWebAuthFlow for non-Google providers, with a redirect to https://<app-id>.chromiumapp.org/. It needs the identity permission, so only add it if you use it.
  • Use your account ID in the link. Not an installation ID, which changes on reinstall, and not an email address. The user_id accepts 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -. Use the same ID on your website so both share one balance.
  • Store only a session token in the extension. Chrome’s storage API page says to use storage.session for sensitive user data. It lives in memory and is cleared when the browser restarts, so users sign in again after a restart. If that is too often for your product, keep the token in storage.local, give it an expiry, and let users revoke it from your website.

Step 2: an endpoint that returns the signed link

The Sharklio offerwall link is https://wall.sharklio.com/{APP_ID}?user_id={USER_ID}&hash={HASH}. The hash is an HMAC-SHA256 of the user ID with your app’s link hash salt, as lowercase hex. Every file in an extension package can be unzipped and read by anyone who installs it, so the salt stays on your server. A Node.js endpoint behind your normal authentication, using the code from the Offerwall link docs:

const crypto = require('crypto');

app.get('/offerwall-link', requireAuth, (req, res) => {
  const userId = String(req.user.id);
  const linkHashSalt = process.env.SHARKLIO_LINK_SALT;
  const appId = process.env.SHARKLIO_APP_ID;
  const hash = crypto.createHmac('sha256', linkHashSalt).update(String(userId)).digest('hex');
  const url = `https://wall.sharklio.com/${appId}?user_id=${encodeURIComponent(userId)}&hash=${hash}&sub1=extension`;
  res.json({ url });
});

requireAuth stands for your own middleware that checks the bearer token and sets req.user. The PHP and Python versions are in the same docs page.

The sub1=extension value is optional. It comes back in every postback as {sub1} and can group your numbers in the Reporting API, so you can compare extension users with website users. Sub IDs are not part of the hash, so users can change them: use them for tracking, never for crediting.

Step 3: the manifest

The popup needs three things from the manifest: the popup file itself, permission to store the session token, and permission to call your API. A minimal manifest.json:

{
  "manifest_version": 3,
  "name": "Tab Tidy",
  "version": "1.4.0",
  "action": {
    "default_title": "Tab Tidy",
    "default_popup": "popup.html"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://api.example.com/*"]
}
  • No tabs permission. The chrome.tabs reference says creating a tab needs no permission. The tabs permission only lets you read sensitive tab fields such as the URL and title, and asking for it adds a warning at install time for nothing.
  • Host permission for your API only. Chrome’s cross-origin network requests guide says extension pages can call a remote server when its host is listed in host_permissions; without it, the request is treated as cross-origin and your server would need CORS headers. You never call wall.sharklio.com from the extension, so it does not belong here.
  • Separate script file. Chrome’s popup guide says any JavaScript used in a popup must be in a separate file. Inline onclick handlers and inline <script> blocks will not run.

The popup page is small. A balance line, a sign-in form for signed-out users, and the button:

<p id="balance"></p>
<form id="signin" hidden>
  <input name="email" type="email" required>
  <input name="password" type="password" required>
  <button type="submit">Sign in</button>
</form>
<button id="earn" hidden>Earn points</button>
<p id="note">Offers come from third-party advertisers and open in a new tab.</p>
<script src="popup.js"></script>

Then popup.js, which loads the balance every time the popup opens and opens the wall on click:

const API = "https://api.example.com";
const store = chrome.storage.session;

async function api(path, options = {}) {
  const { token } = await store.get("token");
  const res = await fetch(API + path, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: "Bearer " + (token || "") }
  });
  if (res.status === 401) {
    await store.remove("token");
    throw new Error("signed-out");
  }
  if (!res.ok) throw new Error("http-" + res.status);
  return res.json();
}

async function render() {
  const { token } = await store.get("token");
  document.getElementById("signin").hidden = Boolean(token);
  document.getElementById("earn").hidden = !token;
  if (!token) return;
  try {
    const me = await api("/me");
    document.getElementById("balance").textContent = me.points + " points";
  } catch (err) {
    if (err.message === "signed-out") return render();
    document.getElementById("balance").textContent = "Balance not available right now.";
  }
}

document.getElementById("signin").addEventListener("submit", async (event) => {
  event.preventDefault();
  const form = new FormData(event.target);
  try {
    const { token } = await api("/login", {
      method: "POST",
      body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
    });
    await store.set({ token });
  } catch (err) {
    document.getElementById("note").textContent = "Sign-in failed. Check your details.";
  }
  render();
});

document.getElementById("earn").addEventListener("click", async (event) => {
  const button = event.target;
  button.disabled = true;
  try {
    const { url } = await api("/offerwall-link");
    if (typeof url === "string" && url.startsWith("https://wall.sharklio.com/")) {
      await chrome.tabs.create({ url });
    }
  } catch (err) {
    document.getElementById("note").textContent = "Offers are not available right now.";
    button.disabled = false;
  }
});

render();

A few details in that code matter:

  • A fresh link on every click. Do not cache the URL. Fetching it each time means it always belongs to the account signed in now.
  • Check the prefix. Opening only URLs that start with https://wall.sharklio.com/ means a bug or a compromised API response cannot make your extension open an arbitrary page.
  • Do the work before the tab opens. Chrome’s popup guide says popups close automatically when the user focuses anything outside them, and there is no way to keep a popup open after that. Once the new tab is in front, expect the popup to be gone, so nothing important should run after chrome.tabs.create.
  • Refresh on open. The popup document is built fresh each time the user clicks your toolbar icon, so render() runs on every open. That is the simplest way to show new points: the user finishes an offer in the tab, clicks your icon, and sees the updated balance.

The same code works on an options page. There it stays open while the wall tab is in use, so also call render() when the page becomes visible again, for example on the visibilitychange event.

Why a new tab and not an iframe in the popup

A wall inside the popup looks tidier, but it fails:

ApproachWorks?Why
New tab with chrome.tabs.createYesA direct link opens anywhere, the page has full width, and it stays open while the user completes an offer
Iframe of the wall in the popupNoThe Sharklio wall only loads inside frames on the https website you registered for the app, and an extension page is not that website
embed.js floating button in the popupNoIt is a remote script; MV3 only runs scripts that ship in your package

Even without the frame restriction, the popup is a poor place for a wall. The chrome.action reference says a popup must be between 25x25 and 800x600 pixels, and the popup closes as soon as the user clicks elsewhere, which is exactly what happens when an offer opens a store page or a sign-up form.

Content Security Policy is the other half. The MV3 CSP reference gives the default policy for extension pages as script-src 'self'; object-src 'self'; and says it cannot be loosened to allow remote script sources. That is why the floating button script, which is the right tool for websites (see Add a floating Earn button to any website), has no place on extension pages. If you also run a website, embed the wall there and compare the options in Offerwall integration: iframe, link or API?

Remote code rules: why a wall in a tab is fine

Chrome’s remotely hosted code guide defines it as anything the browser executes that is loaded from somewhere other than the extension’s own files, and says data such as JSON or CSS is not remote code. The Chrome Web Store Manifest V3 requirements add that an extension’s full functionality must be discernible from its submitted code, list a <script> tag pointing outside the package and eval() of fetched strings as violations, and allow remote communication for things like syncing user account data.

The pattern in this guide stays inside those lines:

  • Your extension fetches JSON (a link and a balance) from your own API. That is data, not code.
  • The wall is an ordinary website opened in an ordinary tab. Its scripts run as that website, not inside your extension.
  • Nothing in the extension loads a script, a module, or a library from a CDN or from Sharklio.

One trap: Chrome’s guide names a library that loads remote code at runtime as a violation, so bundle every dependency.

Chrome Web Store policies for an earn feature

The Ads and Affiliate Ads policies do not mention offerwalls by name, so read them broadly. How each one applies to the build above (checked on October 21, 2026):

PolicyWhat it saysWhat to do
AdsAds are part of your product for review, must be presented in context or say which product they are bundled with, must be easy to remove, and users may not be forced to click ads to use the productLabel the button as rewards from third-party offers, keep every core feature usable without it, and add a setting that hides it
Affiliate AdsAny affiliate program must be described prominently in the store listing, in the interface, and before installation, and each affiliate link, code, or cookie needs a related user actionDescribe the earn feature in your listing and onboarding, and open the wall only from a click on your button
Spam FAQThe Spam FAQ says incentives to download a Chrome Web Store extension are not allowedNever give points for installing your extension
User dataThe User Data FAQ requires a privacy policy for any product that handles user data, and secure transmission such as HTTPSName the offerwall partner and the user ID it receives in your privacy policy, and call your API over https only

The disclosure wording itself, and how close it should sit to the button, is the same problem as any affiliate disclosure; Affiliate disclosure rules: what the FTC requires covers it. If you are on the other side, paying to grow your extension, How to promote a browser extension or desktop app covers which promotion methods stay within the rules.

Builds that get rejected or abused

The link hash salt or postback key inside the package, a points balance kept in chrome.storage and trusted, the wall loaded in a popup iframe, embed.js or any CDN script on an extension page, an extension that reports “offer completed” to your server, points for installing or rating the extension, and notifications about offers that users never asked for.

Step 5: credit points from postbacks

When a user completes an offer, Sharklio calls your postback URL server to server. That call is the only thing that may change a balance. Your handler:

  1. Verifies {hash}: an HMAC-SHA256 of transaction ID, user ID, reward, and status joined with colons, signed with your postback secret key, which is a different value from the link salt.
  2. On status 1 (credited), adds {reward} once per {transaction_id}.
  3. On status 2 (reversed), takes it back only if you credited that transaction.
  4. Answers with a 2xx status within 6 seconds, with no redirect.

A complete Express handler, with the timing-safe comparison and the duplicate check, is in Offerwall postback in Node.js, Express and Next.js. What is a postback URL? explains the macros, and Postback security: verify every reward call lists every check.

Some offers credit after a review or a later step, so say so next to the balance and point users to My offers on the wall.

Firefox and Edge

Microsoft’s porting guide says the extension APIs and manifest keys Chrome supports are code-compatible with Edge, so this build ports with minimal changes; Edge asks you to remove update_url and to rebrand if Chrome appears in your name or description. Firefox documents tabs.create under the browser namespace with a Promise, and Mozilla’s own add-on policies apply there. Test each browser separately before you publish.

Step 6: test before you submit

  1. Load the unpacked extension from chrome://extensions with developer mode on, and sign in with a test account. Note its user ID.
  2. In Integration status on the Sharklio Integration tab, click Check a link and use Test a link with that ID. The result must match what your /offerwall-link endpoint returns exactly.
  3. Click Earn and check that the wall opens in a new tab, not the Link not valid page. Test from a normal home or mobile connection: VPNs, proxies, Tor, and data center networks are blocked from the wall.
  4. Use Send a test postback with status 1 for the test user. Open the popup again: the balance should go up once. A status 2 test uses a new test_ transaction ID you never credited, so it must change nothing.
  5. Open the popup’s DevTools (right-click the popup, Inspect) and confirm that every request goes to your API and nothing loads a remote script.

Every postback attempt and your server’s answer is listed under Reports, Postbacks. The Testing and troubleshooting docs list common errors, and How to test your app before it goes live walks through the full pre-launch pass.

A Sharklio offerwall link for your extension

Sharklio is a pay-per-result affiliate network, and every Sharklio app has an offerwall link that suits an extension: your server signs it, your popup opens it in a tab, and nothing ships in your package. You set your own currency name and icon, rate, user split, rounding, and wall design, and every credit and reversal reaches your server as a signed postback carrying your sub IDs. Every publisher application and every app is reviewed by hand before the wall goes live. See how the wall works, prepare with Get your publisher account approved, or get in touch with questions.

Frequently asked questions

Can a Chrome extension show an offerwall in its popup?

Not the Sharklio wall: it only loads inside frames on the website you registered for the app. Open it in a new tab with chrome.tabs.create, which also avoids the popup’s 800x600 size limit and its habit of closing when the user clicks away.

Does chrome.tabs.create need the tabs permission?

No. Chrome’s tabs reference says creating a tab needs no permission; the tabs permission only gives access to sensitive fields such as a tab’s URL and title.

Is opening an offerwall in a tab remote code under Manifest V3?

No. Remote code means scripts your extension executes from outside its package. Opening a website in a normal tab and fetching JSON from your own API are both allowed patterns.

Where should the offerwall link be built?

On your server. The link hash salt signs the user ID, and anything inside an extension package can be read by anyone who installs it, so the extension only receives the finished link.

How does the extension know when a user earned points?

It does not need to. The offerwall sends a postback to your server, your server updates the balance, and the popup reads the balance from your API each time it opens.

Can I use chrome.identity to identify users for the offerwall?

You can use launchWebAuthFlow to sign users in to your own account system, but the ID in the wall link should be your account ID, which stays the same across reinstalls, browsers, and your website.