Skip to content

Matomo in a Chrome Extension: Setup, Limits, and Alternatives

9 min readModerok team

Using Matomo in a Chrome extension: why the JavaScript snippet cannot run in an MV3 service worker, and what the HTTP Tracking API gives you instead.

You can send data to Matomo from a Chrome extension, but not the way Matomo documents it. The documented browser install builds a <script> element with document.createElement('script') and loads matomo.js from your Matomo server (Matomo, as of October 2026). A Manifest V3 background service worker "cannot access the DOM, though you can use it if needed with offscreen documents" (Chrome), and Chrome's migration guide says "You can no longer load and execute remotely hosted files according to Chrome Web Store policy" (Chrome). What does work is Matomo's HTTP Tracking API, called with fetch().

TL;DR: The Matomo JavaScript tracker is a page snippet that injects a remote matomo.js (Matomo, as of October 2026), and both halves of that are blocked in MV3. The HTTP Tracking API works: POST to matomo.php with idsite and rec=1 required, and a visitor _id that "must be a 16 characters hexadecimal string" (Matomo, as of October 2026). Generating and persisting that id is your code, and so is every lifecycle event: the same reference documents no install, update or uninstall parameter.

The 30-second comparison

WhatWhat the page saysSource
Matomo browser installSnippet pushes ['setTrackerUrl', u+'matomo.php'], then g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);, pasted "just after the opening <body> tag (or within the <head> section)"Matomo developer docs, as of October 2026
MV3 service worker and the DOM"an extension service worker cannot access the DOM, though you can use it if needed with offscreen documents"Chrome
MV3 remote code"You can no longer load and execute remotely hosted files according to Chrome Web Store policy"Chrome
Extension page CSPThe minimum extension_pages policy is "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';", and it "cannot be relaxed beyond this minimum value. In other words, you cannot add other script sources to directives"Chrome
Matomo HTTP Tracking APImatomo.php, GET or POST; idsite required; rec "Required for tracking, must be set to one, eg, &rec=1"Matomo, as of October 2026
Matomo visitor id_id "must be a 16 characters hexadecimal string"; "Every unique visitor must be assigned a different ID and this ID must not change after it is assigned"Matomo, as of October 2026
Without that id"If this value is not set Matomo will still track visits, but the unique visitors metric might be less accurate."Matomo, as of October 2026
Parameters needing a tokentoken_auth, a "32 character authorization key used to authenticate the API request", is required for cip, country, region, city, lat and long (and for cdt only when it is "older than 24 hours")Matomo, as of October 2026
Calling that endpoint from MV3"A script executing in an extension service worker or foreground tab can talk to remote servers outside of its origin, as long as the extension requests host permissions."Chrome
Matomo tracking clientsA "JavaScript SDK" for websites, "Mobile SDKs" (iOS, Android, Appcelerator Titanium) and "Client libraries for Apps or Server-Side tracking" (PHP, Java, C#); under "Other clients", "Other community contributed clients are also available in C++, Python, Node.js, JavaScript Frameworks"Matomo, as of October 2026
What Matomo bills"A hit is a single interaction sent to Matomo and processed for reporting or analysis", including page views, events, downloads, outlinks and crashesMatomo, as of October 2026
Matomo Cloud pricePriced per hits-per-month tier; the page's own structured pricing data puts the 50,000 hits/month Cloud tier at 22 EUR or 26 USD per month, tax not included, through 1600 EUR at the 10 million hits/month tier, with larger tiers above that and a custom Enterprise offerMatomo pricing, as of October 2026
Matomo On-PremiseCommunity edition: "Free" "forever", "Unlimited users and hits"Matomo pricing, as of October 2026
Self-hosting requirements"Matomo 4.16 and Matomo 5 : PHP 8 and MySQL version 5.5 or greater, or MariaDB"; the Matomo 6.0.0 row, which the page follows with "See Matomo 6.0.0 Beta", asks for "PHP version 8.1 or newer" and "MySQL 8.0 or newer / MariaDB 10.6 or newer"Matomo, as of October 2026
Moderok installnpm package, the storage permission, and Moderok.init({ appKey }) in the service workerModerok docs

Round 1Why the Matomo snippet cannot run in a Chrome extension

The problem is in the snippet itself:

var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);

Two independent blockers. The first line touches document, which does not exist in a background service worker, so it throws before reaching the network. The second is policy: matomo.js is fetched from your Matomo host at runtime, which Chrome's migration guide lists alongside "Any library hosted on a CDN" (Chrome).

Self-hosting Matomo fixes neither: matomo.js is still remote code to Chrome, and the tracker's API reference is written for pages, where setDocumentTitle overrides document.title and the content-impression methods take DOM nodes (Matomo, as of October 2026).

A context with a real document (a popup, an options page, or the offscreen document Chrome's sentence points at) removes the DOM objection and leaves the other standing. The minimum extension_pages policy is "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';", and it "cannot be relaxed beyond this minimum value. In other words, you cannot add other script sources to directives" (Chrome). Your Matomo host cannot become a script source anywhere in the extension, so only a bundled copy loads, and that gets you the tracker in one page and nothing outside it.

Winner

Moderok, whose SDK is written for the service worker and ships entirely inside the package.

Round 2The HTTP Tracking API, which does work

The supported path for a Matomo Chrome extension is the tracking endpoint itself. idsite and rec=1 are the required parameters, requests may be GET or POST, and many can go in one POST body as a JSON object with a requests array (Matomo, as of October 2026).

// background service worker
await fetch("https://matomo.example.com/matomo.php", {
  method: "POST",
  body: new URLSearchParams({
    idsite: "1",
    rec: "1",
    _id: visitorId,          // 16 hex characters, see Round 3
    e_c: "extension",
    e_a: "export_clicked",
    url: "https://extension.invalid/export",
    send_image: "0",
  }),
});

Three things to settle. Any cross-origin call from the worker is a manifest question, Matomo or not: "A script executing in an extension service worker or foreground tab can talk to remote servers outside of its origin, as long as the extension requests host permissions" (Chrome), answered either by host_permissions or by CORS headers for your extension origin.

Next, the parameters overriding where a visit came from, including cip, country, region, city, lat and long, need token_auth, a "32 character authorization key used to authenticate the API request" (Matomo, as of October 2026). A key with write access to your analytics does not belong in a bundle anyone can unzip.

Third, two recommended parameters earn their place here. send_image=0 returns "an HTTP 204 response code instead of a GIF image" and "can fix errors if images are not allowed to be obtained directly (eg Chrome Apps)"; url, "The full URL for the current action", organises actions, so an extension with no URLs wants a synthetic scheme it uses consistently (Matomo, as of October 2026).

There is no client to lean on either: a "JavaScript SDK" for websites, "Mobile SDKs" (iOS, Android, Titanium), "Client libraries for Apps or Server-Side tracking" (PHP, Java, C#), and "Other community contributed clients are also available in C++, Python, Node.js, JavaScript Frameworks" (Matomo, as of October 2026). Nothing targets an extension background worker, so queueing, batching and retries are yours.

Winner

Matomo, for a documented HTTP endpoint a service worker can call directly.

Round 3Who counts as a user

Matomo lets you supply the identity. _id "must be a 16 characters hexadecimal string", and "Every unique visitor must be assigned a different ID and this ID must not change after it is assigned"; leave it out and "the unique visitors metric might be less accurate" (Matomo, as of October 2026). Send a stable one and the visitor persists as long as your storage does.

Generating and keeping it is your job. In MV3 it belongs in chrome.storage, written before the first ping, because an extension service worker "is loaded when it is needed, and unloaded when it goes dormant" (Chrome):

const KEY = "matomo_vid";
async function visitorId() {
  const { [KEY]: existing } = await chrome.storage.local.get(KEY);
  if (existing) return existing;
  const bytes = crypto.getRandomValues(new Uint8Array(8));
  const id = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
  await chrome.storage.local.set({ [KEY]: id });
  return id;
}

Moderok does this itself: a random anonymous profile id in chrome.storage.local, mirrored to chrome.storage.sync so a profile can recover it (Moderok docs).

Winner

Matomo, because the identifier is yours to control, if you write the storage layer.

Round 4The events an extension actually has

An extension's interesting moments are installs, updates, uninstalls, and whether anyone came back. Matomo counts hits, and the tracking API reference has no parameter for an extension install or update (Matomo, as of October 2026). You can express an install as a custom event, but it has to fire from the right listener, registered synchronously, and "active today" is a definition you still owe, both worked through in how many users your Chrome extension really has.

Moderok sends __install, __update, __first_open, __daily_ping and __error with no track() call (automatic events), from a 5.7 kB gzipped SDK with zero runtime dependencies, and reports them in what the dashboard reports.

Winner

Moderok, because extension lifecycle is the default rather than the integration project.

Round 5Hits, price, and running it yourself

Matomo bills hits, broadly defined: "A hit is a single interaction sent to Matomo and processed for reporting or analysis", a list including "Page views", "Events", "Downloads", "Outlinks" and "Crashes" (Matomo, as of October 2026). For an extension tracking a handful of interactions per session, every one is a hit. Cloud is sold per tier: the pricing page's structured data puts the 50,000 hits/month tier at 22 EUR or 26 USD per month excluding tax and the 10 million tier at 1600 EUR, with larger tiers above (Matomo pricing, as of October 2026).

The same page lists the On-Premise Community edition as "Free" "forever" with "Unlimited users and hits" (Matomo pricing, as of October 2026), the real answer for a chatty extension on a budget. The cost moves to operations: "Matomo 4.16 and Matomo 5 : PHP 8 and MySQL version 5.5 or greater, or MariaDB", with the 6.0.0 row (followed by "See Matomo 6.0.0 Beta") asking "PHP version 8.1 or newer" and "MySQL 8.0 or newer / MariaDB 10.6 or newer" (Matomo, as of October 2026), plus backups and upgrades. Moderok is a hosted API, and its endpoint option is documented as one to "Override only if directed" (configuration), so if the rows must sit on hardware you operate, Matomo's Community edition answers that directly and decides it.

Winner

Matomo, at both ends: a Community edition listed with "Unlimited users and hits", and hosted plans priced per hit tier.

When Matomo is the right choice

Pick Matomo when the extension is one surface among several. The endpoint accepts whatever idsite you give it, so extension events can land beside your website's, and one dashboard instead of two is worth a lot.

Pick it when the data has to sit somewhere specific: on Cloud "the data is stored in Frankfurt, Germany", while "When you self-host Matomo On-Premise you can choose where the data should be located by setting up servers in your preferred location" (Matomo pricing, as of October 2026). And pick it when you want to define the identifier, since _id is a 16 hex character string you generate and your own code keeps stable (Matomo, as of October 2026), a precise thing to describe on a privacy page.

Where an extension-shaped tool wins

Pick one when the questions are the extension's own: how many installs stuck, whether people come back, which features get used. For the wider field, see the Chrome extension analytics roundup.

If you would rather not write the visitor id, the queue and the lifecycle listeners yourself, read the Moderok getting started guide: npm install, one permission, an init() call in your service worker.