← Back to Guides
UnifiedLicensing Integration Guide

🔌 Chrome Extension
License Validation

Add license keys, trials, and feature gating to your Manifest V3 extension — validated in the background service worker, cached in chrome.storage.local, and enforced everywhere from your popup to your content scripts.

Manifest V3 Service Worker chrome.storage · chrome.alarms ⏱ ~15 min setup

1 Quick Start

Thirty seconds, three steps. By the end you'll have a real license check running inside a service worker.

Step 1 — Make sure your manifest.json allows talking to the API:

{
  "permissions": ["storage", "alarms"],
  "host_permissions": ["https://api.unifiedlicensing.com/*"]
}

Step 2 — Drop this into background.js:

// background.js
const API = 'https://api.unifiedlicensing.com/api/v1/validate-license';

chrome.runtime.onInstalled.addListener(async () => {
  const res = await fetch(API, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key:     'ul_live_YOUR_VENDOR_API_KEY',
      product_key: 'prod_YOUR_PRODUCT_KEY',
      license_key: 'XXXX-XXXX-XXXX-XXXX',
      machine_id:  crypto.randomUUID(),
      platform:    'extension'
    })
  });

  const data = await res.json();
  console.log('License valid:', data.valid);
});

Step 3 — Go to chrome://extensions, enable Developer mode, click Load unpacked, pick your extension folder, then click the service worker link on your extension card to open DevTools and see the result.

Heads up

The quick start hardcodes a license key just to prove the pipe works. Real extensions collect the key from the user in a popup — that's Section 5. The rest of this guide turns this snippet into something shippable.

2 Setup

Before writing code, grab three things from your vendor dashboard:

Extension folder layout

By the end of this guide your extension will look like this:

my-extension/
├── manifest.json
├── background.js          ← validation lives here
├── popup.html             ← license status UI
├── popup.js
├── content.js             ← optional page integration
└── unifiedlicensing.js    ← the SDK, copied in

Copy the SDK in

Download UnifiedLicensing.JavaScript.v4.0-Full.js from your dashboard and copy it into the extension folder as unifiedlicensing.js:

# macOS / Linux
cp ~/Downloads/UnifiedLicensing.JavaScript.v4.0-Full.js ./unifiedlicensing.js

# Windows
copy "%USERPROFILE%\Downloads\UnifiedLicensing.JavaScript.v4.0-Full.js" unifiedlicensing.js

Where the SDK can run (important!)

Manifest V3 gives you two very different JavaScript environments, and they have different superpowers:

EnvironmentHas window / localStorage?Use
Popup, options page, tab pages Yes Full SDK works as-is: new UnifiedLicenseManager({...})
Background service worker No No DOM, no localStorage. Use the lightweight fetch() client shown in Section 4 and cache verdicts in chrome.storage.local.
MV3 CSP: no remote code

Manifest V3 forbids loading scripts over the network — <script src="https://cdn..."> will be blocked by the Content Security Policy. The SDK must live inside your extension package, which is exactly why we copied the file in above. Bonus: bundling means your licensing logic keeps working even if a CDN has a bad day.

3 manifest.json

Here's a complete Manifest V3 manifest wired up for licensing:

{
  "manifest_version": 3,
  "name": "My Awesome Extension",
  "version": "1.0.0",
  "description": "An extension licensed with UnifiedLicensing.",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "permissions": ["storage", "alarms"],
  "host_permissions": [
    "https://api.unifiedlicensing.com/*"
  ],

  "background": {
    "service_worker": "background.js"
  },

  "action": {
    "default_popup": "popup.html",
    "default_title": "My Awesome Extension"
  },

  "content_scripts": [
    {
      "matches": ["https://app.example.com/*"],
      "js": ["content.js"]
    }
  ]
}

Why each piece matters for licensing

Tip

After editing manifest.json, hit the circular reload arrow on your extension card at chrome://extensions. Permission changes sometimes require the extension to be fully removed and re-loaded.

4 Background Script Validation

The service worker is the brain of your licensing system. It's the one place that:

Keep all API calls here, and let everything else ask nicely via messages (that's Section 6).

A stable machine ID

The API wants a machine_id so seats can be counted per install. Generate one UUID per installation and persist it:

// background.js
const API_BASE   = 'https://api.unifiedlicensing.com/api/v1';
const API_KEY    = 'ul_live_YOUR_VENDOR_API_KEY';
const PRODUCT_KEY = 'prod_YOUR_PRODUCT_KEY';

async function ensureMachineId() {
  const { ul_machine_id } = await chrome.storage.local.get('ul_machine_id');
  if (!ul_machine_id) {
    await chrome.storage.local.set({ ul_machine_id: crypto.randomUUID() });
  }
}

async function getMachineId() {
  const { ul_machine_id } = await chrome.storage.local.get('ul_machine_id');
  return ul_machine_id;
}

The core validation call

One function, one HTTP call, honest error handling:

async function validateLicense(licenseKey) {
  try {
    const res = await fetch(`${API_BASE}/validate-license`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        api_key:     API_KEY,
        product_key: PRODUCT_KEY,
        license_key: licenseKey,
        machine_id:  await getMachineId(),
        platform:    'extension'
      })
    });

    if (!res.ok) {
      return { valid: false, error: `HTTP ${res.status}` };
    }

    return await res.json(); // { valid, tier, expires_at, features, ... }
  } catch (err) {
    // Network down, DNS sad, laptop on a plane...
    return { valid: false, error: err.message, offline: true };
  }
}

Validate, then store the verdict

Every successful check writes a snapshot into chrome.storage.local. That snapshot is what the rest of the extension reads — instantly, with zero network:

async function validateAndStore(licenseKey) {
  const result = await validateLicense(licenseKey);

  await chrome.storage.local.set({
    ul_license_key: licenseKey,
    ul_status: {
      valid:          !!result.valid,
      tier:           result.tier || null,
      expires_at:     result.expires_at || null,
      features:       result.features || [],
      trial:          result.tier === 'trial',
      last_validated: Date.now()
    }
  });

  return result;
}

Validate on install, re-validate forever

Two lifecycle hooks do the heavy lifting: chrome.runtime.onInstalled fires on install and update, and chrome.alarms wakes the (long-since-terminated) service worker up every 12 hours for a fresh check:

const ALARM_NAME       = 'ul-revalidate';
const ALARM_PERIOD_MIN = 12 * 60; // every 12 hours

chrome.runtime.onInstalled.addListener(async () => {
  await ensureMachineId();

  // Extension update? Re-validate whatever key we already have.
  const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
  if (ul_license_key) await validateAndStore(ul_license_key);

  // Schedule periodic re-validation.
  chrome.alarms.create(ALARM_NAME, { periodInMinutes: ALARM_PERIOD_MIN });
});

// Also re-validate when the browser starts a new session.
chrome.runtime.onStartup.addListener(async () => {
  const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
  if (ul_license_key) await validateAndStore(ul_license_key);
});

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== ALARM_NAME) return;
  const { ul_license_key } = await chrome.storage.local.get('ul_license_key');
  if (ul_license_key) await validateAndStore(ul_license_key);
});
Why alarms and not setInterval?

MV3 service workers are ephemeral — Chrome kills them after roughly 30 seconds of idle time, which murders any setInterval you were fond of. chrome.alarms is the sanctioned way to schedule future work: Chrome persists the alarm and spins the worker back up when it fires. Never keep licensing state in global variables; always read it back from chrome.storage.local.

6 Content Script Integration

Content scripts run inside web pages, which changes the rules:

The fix is simple: content scripts ask the background for license status via message passing.

The message flow

┌───────────────┐  chrome.runtime.sendMessage   ┌───────────────────┐
├ content.js      ┞ ──────────────────────────▶ ├ background.js (SW)     ├
├ (inside a tab)  ┞                               ├ getStatus()            ├
├                 ┞ ███████████████████████──── ├                        ├
└───────────────┘   sendResponse(status)         └────────────────────┘
                                                                │ reads
                                                    ┌────────────────────┘
                                                    ├ chrome.storage.local   ├
                                                    └────────────────────┘

Content script side

// content.js
chrome.runtime.sendMessage({ type: 'UL_GET_STATUS' }, (status) => {
  if (chrome.runtime.lastError) {
    // Extension was updated/reloaded — this script is orphaned.
    console.warn('[License]', chrome.runtime.lastError.message);
    return;
  }

  if (status?.licensed) {
    enableProFeatures(status);
  } else {
    showUpgradeBanner();
  }
});

function enableProFeatures(status) {
  if (status.features.includes('export-pdf')) {
    document.body.classList.add('pro-export-enabled');
  }
}

function showUpgradeBanner() {
  const banner = document.createElement('div');
  banner.textContent = 'Unlock Pro features — activate your license.';
  banner.style.cssText =
    'position:fixed;top:0;left:0;right:0;z-index:999999;' +
    'padding:8px;text-align:center;background:#C7A34C;color:#081220;' +
    'font:600 14px system-ui;';
  document.body.appendChild(banner);
}

Background side

Add this to the same onMessage listener from Section 5. The getStatus() helper reads only from storage — fast enough to answer while the page loads:

const GRACE_MS = 72 * 60 * 60 * 1000; // 3-day offline grace window

async function getStatus() {
  const { ul_status, ul_license_key } =
    await chrome.storage.local.get(['ul_status', 'ul_license_key']);

  if (!ul_license_key || !ul_status) {
    return { licensed: false, reason: 'not_activated' };
  }
  if (!ul_status.valid) {
    return { licensed: false, reason: 'invalid' };
  }
  if (ul_status.expires_at && new Date(ul_status.expires_at) < new Date()) {
    return { licensed: false, reason: 'expired' };
  }
  if (Date.now() - ul_status.last_validated > GRACE_MS) {
    return { licensed: false, reason: 'grace_period_exceeded' };
  }

  return {
    licensed: true,
    tier: ul_status.tier,
    trial: ul_status.trial,
    features: ul_status.features
  };
}

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'UL_GET_STATUS') {
    getStatus().then(sendResponse);
    return true;
  }
  // ... UL_ACTIVATE handler from Section 5 ...
});
"Extension context invalidated"

When you ship an update, old tabs still run the old content script, but its background worker is gone. The chrome.runtime.lastError check above handles that gracefully. If you want those tabs to recover, listen for window.addEventListener('load', ...) retries or ask users to refresh — Chrome does not auto-inject new content scripts into existing tabs.

7 Trial Support

Treat a trial as just another license — one with tier: "trial" and an expires_at date. The server tracks the expiry; your extension renders the countdown.

Starting a trial

Create a trial key in your dashboard (or embed a public one for self-serve trials), then validate it exactly like any other key:

// background.js
const TRIAL_KEY = 'UL-TRIAL-8F3K-2M9Q'; // issued from your dashboard

async function startTrial() {
  const result = await validateAndStore(TRIAL_KEY);
  return result; // { valid: true, tier: 'trial', expires_at: '...' }
}

Wire it to the popup's trial button:

// popup.js
$('trial-btn').addEventListener('click', async () => {
  $('message').textContent = 'Starting trial…';
  const res = await chrome.runtime.sendMessage({ type: 'UL_START_TRIAL' });
  $('message').textContent = res.valid ? '✓ Trial started!' : `✗ ${res.error}`;
  render();
});
// background.js — add to your onMessage listener
if (msg.type === 'UL_START_TRIAL') {
  startTrial().then(sendResponse);
  return true;
}

Trial countdown in the popup

Because expires_at is already in storage, the countdown is pure math:

// popup.js — inside render(), when status.trial is true
if (ul_status.expires_at) {
  const msLeft = new Date(ul_status.expires_at) - Date.now();

  if (msLeft > 0) {
    const days  = Math.floor(msLeft / 86400000);
    const hours = Math.floor((msLeft % 86400000) / 3600000);
    $('trial-info').hidden = false;
    $('trial-info').textContent =
      `${days}d ${hours}h left in your trial`;
  } else {
    badge.textContent = 'Trial ended';
    badge.className = 'badge inactive';
    $('activate-form').hidden = false; // time to buy :)
  }
}
Can't users just reinstall to reset the trial?

Uninstalling wipes chrome.storage.local, including ul_machine_id — so a reinstall looks like a brand-new machine. For casual products that's fine. If trials are expensive to you, gate trial starts behind an email signup on your server, where one identity = one trial regardless of reinstalls.

8 Offline Mode

chrome.storage.local is your offline cache. Every successful validation writes a verdict plus a timestamp (last_validated). When the network disappears, you trust that cache for a grace period instead of hard-locking your users mid-flight.

SituationWhat happensUser sees
Online, license valid Fresh verdict stored with new timestamp Full access
Offline, cache within grace window Cached verdict used, flagged cached: true Full access + "offline mode" hint
Offline, cache older than grace window Status treated as unlicensed until next successful check Features locked
Online, server says invalid/expired Negative verdict stored immediately Features locked

The fallback wrapper

async function validateWithFallback(licenseKey) {
  const online = await validateLicense(licenseKey);

  if (!online.offline) return online; // got a real answer from the server

  // Network failed — decide from the cache.
  const { ul_status } = await chrome.storage.local.get('ul_status');
  const cacheFresh = ul_status?.valid &&
    Date.now() - ul_status.last_validated < GRACE_MS;

  if (cacheFresh) {
    return { ...ul_status, cached: true };
  }

  return { valid: false, error: 'Offline and no valid cached license' };
}

Use validateWithFallback() anywhere you'd otherwise call validateLicense() directly — the periodic alarm in Section 4 is a great candidate, since it's exactly the check that runs when the user might be offline.

Tuning the grace window

Storage survives everything except uninstall

chrome.storage.local persists across browser restarts, crashes, and updates. It's only cleared when the user fully removes the extension (or clears extension data). That makes it ideal for license caches — and remember, clearing it also regenerates ul_machine_id.

9 Feature Gating

Don't just check the license once on install — gate every premium feature on every use. A pirate who deletes your check has to rewrite every gate call, not just one.

The gate function

// background.js — reusable gate function
async function isPremiumEnabled() {
  const { ul_valid, ul_tier } = await chrome.storage.local.get(['ul_valid', 'ul_tier']);
  return ul_valid && ul_tier !== 'free';
}

// Use it before any premium action
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'UL_UNLOCK_FEATURE') {
    isPremiumEnabled().then(enabled => {
      sendResponse({ allowed: enabled });
    });
    return true; // async response
  }
});

Content script gating

// content.js — check before injecting premium UI
async function initPremiumFeatures() {
  const response = await chrome.runtime.sendMessage({ type: 'UL_UNLOCK_FEATURE' });
  if (!response.allowed) {
    document.body.classList.add('ul-free-only');
    return;
  }
  injectPremiumUI();
}
initPremiumFeatures();
Why this matters

A pirate who edits background.js to always return { success: true } still has to find and remove every isPremiumEnabled() call across your content scripts, popup, and background logic. More gates = more work = most pirates give up.

10 Security Hardening

Chrome extensions are code-signed by the Web Store, which makes tampering harder than WordPress. But unpacked/sideloaded extensions, DevTools, and chrome.storage.local are all accessible. Here's how to raise the bar.

10.1 — Obfuscate storage keys

Don't use obvious key names like ul_license_key. A pirate searching DevTools can spot them instantly:

// BAD — obvious, searchable
await chrome.storage.local.get('ul_license_key');

// BETTER — opaque key names
const SK = {
  k: 'u4k_lic_v2',
  v: 'u4k_val_ts',
  s: 'u4k_status',
  t: 'u4k_tier',
  m: 'u4k_mid',
};
await chrome.storage.local.get(SK.k);

10.2 — Integrity check

Store a checksum of your license data. If someone edits the storage directly, the checksum won't match:

// After every successful validation
const checksum = await computeChecksum(data);
await chrome.storage.local.set({ ul_data: data, ul_cs: checksum });

async function computeChecksum(d) {
  const enc = new TextEncoder();
  const hash = await crypto.subtle.digest('SHA-256', enc.encode(JSON.stringify(d)));
  return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}

// Before using license data, verify integrity
async function getVerified() {
  const { ul_data, ul_cs } = await chrome.storage.local.get(['ul_data', 'ul_cs']);
  if (!ul_data) return null;
  const expected = await computeChecksum(ul_data);
  if (ul_cs !== expected) {
    await chrome.storage.local.clear(); // tampered — force re-validation
    return null;
  }
  return ul_data;
}

10.3 — Keep gating in the background

Never put premium logic in content scripts alone — it's visible in DevTools Sources tab. Keep gating in the background and only send the result:

// content.js — thin client, no premium logic
async function checkAccess() {
  const response = await chrome.runtime.sendMessage({ type: 'UL_UNLOCK_FEATURE' });
  return response.allowed;
}

// Don't do this:
// const isPremium = licenseData.tier === 'pro';  // pirate can see and edit

10.4 — Summary

LayerWhat it stopsEffort to bypass
Periodic re-validationStale cached licensesDisable alarms
Feature gating functionSimple deletion of checksRewrite all gate calls
Obfuscated storage keysQuick DevTools searchFind the real keys
Integrity checksumDirect storage editsRecalculate hash
Background-only gatingContent script editsModify service worker

Chrome extensions are already harder to crack than WordPress because the Web Store signs the code. Stack these layers and most pirates won't bother.

11 Troubleshooting

Common issues and how to fix them:

"Extension context invalidated"

You shipped an update while a tab was running the old content script. The chrome.runtime.lastError check in Section 6 handles this. Ask the user to refresh the tab.

Service worker won't stay alive

MV3 service workers terminate after ~30 seconds of inactivity. Use chrome.alarms (not setInterval) for periodic work. Always read state from chrome.storage.local, never from global variables.

API call blocked from content script

Content scripts don't get host_permissions. Route all API calls through the background script via chrome.runtime.sendMessage().

Storage cleared on reinstall

This is by design. chrome.storage.local is only cleared on full extension removal. But reinstalling wipes ul_machine_id, so it looks like a new device. If that's a problem, gate trial starts behind email on your server.

License validates but features stay locked

Check that ul_valid and ul_tier are actually in chrome.storage.local. Open DevTools on the service worker and inspect storage. If the keys are missing, the validation response may not have been stored correctly.

Still stuck?

Open the service worker DevTools, check the Console for errors, and reach out via the support channels on the guides index.