Key takeaways

  • An Electron app ships its code to every user, so the link hash salt stays on your backend and the main process only fetches the finished, signed link.
  • Opening the wall in the user's browser with shell.openExternal is the simplest and safest option; an in-app BrowserWindow needs context isolation, the sandbox, no preload, and navigation and window-open handlers.
  • Points change only on your server, from verified postbacks, and the app reloads the balance from your API when its window gets focus.

How do you add an offerwall to an Electron app?

To add an offerwall to an Electron app, have your backend sign the offerwall link for the signed-in user, let the main process fetch it when the user clicks “Earn”, and open it in the default browser with shell.openExternal. To keep the wall inside your app, use a separate BrowserWindow with context isolation, the sandbox, no Node.js, no preload, and handlers that send off-wall links to the browser. Your server credits points only from signed postbacks.

The code below was tested on Electron 45.0.0. Whether rewarded offers suit your product is covered in How to monetize a browser extension with an offerwall, which applies to most desktop tools too. A web version can reuse the same endpoint; see the Next.js and React offerwall guide.

Who does what, and why the app holds no secret

PartDoesNever does
Renderer (your page)Shows the Earn button and balanceHolds tokens, opens URLs, uses Node.js
Main processHolds the session token, fetches the link, opens itHolds the link hash salt or postback key
Your backendSigns the link, receives postbacks, owns the balanceTrusts the app about rewards
SharklioShows offers for the user’s country and device, sends signed postbacksPays your users directly

An Electron app ships its JavaScript to every user. Electron’s ASAR archives page only promises to hide your source “from cursory inspection”. Anyone who unpacks the archive and finds the link hash salt can sign links for any user ID, so the salt and the postback secret key stay on your server.

Step 1: 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. This Express route uses the Node.js code from the Offerwall link docs:

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(String(userId)).digest('hex');
  const url = `https://wall.sharklio.com/${process.env.SHARKLIO_APP_ID}?user_id=${encodeURIComponent(userId)}&hash=${hash}&sub1=desktop`;
  res.json({ url });
});
  • requireAuth stands for your own middleware that checks the bearer token and sets req.user.
  • Use your account ID as user_id. 1 to 100 characters from A-Z a-z 0-9 . _ @ : + -, never changing for the same user. Not a machine ID or an email address.
  • sub1=desktop is optional. It returns in every postback as {sub1}. Sub IDs are not part of the hash, so never credit by them.

If the salt ever leaks, see How to rotate your keys and link salt.

Step 2: open the wall in the user’s browser (recommended)

The renderer asks the main process to open the wall; the main process fetches the link, checks it, and hands it to the operating system. First main.js:

const { app, BrowserWindow, ipcMain, shell } = require('electron');
const path = require('node:path');

const API = 'https://api.example.com';
const WALL_ORIGIN = 'https://wall.sharklio.com';
let mainWindow;

function isWallUrl(value) {
  try {
    return new URL(value).origin === WALL_ORIGIN;
  } catch {
    return false;
  }
}

async function apiGet(route) {
  const token = await getSessionToken();
  const res = await fetch(API + route, { headers: { Authorization: 'Bearer ' + token } });
  if (!res.ok) throw new Error('http-' + res.status);
  return res.json();
}

function fromMainWindow(event) {
  if (!mainWindow || event.sender !== mainWindow.webContents) throw new Error('blocked');
}

ipcMain.handle('offerwall:open', async (event) => {
  fromMainWindow(event);
  const { url } = await apiGet('/offerwall-link');
  if (!isWallUrl(url)) throw new Error('unexpected-url');
  await shell.openExternal(url);
});

ipcMain.handle('wallet:balance', async (event) => {
  fromMainWindow(event);
  const { points } = await apiGet('/me');
  return { points };
});

app.whenReady().then(() => {
  mainWindow = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true
    }
  });
  mainWindow.loadFile('index.html');
});

getSessionToken() stands for your own login code. To keep the token on disk, use Electron’s safeStorage with encryptStringAsync and decryptStringAsync; the docs say the synchronous methods will be removed in Electron 46.

Then preload.js, which exposes exactly two functions to your page:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('earn', {
  openOfferwall: () => ipcRenderer.invoke('offerwall:open'),
  getBalance: () => ipcRenderer.invoke('wallet:balance')
});

And renderer.js, loaded by an index.html with an Earn button, a balance line, a note, and a script-src 'self' Content Security Policy:

const button = document.getElementById('earn');
const note = document.getElementById('note');
const balance = document.getElementById('balance');

async function refreshBalance() {
  try {
    const { points } = await window.earn.getBalance();
    balance.textContent = points + ' points';
  } catch {
    balance.textContent = 'Balance not available right now.';
  }
}

button.addEventListener('click', async () => {
  button.disabled = true;
  try {
    await window.earn.openOfferwall();
    note.textContent = 'The offerwall opened in your browser. New points show here when they arrive.';
  } catch {
    note.textContent = 'Offers are not available right now.';
  } finally {
    button.disabled = false;
  }
});

window.addEventListener('focus', refreshBalance);
refreshBalance();

Why it is built this way:

  • The main process does the work. The shell API does not work in a sandboxed renderer, the token never reaches the page, and your API needs no CORS rules.
  • One method per IPC message. Electron’s context isolation guide warns against exposing ipcRenderer.send directly and recommends one narrow method per message.
  • Check the sender and the URL. The Electron security checklist asks you to validate the sender of all IPC messages and not to use shell.openExternal with untrusted content, because that “can be leveraged to execute arbitrary commands.” isWallUrl compares the parsed origin; the same page warns that a startsWith test would let https://example.com.attacker.com through.
  • A fresh link per click, a refresh on focus. The link always matches the current account.

Step 2, alternative: a secure wall window inside your app

If leaving the app is a real problem for your users, replace shell.openExternal(url) in the offerwall:open handler with openWallWindow(url), and add this to main.js (with session added to its require):

const { session } = require('electron');

let wallWindow;

function isHttpsUrl(value) {
  try {
    return new URL(value).protocol === 'https:';
  } catch {
    return false;
  }
}

function openWallWindow(url) {
  if (wallWindow && !wallWindow.isDestroyed()) {
    wallWindow.loadURL(url);
    wallWindow.focus();
    return;
  }
  const wallSession = session.fromPartition('offerwall');
  wallSession.setPermissionRequestHandler((contents, permission, callback) => callback(false));
  wallSession.setPermissionCheckHandler(() => false);

  wallWindow = new BrowserWindow({
    width: 1100,
    height: 800,
    parent: mainWindow,
    title: 'Earn points',
    autoHideMenuBar: true,
    webPreferences: {
      session: wallSession,
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true
    }
  });

  const contents = wallWindow.webContents;
  contents.setWindowOpenHandler(({ url: target }) => {
    if (isHttpsUrl(target)) shell.openExternal(target);
    return { action: 'deny' };
  });
  const stayOnWall = (event) => {
    if (isWallUrl(event.url)) return;
    event.preventDefault();
    if (isHttpsUrl(event.url)) shell.openExternal(event.url);
  };
  contents.on('will-navigate', stayOnWall);
  contents.on('will-redirect', stayOnWall);
  wallWindow.on('closed', () => {
    wallWindow = null;
  });
  wallWindow.loadURL(url);
}

Each line maps to the security checklist:

  • contextIsolation, nodeIntegration: false, sandbox. Defaults since Electron 12, 5, and 20; writing them out guards against a stray override.
  • No preload. The wall is remote content, and the checklist says not to expose Electron APIs, especially IPC, to untrusted web content. Your window.earn bridge exists only in your own window.
  • A separate session that denies permissions. The checklist notes that Electron approves all permission requests unless you set a handler. A partition without the persist: prefix is an in-memory session, cleared when the app quits.
  • New windows go to the browser. The window-open handler runs for window.open() and target="_blank" links. The wall opens offers in a new tab, so each offer continues in the user’s browser. Only https: URLs are handed over.
  • Navigation stays on the wall. Per the webContents docs, will-navigate covers link clicks and window.location changes, and will-redirect covers server redirects. Anything leaving wall.sharklio.com opens in the browser instead.

Setups that fail or open holes

An <iframe> of the wall in your own page (the wall only loads in frames on the https website you registered for the app, and your app’s page is not that site), a <webview> tag (Electron’s webview docs recommend against it), nodeIntegration: true or your preload on the wall window, the salt or postback key anywhere in the app, and an app that tells your server “offer completed”.

Browser or wall window: the trade-offs

shell.openExternalWall BrowserWindow
Code to secureOne IPC handlerWindow settings, session, three handlers
User stays in your appNo, the browser opensYes, for browsing the wall
Offers themselvesRun in the browserAlso run in the browser, through the window-open handler
Saved passwords and sign-insThe user’s own browser profileAn empty session, cleared on quit
Address bar the user can checkYesNo

Offers end up in the browser either way, on purpose. Many offers ask users to sign in, and RFC 8252 says native apps “MUST NOT use embedded user-agents” for OAuth because the host app can read credentials; Google’s OAuth guide for desktop apps says desktop apps must open the system browser. Store links also work best there.

Start with shell.openExternal. Move to the wall window only after testing it on Windows, macOS, and Linux. The broader comparison is in Offerwall integration: iframe, link or API?

What desktop users see

  • Offers depend on country and device. Desktop users see only desktop offers for their country, which can be fewer than on a phone. The postback names the device in {device}.
  • VPNs and data centers are blocked. VPN, proxy, Tor, and data center users see a block page. Say so near your Earn button.
  • Age. End users must be at least 16, with parental permission under 18.

Step 3: credit points from postbacks

Only Sharklio’s server-to-server postback changes a balance. Per the Postbacks docs, your handler:

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

A full Express handler is in Offerwall postback in Node.js, Express and Next.js; see also how to prevent duplicate reward credits and Postback security: verify every reward call. Some offers credit later, so point users to My offers on the wall.

Distribution rules for an earn feature

Where you ship decides the rules (checked on October 22, 2026). For your own download, your terms should describe the feature and say rewards can be reversed.

ChannelWhat the rules sayWhat to do
Microsoft StoreStore Policies 10.10.4: advertising must not be the primary content and must be clearly distinguishable; 10.10.5: your privacy statement must say you send personal information to the ad service providerKeep the wall optional and labeled; update your privacy statement
Mac App StoreApple’s App Review Guidelines 3.2.2(x): apps must not force users to rate, review, or download other apps to use the appNever gate features behind offers, and never reward ratings or reviews
SteamValve’s Advertising on Steam page says games should not reward players for engaging with advertisingLeave the wall out of a Steam game build; see Can you put ads in a Steam game?

Read the current versions before you submit. To grow the app itself, see How to promote a browser extension or desktop app.

Step 4: test before you ship

  1. Sign in with a test account. In Integration status on the Integration tab, use Check a link, then Test a link with its ID; the result must match your endpoint’s link.
  2. Click Earn from a normal home connection, not a VPN or cloud machine. The wall must open, not Link not valid.
  3. Use Send a test postback with status 1; the balance must rise once when you switch back. A status 2 test has a new test_ transaction ID you never credited, so nothing changes.
  4. With the wall window, check that a real offer opens in the browser, not a second Electron window.

We ran this code on Electron 45.0.0 on Linux against local stand-ins for your API and the wall. Earn passed the exact link to openExternal; neither page could reach Node.js; in the wall window, new-tab links, off-site links, and a 302 to another site all went to the browser, a file: link was dropped, and a notification request was denied. The full pre-launch pass is in How to test your app before it goes live, and the Testing docs list the common errors.

A Sharklio offerwall for your desktop app

Sharklio is a pay-per-result affiliate network. For an Electron app, you use the offerwall link: your server signs it, the app opens it, and nothing secret ships in your build. 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. Every publisher application is reviewed by hand. See how the wall works, prepare with Get your publisher account approved, or get in touch with questions.

Frequently asked questions

How do I open an external URL from an Electron app?

Call shell.openExternal(url) from the main process. Electron’s security checklist says never to pass it untrusted content, so check the parsed URL first.

Can I show an offerwall inside an Electron window?

Yes, in a separate BrowserWindow with context isolation, the sandbox, no Node.js, no preload, and handlers that send new windows and off-wall navigation to the browser. An iframe in your own page does not work, because the Sharklio wall only allows frames on the website registered for the app.

Where should I build the offerwall hash in an Electron app?

On your server. Everything in an Electron package can be unpacked and read, so the app should only receive the finished, signed link for the signed-in user.

How does the Electron app know a user earned points?

It does not need to. Your server gets the postback and updates the balance, and the app reloads it when its window gets focus.

Do offerwalls have offers for desktop users?

Yes, but offers depend on country and device, so desktop users see only desktop offers, which can be fewer than on a phone.