# Martez — full blog content
> Martez connects Matomo Analytics with Meta and Google Ads to gain insights into marketing KPIs like CPC, ROAS, CLV and more.
This file concatenates all 35 published posts from https://martez.io/blog. Each post is also available individually at its canonical URL with `.md` appended.
# Your PHP is on the latest release and Matomo still shows a warning. Here's why
> A warning in System Check when your PHP is current is almost always about your database, not PHP. And the PHP Deprecated lines flooding your archiver log aren't errors at all. Here's how to tell what each one is really saying, and what's worth doing about it.
Published: 2026-08-19
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-php-version-warning-explained
---
You're on the newest PHP release, you open System Check, and there's still an amber or red row staring back at you. Or your nightly archiving cron has started filling its log with `PHP Deprecated:` lines on PHP 8.2 or 8.3. Both look like Matomo is unhappy with your PHP, and both get read the same way: fine, what version does it want, then? In almost every case neither warning means Matomo is broken, and neither is actually asking you to change PHP. Here's how to tell what each one is really saying.
If your PHP is current but System Check still warns about end of life, the warning is almost always about your database engine, not PHP. Read the text of the flagged row and plan a MySQL or MariaDB upgrade. The `PHP Deprecated:` lines in your archiver log are harmless notices, not errors, so archiving still finishes; upgrading Matomo to the latest 5.x clears them.
## The warning that survives a current PHP
The pattern goes like this. The PHP row in System Check is green, required `>= 7.2.5` and installed `8.3.x`, and yet there's still a warning on the page mentioning End of Life. The obvious read is that your PHP passed, so this must be a false positive.
It usually isn't. Since Matomo 5.4, System Check can warn when your database engine has reached end of life, and that warning lands next to a passing PHP row. MySQL 8.0 reached EOL in April 2026, so a current Matomo flags it. Your PHP being current and your database being EOL are two separate checks that happened to land on the same page at the same time.
The tell is in the version string. If your database reports something like `8.0.45-cll-lve`, the `-cll-lve` suffix means you're running on CloudLinux, which is typical of shared and managed hosting. On that kind of stack the host controls the database version, not you, which is exactly why the warning feels stuck. You can't `apt upgrade` your way out of a database your provider pins.
Here's what System Check doesn't make obvious: it grades a lot of components, not just PHP. A green PHP row doesn't mean every row passed. When something warns, read the text of the flagged row rather than guessing from the color of the row above it. It's the same family of "the message names one thing but means another" confusion we see with [Matomo database errors](/blog/fix-matomo-database-errors), where the surface symptom and the real cause sit in different places.
## The deprecation notices in the archiver
The second symptom looks even more like a PHP problem because it literally says `PHP`. Run the archiving console command on a newer PHP and you may watch lines like this scroll past:
```text
PHP Deprecated: Using ${var} in strings is deprecated, use {$var} instead in ...
```
These aren't errors. Archiving still finishes and your reports still build. A deprecation notice is PHP telling you that some code uses a syntax a *future* PHP will remove someday. It's a heads-up for maintainers, not a failure.
On older Matomo builds, a batch of these came from a dependency Matomo bundles called Symfony Console. PHP 8.2 deprecated the `${var}` form of string interpolation in favor of `{$var}`, Symfony Console still used the old form in a couple of files, and so every archiving run echoed the notice. Matomo staff confirmed the cause and shipped the fix in Matomo 5.0, so on a current 5.x these particular lines are already gone. If you're still seeing them, you're on an older build.
## Why the two get mixed up
Both warnings carry the word PHP, one because it sits in a PHP-heavy diagnostics page and the other because PHP itself emits it, so both get filed under "Matomo wants a different PHP version." Neither does. Here's the split:
| Warning | Where it shows up | What it means | What to do |
| --- | --- | --- | --- |
| EOL warning beside a green PHP row | System Check page | A component has reached end of life. With a current PHP, that component is almost always the database engine. This is a genuine "no longer getting security patches" signal. | Read the flagged row, then upgrade or escalate MySQL/MariaDB. |
| `PHP Deprecated:` lines | Archiver CLI and cron log | Informational notices, not failures. Archiving still finishes. | Upgrade Matomo and plugins, run a supported PHP, then quiet the CLI noise only if any survives. |
The `PHP Deprecated:` notices come from one of three places: an outdated bundled dependency on old Matomo (fixed by upgrading), a third-party plugin, or a PHP newer than the version your Matomo build was tested against.
## If it's a database EOL warning
First, confirm what the row is actually naming. Go to Administration → Diagnostics → System Check and read the text of the flagged row instead of judging it by the PHP row's color. If it names your MySQL or MariaDB version, here's the path:
- Self-managed server: upgrade to a supported release. MySQL 8.4 LTS is the natural target coming off 8.0, and the jump is far gentler than the old 5.7 to 8.0 migration was. On MariaDB, move to a supported LTS such as 10.11, 11.4, or 11.8, after checking your OS packaging and Matomo compatibility. Back up your database before you touch it.
- Shared or managed hosting (CloudLinux `-cll-lve`, cPanel, and similar): you can't change the engine yourself. Open a ticket asking your host to move you to a supported MySQL 8.4 LTS or a current MariaDB. The database version is theirs to set.
What we'd avoid is reaching for the "ignore this check" option. An EOL database warning is a genuine security signal, the engine has stopped getting patches, and it belongs on your real maintenance list next to the rest of your [Matomo hardening](/blog/harden-secure-matomo-installation) work. Hiding it just means the next person to read System Check rediscovers it from scratch.
## If it's deprecation notices in the archiver
The proper fixes, in order:
1. Upgrade Matomo to the latest 5.x. This is what cleared the Symfony Console notices, and it clears most others too, because dependency and deprecation fixes ship with releases. If you're chasing notices on an old build, stop and upgrade first.
2. Run a PHP 8.x version your Matomo release supports. Matomo runs on PHP 8 and recommends a current 8.x, but if you jump onto a PHP branch newer than your build expects, that's a common source of fresh deprecation chatter: the code is fine, it just hasn't been certified against the newer runtime's stricter notices yet. Check your release's requirements before moving to a brand-new PHP.
3. Update your plugins. Third-party plugins are the usual remaining source once Matomo core is current.
If you've done all that and only harmless log noise is left, you can quiet it at the PHP CLI level. The thing to know is that cron runs through the CLI `php.ini`, which is often a different file from the one your web server uses, so changing the web config does nothing for your archiver. Find the active CLI file:
```bash
php --ini
```
Then, in the file it reports as loaded, mute PHP's engine-level deprecation notices for the CLI:
```ini
; In the CLI php.ini only, not the web one
error_reporting = E_ALL & ~E_DEPRECATED
```
One caveat: `~E_DEPRECATED` silences only the engine notices, the kind the `${var}` line above belongs to. It does not touch `E_USER_DEPRECATED`, which plugins and libraries raise deliberately in their own code. If those are part of the noise too and you want them gone, extend the mask to `E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED`. Keep the change to the CLI and cron context. Blanket-suppressing deprecations across your whole stack hides exactly the early warnings that make the next PHP upgrade smooth. If you're setting up archiving from scratch and want the cron side done properly, we walk through it in [setting up Matomo cron archiving](/blog/set-up-matomo-cron-archiving).
## Verify
Reload System Check after a database upgrade and the flagged row should clear. For the archiver, re-run it by hand and confirm the deprecation lines are gone:
```bash
./console core:archive --matomo-domain=your-matomo-domain
```
On Matomo 5 the option is `--matomo-domain`. The older `--url=https://your-matomo-domain/` still works and is the one you want when Matomo lives under a sub-path. Use whichever matches your install. A WordPress-plugin install drives archiving differently from a standalone one. If the run itself misbehaves rather than just printing notices, that's a separate problem, and [archiving returning an invalid response](/blog/matomo-cron-archiving-invalid-response) is the place to start.
## What we'd actually do
Read the row, not the color. Nine times out of ten, the "PHP is current but still warned" report is a database EOL warning wearing a PHP costume, so treat it as the database task it is. On a server you control, schedule the MySQL 8.4 or MariaDB LTS upgrade. On managed hosting, file the ticket today rather than letting an unpatched engine drift, and don't burn time trying to upgrade something your provider has locked.
For the archiver noise, upgrade Matomo and stay on a PHP version it's tested against. That alone clears almost all of it. Only after that, if a few cosmetic notices survive, silence them in the CLI `php.ini`, narrowly and on purpose. Suppression is the last step, never the first.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-php-version-warning-explained) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-php-version-warning-explained) if that's relevant.
A warning that names PHP is rarely about PHP. Read the row, and fix the real thing.
---
# How to lock down a self-hosted Matomo install without breaking tracking
> Self-hosting Matomo means the admin login sits behind nothing but a username and password, and on some servers files that should be private are downloadable in the browser. Matomo auto-protects its private directories but not the web root, and on Nginx it protects nothing at all. Here's the hardening that actually matters on Matomo 5, in priority order, without locking out your tracker or your own archiving.
Published: 2026-08-17
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/harden-secure-matomo-installation
---
If you self-host Matomo, two things tend to surface around the same time. The admin login at `index.php` is reachable by anyone who knows the URL, with only a username and password in front of it. And files that feel like they should be private are downloadable straight from the browser: a stray `.sh` helper, a `README.md` buried in `node_modules`, leftover build artifacts. Neither is a bug. That's the default posture of a fresh install, and closing both gaps is on you, not on Matomo.
Fast path: keep `matomo.php`, `matomo.js`, the legacy `piwik.*` files, `js/container_*.js`, opt-out assets, and Heatmap configs public. Require authentication for everything else. Then switch archiving to real CLI, force HTTPS, verify the trusted host, enforce 2FA, turn on strong-password-on-change, and rotate stale tokens. The details below keep each exception testable.
A fresh Matomo self-host leaves the admin login exposed, and depending on your web server, leaves private files downloadable too. Matomo auto-protects its private directories on Apache and IIS but never the web root, and on Nginx it protects nothing. Require auth everywhere except an explicit public allow-list (`matomo.php`, `matomo.js`, `js/container_*.js`, opt-out, Heatmap configs), then handle archiving, `force_ssl`, trusted hosts, 2FA, and stale tokens. The one trap to plan for: Basic Auth breaks HTTP cron archiving, so move archiving to CLI with `./console core:archive`.
It's worth understanding why the install feels open before you start pasting config. During setup, Matomo writes protective files for private directories on Apache and IIS. Those files are real, but Apache still has to honor overrides, and Nginx ignores `.htaccess` outright. Matomo also does not lock the web root for you, because the tracker, Tag Manager containers, opt-out iframe, and some plugin endpoints must remain reachable by visitors.
## Confirm what's exposed before you change anything
Go to Administration → Diagnostic → System Check. It reports, among other things, whether private directories are reachable over HTTP. If it flags a directory as accessible, that's your starting point: the generated protection either did not write, is being ignored by the server, or has not been translated into Nginx rules. The same report runs from the CLI with `./console diagnostics:run`, which is handy for checking several installs at once.
Then test it yourself, because System Check can't see everything your web server exposes. In a browser or with curl, hit paths that should be off-limits:
```bash
curl -I https://your-matomo.example.com/node_modules/
curl -I https://your-matomo.example.com/CHANGELOG.md
curl -I https://your-matomo.example.com/config/config.ini.php
```
You want a `403`, `404`, or `401`, not a directory listing or file contents. If a private file downloads, fix the server layer first. Where that fix lives depends on the stack: on Apache or IIS, run `./console core:create-security-files` from the Matomo directory if the generated security files are missing; on Nginx, start from the maintained `matomo-org/matomo-nginx` configuration rather than relying on `.htaccess`; behind Docker or a proxy, track down which container or proxy actually serves Matomo before touching anything. Only once the private paths are sealed should you add admin auth with the public exceptions below.
## Restrict the admin, keep the tracker public
The goal is to require authentication for everything by default, then re-open the handful of endpoints that genuinely need to be public. Matomo's own security guidance calls out this public family:
| Path | Why it stays public | Expected result |
| --- | --- | --- |
| `/matomo.php`, `/matomo.js`, `/piwik.php`, `/piwik.js` | JavaScript tracker and legacy tracker URLs | Reachable without Basic Auth |
| `/js/container_*.js` | Matomo Tag Manager containers and preview containers | `200`, no auth prompt |
| `/index.php?module=CoreAdminHome&action=optOut` | Opt-out iframe | `200`, no auth prompt |
| `/plugins/CoreAdminHome/javascripts/optOut.js` | Opt-out JavaScript | `200`, no auth prompt |
| `/plugins/HeatmapSessionRecording/configs.php` | Heatmaps and Session Recording config bootstrap | Public if that plugin is enabled |
| `/favicon.ico` | Browser favicon request | Public |
| `/node_modules/`, `/config/config.ini.php`, `/tmp/` | Private application files | `403`, `404`, or auth prompt |
Run this matrix after every server edit. Replace the Tag Manager container name with a real generated filename from your install:
```bash
curl -I https://your-matomo.example.com/matomo.js
curl -I https://your-matomo.example.com/matomo.php
curl -I https://your-matomo.example.com/js/container_ABC123.js
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I https://your-matomo.example.com/plugins/CoreAdminHome/javascripts/optOut.js
curl -I https://your-matomo.example.com/plugins/HeatmapSessionRecording/configs.php
curl -I https://your-matomo.example.com/node_modules/
```
### Apache
On Apache 2.4, the web-root `.htaccess` approach only works if Apache is allowed to read it. Confirm the vhost has `AllowOverride All`, or at least the override classes needed for auth and access control, and that the auth modules are loaded: `mod_auth_basic`, `mod_authn_file`, and `mod_authz_core`. Create the password file outside the public web root, for example with `htpasswd -c /secure/path/.htpasswd admin`. Then reload Apache and test one protected URL and one public tracker URL before you change any other hardening setting.
Use modern `Require` directives. `Allow`, `Deny`, and `Order` are deprecated in Apache 2.4 and depend on `mod_access_compat`; they are not the right basis for new configs.
```apache
# Lock down everything by default
AuthType Basic
AuthName "Matomo"
AuthBasicProvider file
AuthUserFile "/secure/path/.htpasswd"
Require valid-user
# Re-open tracker, legacy tracker, Tag Manager, opt-out JS, and common public files
Require all granted
# Re-open the Heatmap and Session Recording config endpoint only
Require all granted
# Re-open the opt-out iframe, and nothing else under index.php
Require all granted
```
If Matomo lives under a sub-path, update the `REQUEST_URI` expression for that path and re-run the curl checks. The opt-out block deserves the extra caution. The official endpoint is `index.php?module=CoreAdminHome&action=optOut`, and you have to keep it public so the iframe loads for visitors. The two negative lookaheads stop someone appending a second `module=` or `action=` to the query string to smuggle a different controller past your auth. Test that exact bypass on staging:
```bash
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut&module=UsersManager"
```
The first should load without auth. The second should not.
If you'd rather gate the UI by network than by password, use `Require ip 203.0.113.5` for the protected side instead of `Require valid-user`. That's the easier path when admins all sit behind a VPN or a known office IP.
### Nginx
On Nginx, do not translate the Apache block mentally. Start from the maintained `matomo-org/matomo-nginx` vhost, then add Basic Auth at the `server` or `/` level and turn it off only for the public endpoint family above. Nginx `location` and regex order decide which rule wins, so the curl matrix is part of the change.
One workable pattern is to put a `map` in the `http` context, outside the `server` block, and feed that value into `auth_basic`:
```nginx
map "$uri?$args" $matomo_basic_auth {
default "Matomo";
~^/(matomo|piwik)\.(php|js)\?$ off;
~^/js/container_.*\.js\?$ off;
~^/plugins/CoreAdminHome/javascripts/optOut\.js\?$ off;
~^/plugins/HeatmapSessionRecording/configs\.php\?$ off;
~^/favicon\.ico\?$ off;
~^/index\.php\?module=CoreAdminHome&action=optOut$ off;
}
server {
server_name your-matomo.example.com;
root /var/www/matomo;
auth_basic $matomo_basic_auth;
auth_basic_user_file /etc/nginx/.htpasswd;
location ~ ^/(index|matomo|piwik|js/index|plugins/HeatmapSessionRecording/configs)\.php$ {
include snippets/fastcgi-php.conf;
try_files $fastcgi_script_name =404;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
location ~* ^.+\.php$ {
deny all;
return 403;
}
location / {
try_files $uri $uri/ =404;
}
location = /plugins/CoreAdminHome/javascripts/optOut.js {
try_files $uri =404;
}
location ~ \.(gif|ico|jpg|png|svg|js|css|htm|html|ttf|eot|woff|woff2)$ {
try_files $uri =404;
}
location ~ ^/(config|tmp|core|lang|libs|vendor|plugins|misc|node_modules) {
deny all;
return 403;
}
}
```
This is a skeleton, not a replacement for the full Matomo Nginx vhost. Keep the FastCGI socket, TLS, headers, cache rules, and deny rules from your real config. If your opt-out URL has extra benign query parameters, add that exact shape to the `map`; do not make every `index.php` request public. If your Matomo sits behind a reverse proxy, the headers that matter for auth matter for the proxy too. We covered that wiring in [why a reverse-proxied Matomo throws 502s](/blog/self-host-matomo-reverse-proxy-502).
Keep an SSH session open while you test. If you lock yourself out on Apache, move the web-root `.htaccess` aside and leave Matomo's private-directory security files alone. On Nginx, comment the new `auth_basic` lines, run `nginx -t`, and reload.
## Don't lock out your own archiving
The first thing Basic Auth tends to break is HTTP-triggered cron archiving, which starts returning `401`s the moment you protect the root. The clean fix is to stop archiving over HTTP and run it through the CLI:
```bash
./console core:archive --url=https://your-matomo.example.com
```
Run that from the Matomo host as the web-server user or the dedicated cron user. `core:archive` normally avoids web-server auth when Matomo can launch CLI child processes. That nuance matters: if the OS or PHP runtime can't support CLI child processes, Matomo falls back to curl-based HTTP requests, and then Basic Auth can still bite. Watch the archiver output and server logs after the first protected run. If you see `401`s, check whether CLI child processes are supported; if Matomo has fallen back to HTTP, fix CLI support or allow the archiver path with credentials over HTTPS.
If you're still wiring archiving up, [setting up Matomo cron archiving](/blog/set-up-matomo-cron-archiving) walks through the whole job. If you genuinely must keep HTTP archiving, pass credentials only over HTTPS and prefer POSTing tokens where Matomo supports it. CLI is what we'd reach for every time.
## Force HTTPS and verify the host header
Two lines in `config/config.ini.php`, under `[General]`, close off a surprising amount of surface:
```ini
[General]
force_ssl = 1
trusted_hosts[] = "matomo.example.com"
```
`force_ssl = 1` redirects every `http://` request to its `https://` equivalent. Confirm HTTPS actually works first, or you'll lock yourself out. The trusted host check is enabled by default in current Matomo and the install hostname is set during setup, so treat this as a verification step: make sure every real public hostname appears as its own `trusted_hosts[]` line and remove stale ones. This protects against untrusted hostnames, including host-header injection risks, but it does not restrict visitors by IP or network. Use server-level rules for that.
## Lock down the accounts
Web-server hardening protects the perimeter. The accounts behind it are the other half.
Start with two-factor authentication. Set up 2FA on your own Super User account first, under Administration → Personal → Security, because Matomo won't let you enforce it org-wide until you have. Then, in the Two-Factor Authentication settings under General Settings, tick "Require two-factor authentication for everyone". One caveat that matters here: 2FA isn't checked on API requests authenticated by token. So enforcing it hardens the interactive login and does nothing for a leaked token, which is exactly why the audit step below isn't redundant.
Force strong passwords next. Under Administration → System → General Settings → Login, enable "Force strong passwords to be used". It requires at least 12 characters with mixed case, numbers, and symbols when a password is set or updated. It is not retroactive: existing weak passwords continue to work until users or a Super User change them. If compliance requires immediate rotation, reset those accounts rather than assuming the checkbox forced it.
Leave brute-force protection on. Matomo 5 ships it enabled by default: repeated failed logins temporarily block the offending IP. Check the Login settings and your config for the actual duration on your install instead of assuming a universal block window. The allowlist for IPs that should never be blocked lives in that same General Settings → Login panel, and you can review or clear active blocks under Administration → Diagnostic → Brute Force Log. Worth checking the allowlist before a flaky office connection locks you out.
## Audit users and tokens
The UI does not give a Super User a full token and password-age audit view. On self-hosted Matomo 5, query the database directly. Replace the `matomo_` prefix if your tables use a different one:
```sql
SELECT
login,
email,
superuser_access,
ts_password_modified,
ts_last_seen
FROM matomo_user
ORDER BY login;
SELECT
login,
description,
date_created,
last_used,
date_expired,
secure_only,
system_token,
hash_algo
FROM matomo_user_token_auth
ORDER BY login, date_created;
```
`user_token_auth` stores token hashes, not the plain token value. In current Matomo 5 code those hashes are salted SHA-512 values in the `password` column. The useful audit fields are the owner, description, creation date, last-used date, expiry, whether it is a system token, and `secure_only`. A `secure_only` token must be supplied through a secure mechanism such as POST or an auth header; do not rely on URL parameters for those tokens. Rotate or expire stale tokens, and prefer short-lived or POST-only tokens where the workflow allows it.
The `user.ts_password_modified` field records when a password was last changed. The UI may not expose that as a global report, but the data is there for self-hosted installs. The community `UsersPasswordModified` plugin adds a Users Manager column if you need that view in the interface. If a query against these tables errors out instead of returning rows, our notes on [fixing common Matomo database errors](/blog/fix-matomo-database-errors) cover the usual causes.
If creating or deleting an API token hangs after submission, inspect application logs and mail logs before blaming Basic Auth. If the one-time token display was interrupted, delete and recreate that token; you cannot read the plain token value back from the database.
On Matomo Cloud, the web-server and directory hardening is handled for you and you have no database access, so the audit becomes account-layer only: enforce 2FA, force strong passwords, rotate tokens on a schedule, and ask support for a data dump if you need a formal one.
## What we'd actually do
If you run one install, the thirty minutes that matter most go like this: lock the web root with the Apache or Nginx pattern above, paste the public allow-list verbatim, switch archiving to CLI, set `force_ssl`, verify `trusted_hosts`, and enforce 2FA plus strong-password-on-change. Then prove four things: tracker returns without auth, Tag Manager container loads, opt-out iframe loads, and `./console core:archive` runs without `401`s. Save the config diff.
When something does break after you turn on Basic Auth, it's almost always one of four things, and the cause is usually obvious once you curl the right URL:
| What broke | Likely cause | Fix |
| --- | --- | --- |
| Tracker or Tag Manager stops recording | A public tracker endpoint is still behind auth | `curl -I` `matomo.js`, `matomo.php`, and the real `js/container_*.js` URL from outside the admin network; add only that endpoint to the allow-list and reload |
| Opt-out iframe prompts for a password | `index.php` was protected without the narrow `CoreAdminHome` opt-out exception | Allow only `module=CoreAdminHome&action=optOut` and reject duplicate `module`/`action` parameters |
| Heatmaps or session recordings stop loading | `plugins/HeatmapSessionRecording/configs.php` is blocked | Add the Heatmap config endpoint to the allow-list when that plugin is enabled |
| Cron archiving starts returning `401` | The job still uses an HTTP path, or `core:archive` fell back to curl-based HTTP | Run `core:archive` through CLI child processes, or configure the fallback path deliberately |
If you run several installs, put System Check (`./console diagnostics:run`) into a scheduled job and re-run it after every upgrade. Upgrades occasionally regenerate protected paths, and a check that runs itself is the only kind that survives the third busy week. Keep tracking on its own public endpoint while everything else stays gated. It's the same separation we lean on when we [run the tracker server-side through the HTTP Tracking API](/blog/matomo-http-tracking-api-server-side).
Whichever route you take, save the evidence: the System Check result, a `403` or `404` for `/node_modules/` and `/config/config.ini.php`, successful public responses for `matomo.php`, `matomo.js`, the Tag Manager container, opt-out, and Heatmap configs if enabled, and the first clean `core:archive` run after Basic Auth. A short hardening note now is what you'll thank yourself for the next time an upgrade quietly re-opens a path.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=harden-secure-matomo-installation) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=harden-secure-matomo-installation) if that's relevant to you.
Self-hosting means you own the data and you own the lock. Finish by proving the public endpoints still work and the private paths do not.
---
# Why renaming matomo.php doesn't stop ad blockers (serve Matomo Tag Manager first-party instead)
> Renaming matomo.php to a neutral path doesn't help: ad blockers match on hostname and path together, so as long as hits go to analytics.yourcompany.com the rule still fires. The durable fix is to serve Matomo Tag Manager fully first-party through a reverse proxy. Here's how, including the Cloudflare IP gotcha that collapses every visitor to one location.
Published: 2026-08-12
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-tag-manager-first-party-adblockers
---
You renamed `matomo.php` to something neutral, pushed it live, and the ad blocker still eats your hits. uBlock Origin, AdGuard, Brave's built-in shield: toggle one on and the visit count drops the same way it did before you touched anything. The rename felt like the obvious move, and it did nothing.
It did nothing because blockers don't match on the filename alone. They match on two things at once, and renaming the path only deals with one of them. As long as the browser is firing requests at `analytics.yourcompany.com`, a hostname rule catches them no matter what you called the endpoint. The fix that actually holds is to send every tracking request to the same domain as the site itself, over a neutral path, so the browser sees nothing but ordinary first-party traffic to `example.com`. A reverse proxy on your own web server forwards those requests to Matomo without the browser ever knowing.
Renaming `matomo.php` doesn't help because ad blockers match on the hostname too, so hits going to `analytics.yourcompany.com` still get caught regardless of the path. The fix that holds is to serve both Matomo Tag Manager assets first-party through a reverse proxy on your own domain: the container loader and the tracking endpoint, on neutral paths with no tracking words in them. Ship the visitor-IP forwarding (`proxy_client_headers[]` and `proxy_ips[]`) in the same change, or every visitor will geolocate to wherever your server lives.
## Why the rename didn't work
Modern blocker lists test each request against two kinds of pattern:
- Hostname patterns like `analytics.*`, `stats.*`, `matomo.*`, `piwik.*`, and the well-known Matomo Cloud hosts.
- Path and filename patterns like `matomo.php`, `piwik.php`, `/matomo/`, `container_*.js`, and `matomo.js`.
Renaming `matomo.php` to `collect.php` dodges the second list, and not even all of it, since `collect` is a flagged word in its own right. The request is still going to a hostname that announces itself as analytics, so the hostname rule fires and the hit never leaves the browser. You can rename the file forever; the host gives you away every time.
First-party serving removes both signals at once. When the loader and the tracking endpoint both live on `example.com`, there's no separate analytics hostname to match, and you pick path names with no tracking words in them. At the network level the tracking hit then looks like any other request to your own site. That isn't a permanent guarantee: a filter list can always add your specific path later, or match on a request signature. What it buys you is taking away the obvious hostname and default-path signals that catch you today, which is what recovers most of the lost hits. Two browser-facing assets have to move first-party for this to work: the container script, which by default loads from `https://matomo-host/js/container_XXXXXXXX.js`, and the tracking endpoint, which by default is `matomo.php`. Move both onto your domain and the requests look like any other first-party call.
None of this touches Matomo core, and none of it overrides consent. Your CMP and Do-Not-Track handling work exactly as before. That last point is the one that always comes up, so there's a section on it further down.
## Step 1: proxy two neutral paths on each tracked site
On every domain you track, add a reverse proxy that forwards two paths to your Matomo server. Here's the Nginx version; Apache's `mod_proxy` and Caddy do the same thing with their own syntax.
```nginx
# First-party tracking endpoint -> Matomo's matomo.php
location = /app-sync {
proxy_pass https://MATOMO_HOST/matomo.php;
proxy_set_header Host MATOMO_HOST;
proxy_ssl_server_name on;
proxy_ssl_name MATOMO_HOST;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
# First-party container script -> Matomo's container JS
location = /app-loader.js {
proxy_pass https://MATOMO_HOST/js/container_XXXXXXXX.js;
proxy_set_header Host MATOMO_HOST;
proxy_ssl_server_name on;
proxy_ssl_name MATOMO_HOST;
}
```
The path names are the whole game. Pick endpoints with no tracking vocabulary in them: avoid `analytics`, `matomo`, `stats`, `track`, `pixel`, `beacon`, and `collect`. Names like `/app-sync`, `/app-loader.js`, `/api-event`, or `/core.js` blend into normal site traffic and give a filter list nothing generic to match. Swap your real Matomo host in for `MATOMO_HOST` (the bare hostname like `analytics.yourcompany.com`, not a full `https://` URL) and your real container filename in for `container_XXXXXXXX.js`, which you'll find in the embed snippet Tag Manager hands you. The two `proxy_ssl_*` lines matter when Matomo is reached over HTTPS on shared or virtual-hosted TLS, which covers Matomo Cloud and most managed hosts: without them Nginx leaves SNI off the upstream handshake and the connection either fails or lands on the wrong certificate.
## Step 2: point the container at the first-party endpoint
In Matomo, open Tag Manager → your container → Variables and edit the Matomo Configuration variable. There are two fields to set. Point Matomo URL at your first-party base (`https://example.com/`), and set Tracking Request Target Path to the neutral endpoint from Step 1 (`app-sync`). That second field defaults to `matomo.php`, and it exists for exactly this reason; Matomo's own docs describe it as a way to aim hits at a proxy or a custom endpoint. Publish the container when you're done. Hits now go to `https://example.com/app-sync?...`, and Nginx forwards them internally to `matomo.php`. The browser never sees the Matomo host.
## Step 3: serve the container script first-party
The loader in your site's `
` still points at the Matomo host until you change it. Swap the `src`:
```html
```
Now both assets the browser touches, the script that bootstraps tracking and every hit it sends, resolve to your own domain. Keep the cache on `/app-loader.js` short, around an hour, so that when you publish a container change in Tag Manager browsers pick it up quickly instead of serving a stale copy for a day.
## Step 4: give Matomo the real visitor IP back
Here's the gotcha that catches everyone the second first-party serving starts working. Once the proxy sits in front of Matomo, every hit arrives from the proxy's IP address. Matomo geolocates that one IP, and your entire audience suddenly appears to live in a single city, wherever your server happens to be. The data is flowing again. It's just all pointing at your data centre.
Fix it on the Matomo server in `config/config.ini.php`, under `[General]`:
```ini
[General]
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
proxy_host_headers[] = HTTP_X_FORWARDED_HOST
proxy_ips[] = 203.0.113.10 ; your proxy's public IP, not the visitor's
```
`proxy_client_headers[]` tells Matomo which header carries the visitor IP, the one your Nginx block set back in Step 1, instead of reading the address it's connecting from. `proxy_ips[]` is the part people skip, but it isn't a trust switch. It's the list of your own proxy and load-balancer IPs that Matomo should step past when it walks the forwarded chain to find the real visitor; without it, the proxy's own address can end up chosen as the client. List your proxy's address there, and only addresses you control.
Spoofing is a separate worry, and it's handled back in Step 1. Because the Nginx block sets `X-Forwarded-For` to `$remote_addr`, the address Nginx itself saw the request come from, it overwrites anything the client sent. Nobody can hand you a forged `X-Forwarded-For` and fake their location into your reports. If instead you append to the incoming header, which is what the common `$proxy_add_x_forwarded_for` does, a client-supplied value rides along inside the chain, and depending on your Matomo version it can be the one that gets picked. Overwrite at the edge unless you genuinely have a trusted proxy or CDN in front whose forwarded values you need to keep.
If you front the proxy with Cloudflare, the real client IP isn't in `X-Forwarded-For` at all; it's in Cloudflare's own header. Add that one instead, or ahead of the standard header:
```ini
[General]
proxy_client_headers[] = HTTP_CF_CONNECTING_IP
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
```
Matomo checks these in order and stops at the first one with a value, so listing the Cloudflare header first means CF-fronted hits resolve correctly while anything arriving without it falls back to the standard forwarded header. That order is only safe if your origin can't be reached directly: lock the server down to Cloudflare's IP ranges (or strip any client-supplied `CF-Connecting-IP` at your edge), or a visitor hitting the box straight on could set that header themselves. If your geolocation runs off the GeoIP2 database, this header config is the input it reads; a [correct GeoIP2 / GeoLite2 setup](/blog/matomo-geoip2-geolite2-setup) is wasted if the IP reaching it is your proxy's.
## Verify it actually worked
Three checks, in order:
1. Open a tracked page with your ad blocker on. In DevTools, Network tab, confirm the loader (`/app-loader.js`) and the hit (`/app-sync`) both resolve to `example.com` rather than the Matomo host, and both return `200`/`204`.
2. In Matomo, go to Visitors, then Visits Log, and confirm new visits show the right country instead of your data centre's. That's how you know Step 4 took.
3. Toggle the blocker off and on a few times. The visit count should hold steady. If it still drops, something is still resolving to the Matomo host; recheck the loader `src` and the Tracking Request Target Path.
If you proxy everything correctly but get a gateway error on the endpoint instead of a `204`, that's a proxy-layer problem, not a Matomo one. We wrote up the [502s that show up self-hosting Matomo behind a reverse proxy](/blog/self-host-matomo-reverse-proxy-502) separately.
## The official PHP fallback
If you'd rather not maintain Nginx rules, or you don't control the web-server config on every site you track, Matomo ships an official [tracker proxy](/blog/matomo-http-tracking-api-server-side) that gets you to the same place. You download the proxy's PHP files (`matomo.php`, `piwik.php`, `proxy.php`, and `matomo-proxy.php`, plus the Heatmaps config file if you run that plugin) from Matomo's repository, drop them on your domain, and authenticate them server-side with a `token_auth`. Same first-party result, reached through PHP instead of the web-server layer. It's heavier to replicate across a lot of sites and it adds a PHP hop to every hit, which is why we reach for the reverse proxy first, but it's the documented route when editing Nginx isn't an option.
## Does this bypass consent?
No, and it's worth saying plainly, because it's the first thing a privacy-minded teammate will ask. First-party serving changes where the request goes, not whether it's allowed to fire. Consent and Do-Not-Track are enforced inside Matomo and your CMP no matter which domain the hit lands on. If a visitor hasn't consented, moving the endpoint onto your own domain doesn't quietly make tracking happen anyway. This is a measure against blocking, not a way around consent. If [cookieless or consent-gated tracking](/blog/matomo-consent-cookieless-tracking) is part of your setup, it keeps working exactly as you configured it.
## What we'd actually do
Serve both assets first-party through the reverse proxy. It's the highest-impact thing you can do against ad blockers: it removes the hostname signal and the path signal together, it needs no changes to Matomo core, and one Nginx block per site copies cleanly across however many domains feed your central install. Save the official PHP tracker proxy for the sites where you can't touch the web-server config.
And don't ship Step 1 without Step 4. The most common version of this story is someone getting tracking through the proxy, celebrating the recovered hits, and a week later wondering why every visitor lives in Frankfurt. Put the forwarded headers and the `proxy_ips[]` line in the same change, check the country in the Visits Log before you call it done, and you're finished.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-tag-manager-first-party-adblockers) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-tag-manager-first-party-adblockers) if that's relevant.
Renaming the file was never going to work. Moving the request is.
---
# Why Matomo's log importer fails: invalid log lines, JSON errors, and silent piped logging
> Matomo's import_logs.py rarely tells you what's actually wrong. A JSONDecodeError almost always means --url is pointing at the wrong place, every line counted as an 'invalid log line' means your log format doesn't match, and a piped import that records nothing is usually just buffering. Here's how to read each failure and fix it on Matomo 5.x.
Published: 2026-08-10
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-import-logs-not-working
---
If you've run Matomo's log importer and watched it die on a `JSONDecodeError` before it read a single line, or watched it finish cleanly and announce that every one of your lines was an "invalid log line", or wired up Apache piped logging that imports nothing at all on a quiet site, none of those are bugs in your server. They look like three unrelated failures. They're three configuration problems, and each one hands you a message that points the wrong way.
The importer is `import_logs.py`, shipped in `misc/log-analytics/` with every Matomo release (the standalone copy lives at [matomo-org/matomo-log-analytics](https://github.com/matomo-org/matomo-log-analytics)). It's one of the more reliable ways to feed Matomo without a JavaScript tag, the server-side cousin of the [HTTP Tracking API](/blog/matomo-http-tracking-api-server-side) that reads your existing access logs instead of waiting for a browser to fire a beacon. It's also unusually good at hiding the real cause of a failure behind a misleading one. Here's how to read the three you're most likely to hit.
All three are configuration problems, not bugs in your server. A `JSONDecodeError` means `--url` is hitting something that returns HTML instead of your Matomo install, usually the tracked site itself or an `http`-to-`https` redirect. "Invalid log lines" for every entry means `--log-format-name` doesn't match your access log; a standard combined log wants `ncsa_extended`, not one of the `%v`-first formats. A piped import that records nothing on a quiet site is just the recorder batching hits, so set `--recorder-max-payload-size=1`.
## The JSON error: `--url` is pointing at the wrong place
The most common failure is a Python traceback that ends in:
```
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
```
often with a second, scarier-looking line under it: `AttributeError: 'str' object has no attribute 'decode'`. Ignore that one. It's the importer's own error reporting tripping over itself while trying to print the message it should be showing you, which is "Matomo returned an invalid response." Depending on which revision of `import_logs.py` you're running, that helpful line gets swallowed and the JSON traceback is all you're left with.
The mechanism is simple once you see it. `--url` is the address of your Matomo installation, not the website whose logs you're importing. The importer POSTs to Matomo's Tracking API and expects JSON back. Point it somewhere that returns an HTML page instead (the marketing site you're tracking, a login screen, a 404) and Python tries to parse `` as JSON and chokes on character zero. That's the whole bug.
Three variations produce the identical error:
- `--url` is aimed at the tracked site instead of Matomo. Point it at your Matomo install, e.g. `--url=https://analytics.example.com` or `https://www.example.com/matomo`, not `https://www.example.com`.
- The URL is `http` and Matomo redirects it to `https`. The redirect response is an HTML page, not JSON, and that's enough to break the parse. This is the exact trap [Claudio Kuenzler documented](https://www.claudiokuenzler.com/blog/1438/matomo-import-logs-script-error-json-decoder): a cron job kept an old `http` URL after a migration, and the redirect turned into a parse error. Use the final `https` address Matomo actually serves on.
- Something between you and Matomo is returning HTML. A reverse proxy handing back an error page produces the same symptom, the same way [a proxy returning a 502 instead of Matomo](/blog/self-host-matomo-reverse-proxy-502) does.
Don't try to confirm any of this by opening the base URL in a browser. The Matomo base URL normally serves the login screen or dashboard, not the Tracking API, so a page of HTML there tells you nothing either way. Test it the way the importer does: run a one-line import with `--debug` and look at the response Matomo actually sends back.
If you've seen "Matomo returned an invalid response" elsewhere, for instance from [cron archiving](/blog/matomo-cron-archiving-invalid-response), it's the same family of problem: a request to Matomo that came back as something other than the JSON the caller expected.
## "Invalid log lines": your format doesn't match your logs
The second failure is quieter and more demoralizing. The import runs to completion, then the summary says something like `0 requests imported successfully` and `244 invalid log lines`. Nothing went wrong with the run. The parser just couldn't match a single line against the format you told it to expect, so it dropped all of them.
The usual culprit: you set `--log-format-name=common_complete` (or `common_vhost`), but your Apache `LogFormat` doesn't start with the virtual host. Both of those formats expect the virtual host (`%v`) as the very first field. The standard Apache "combined" line, like the Nginx default, starts with `%h`, the client IP, and has no `%v` anywhere. The first field doesn't match, the whole regex fails, and every line lands in the invalid pile.
The four formats most people choose between, sorted by what the log line actually starts with:
| Log line starts with | Use this format |
|---|---|
| `%h` (client IP), request, status, no referrer or user-agent | `common` |
| `%h` plus `"%{Referer}i" "%{User-Agent}i"` | `ncsa_extended` |
| `%v` (vhost) first, then the common fields | `common_vhost` |
| `%v` (vhost) first, then the extended fields | `common_complete` |
`ncsa_extended` is Apache's combined format and the Nginx default, and it carries no virtual host. It's the one most people actually want. The two `%v`-first formats are not interchangeable: `common_vhost` is the vhost plus the common fields, `common_complete` is the vhost plus the extended fields (referrer and user-agent). Only reach for either if `%v` really is your first field.
Other sources have their own format names (`nginx_json`, `w3c_extended`, `iis`, `s3`, `elb`, `amazon_cloudfront`, and more). Run `import_logs.py --help` for the current list rather than guessing.
So the fix is one of two moves. Switch to `--log-format-name=ncsa_extended` to match your existing combined log, or add `%v ` to the front of your Apache `LogFormat` if you genuinely want per-vhost imports. The vhost-first formats earn their keep when one log feeds [several sites or measurables](/blog/matomo-multiple-sites-domains-measurables) and you want the importer to route each request to the right one by hostname. Just don't reach for them unless the field is actually there.
## Silent piped logging: it's buffering, not broken
The third one looks like a ghost. You move from manual imports to Apache piped logging, `CustomLog "|.../import_logs.py ..."`, the debug log says "Launched recorder" a few times, and then nothing. No visits. The same command run by hand against the same log works fine.
Assuming the manual run worked and Apache actually launched the process, the usual suspect on a quiet site is batching, not a broken pipe. In piped mode the importer is a long-lived process, and its recorder batches hits into a single bulk Tracking API request before sending. `--recorder-max-payload-size` sets how many log entries go into each request. On a busy site the batch fills fast and you never notice. On a quiet one it can sit partly full for a long time, which is why visits show up in sudden bursts, or only after you restart Apache and flush the buffer.
Set `--recorder-max-payload-size=1` and each request goes out the moment it arrives. You trade a little efficiency for immediacy, which is the trade you want on a low-traffic site where "did it work?" is the whole question. If setting it to 1 still gets you nothing, the problem is lower down: the Apache user can't execute the script, the trailing `-` is missing, or it can't read the auth file.
## The fix, in order
Don't debug inside Apache. Get a manual run clean first, then wire up the pipe.
1. Run a manual import of a small log file so you can actually read the summary:
```bash
python3 /path/to/matomo/misc/log-analytics/import_logs.py \
--url=https://analytics.example.com \
--idsite=1 \
--token-auth=YOUR_TOKEN \
/var/log/apache2/access.log
```
`--token-auth` on the command line is fine for a one-off test like this; for the permanent pipe, move the token into a config file (see below).
2. Fix `--url` so it points at your Matomo install over `https`, with no redirect in the way. Make `--idsite` a number (`--idsite=1`): it's the numeric site ID, never a site name or shortcode.
3. Match the format to your logs. Standard combined line with no `%v`? Use `--log-format-name=ncsa_extended`. Only reach for `common_vhost`/`common_complete` if the virtual host really is the first field.
4. Re-run and read the summary. You want a non-zero `requests imported successfully` and `0 invalid log lines`. Don't move on until you have both.
5. Only now wire up piped logging, adding `--recorder-max-payload-size=1` and the trailing `-` so the script reads from stdin:
```bash
CustomLog "|/path/to/matomo/misc/log-analytics/import_logs.py \
--url=https://analytics.example.com --idsite=1 \
--auth-config=/etc/matomo/import-auth.cfg \
--recorder-max-payload-size=1 --log-format-name=ncsa_extended \
--output=/var/log/matomo-import.log -" combined
```
In a long-running `CustomLog`, `--token-auth=YOUR_TOKEN` sits in the process arguments where any local user can read it with `ps aux`. Matomo's own docs call passing secrets that way deprecated. Put the token in a config file instead:
```ini
[auth]
token_auth = YOUR_TOKEN
```
Lock it down with `chmod 600 /etc/matomo/import-auth.cfg`, then pass `--auth-config=/etc/matomo/import-auth.cfg`. The `--output` flag sends the importer's own summary to a file you can tail, instead of letting it disappear into Apache's pipe.
6. Drop `--debug` in production. It's invaluable for the manual test and a liability in a piped `CustomLog`, where it floods the output file and buries any real activity.
## How to know it actually worked
- The manual run reports `invalid log lines: 0` and a non-zero count of imported requests. That's the real pass/fail signal: the API took your data.
- The visits show up under Visitors → Visits Log in Matomo, for the right site. One thing that fools people: server logs are imported with the timestamps in the log, not the time you ran the import. If you're backfilling last month's access log, check last month's date range, not today's.
- For piped logging on a quiet site, generate a few real requests yourself and confirm they land within a minute or so once `--recorder-max-payload-size=1` is set. If they do, the pipe is healthy.
## What we'd actually do
Treat the manual run as the only test that matters, and never skip it. Most "import_logs.py not working" tickets come down to one of three things: `--url` aimed at the tracked site instead of Matomo, a format name that assumes a `%v` you don't log, or a recorder quietly batching on a low-traffic pipe. Confirm `0 invalid log lines` and a non-zero import count against a small file, change one thing at a time, and only then hand the command to a `CustomLog`. The importer is dependable once it's configured. It's just terrible at telling you which of the three is wrong.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-import-logs-not-working) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-import-logs-not-working) if that's relevant.
---
# Matomo ecommerce not tracking? Why your orders and products reports stay empty
> Matomo Ecommerce is two separate things wearing one name: a setting that makes the reports exist, and tracking calls that fill them. Most stores only have the first. Here's why your Orders and Products reports read zero, and how to wire up the half that's actually missing.
Published: 2026-08-05
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-ecommerce-not-tracking
---
You added Matomo to your store, the pageviews and visits look healthy, and then you open the Ecommerce reports and there's nothing there. No orders, no products, or the Ecommerce menu isn't even in the sidebar. On a Magento shop it usually surfaces as "I can see general statistics, but nothing about products or orders." Your tracking isn't broken in the way it looks. One of the two halves of Matomo Ecommerce is missing, and you almost certainly only have the other one in place.
Matomo Ecommerce is two things wearing one name: a per-site setting that makes the reports exist, and the tracking calls that fill them, and most stores only have the setting (Magento and Adobe Commerce ship with no Ecommerce tracking, so Orders and Products read zero). Turn Ecommerce on under Administration → Websites, then make the store actually fire the calls through an extension, Matomo Tag Manager, or your own code. If you write the calls yourself, push `setEcommerceView` before `trackPageView` on product pages, since it attaches to the next pageview and records nothing if the snippet already fired one. To confirm it works, place a test order and watch it land in Visitors → Visits Log rather than waiting on the reports, which lag behind archiving.
That's the thing to get straight before you change anything. Matomo Ecommerce is really two systems with one name:
1. Enabling Ecommerce on the site. A per-site setting that makes the Ecommerce reports exist.
2. Sending Ecommerce tracking calls. The JavaScript (or server-side) events that report product views, cart updates, and completed orders.
The standard Matomo snippet you pasted into your store only does one thing: it sends pageviews. It has no concept of a product or an order. Unless something on the page explicitly fires the Ecommerce calls, those reports stay empty even with the setting switched on. And here's the part that catches Magento stores in particular: Magento 2 and Adobe Commerce ship with no built-in Matomo Ecommerce tracking. So the reports look broken straight out of the box, when really nothing was ever wired up to feed them.
## The root causes, in the order worth checking
- Ecommerce isn't enabled for the site. If the setting is off, the Ecommerce menu doesn't appear at all, which is the "statistics not found" version of the problem.
- No Ecommerce calls are being sent. Your snippet tracks pageviews only; nothing fires `addEcommerceItem` or `trackEcommerceOrder`. This is the usual Magento case.
- The order call never fires on the success page. Cart updates work, but `trackEcommerceOrder` doesn't run on the "thank you" page. A cached confirmation page, a redirect, or a checkout that loads the page over AJAX will all break it.
- Reports haven't been archived yet. The data is arriving, Matomo just hasn't processed it into reports.
- An ad blocker or consent tool is silently dropping the request while you test.
## The fix
### 1. Turn Ecommerce on for the site
Go to Administration → Websites → Manage, click the edit (pencil) icon for your site, scroll to the Ecommerce dropdown, and switch it from "Not an Ecommerce site" to "Ecommerce enabled". Pick your currency in the dropdown that follows, then save. An Ecommerce item now appears in the left-hand navigation.
If you don't see the menu after saving, you almost always saved the setting on the wrong site. Each site (idSite) is configured on its own, so double-check you edited the one your tracker actually reports to before assuming something deeper is wrong. If you run several stores or domains off one Matomo, our note on [organising multiple sites and measurables](/blog/matomo-multiple-sites-domains-measurables) is worth a glance.
### 2. Make your store actually send the Ecommerce calls
This is the half almost everyone is missing. Pick one of three routes.
An extension. The widely used JaJuMa "Matomo Analytics" extension for Magento 2 wires up product views, cart updates, and order conversions for you, and it has a server-side PHP tracking mode for more accurate order numbers. One thing worth being plain about, since people ask: the JaJuMa module listed on the Adobe Commerce Marketplace is commercial, so check its current pricing and editions before you commit. You're not forced to buy it. Matomo's Ecommerce reporting is free on its own; the extension only saves you from writing the tracking code yourself.
Matomo Tag Manager. If your store already pushes ecommerce events to a data layer (or you add an extension that does), [Matomo Tag Manager](/blog/matomo-tag-manager-first-party-adblockers) can read those events and fire the Ecommerce calls without hard-coding tracking into every template. With no data layer in place, this still needs developer work to populate one. Serving the container first-party also dodges some of the ad-blocker problems that quietly eat tracking requests.
Custom code in your theme. If you have dev resources, add the calls yourself. That's step 3.
### 3. If you wire the calls yourself, get the signatures right
Matomo's Ecommerce JavaScript API is small, but parameter order and types matter, and so does *when* each call fires. On a product or category page, set the Ecommerce view, then fire the pageview it belongs to:
```js
// productSKU is required; the rest are optional
_paq.push(['setEcommerceView', productSKU, productName, categoryName, price]);
_paq.push(['trackPageView']);
```
`setEcommerceView` doesn't send anything on its own; it attaches product data to the *next* pageview. If the standard snippet already fired `trackPageView` higher up the page, pushing `setEcommerceView` after it records nothing for that view. On product and category pages, push the Ecommerce view first, then call the pageview.
On add-to-cart or the cart page, list each item, then update the cart total:
```js
_paq.push(['addEcommerceItem',
productSKU, // required
productName, // recommended
categoryName, // optional
price, // optional
quantity // optional, defaults to 1
]);
_paq.push(['trackEcommerceCartUpdate', cartGrandTotal]); // cart total, required
```
On the order confirmation page, add every line item, then track the order:
```js
// one addEcommerceItem per line, then:
_paq.push(['trackEcommerceOrder',
orderId, // required, unique
grandTotal, // required, total incl. tax/shipping/discount
subTotal, // optional
tax, // optional
shipping, // optional
discount // optional
]);
```
Two things trip people up here. First, only `orderId` and `grandTotal` are required on the order call; the rest are optional, but if you do supply them, get the order right. Second, every monetary value has to be a number, not a string: `19.90`, not `"19.90"`. Matomo's docs are explicit that price, grandTotal, subTotal, tax, shipping, and discount must be integers or floats, and passing strings is the most common reason totals come out wrong or missing.
Unlike the product view, the cart-update and order calls aren't bound to a pageview. They fire on their own when the cart changes or the order completes, so they can run after the page's normal `trackPageView`.
### 4. Confirm the reports are archiving
If the data is arriving but the reports still say "No data," the problem is archiving, not tracking. Under Administration → System → General Settings, Matomo decides whether reports get processed when you open them in the browser, or only by a scheduled job. Browser-triggered archiving is fine for a low-traffic test site; for production you want a cron job so reports stay fresh without hammering the UI. If you haven't set that up yet, here's how to [set up Matomo cron archiving](/blog/set-up-matomo-cron-archiving).
## Verify it actually worked
Don't wait on the reports to tell you whether tracking is live; they lag behind archiving. Instead:
1. Place a test order.
2. Open Visitors → Visits Log for Today.
A correctly tracked order shows an Ecommerce icon and the order value right on that visit, the moment the call arrives, independent of archiving. That's your ground truth. If the order shows up in the Visits Log but the Ecommerce → Sales / Products report is empty, your problem is archiving (step 4), not tracking. If the order doesn't show up in the Visits Log at all, the call isn't firing, so go back to the success-page integration.
If you'd rather watch the wire, open DevTools → Network and filter for `matomo.php`. A tracked order sends a request carrying `idgoal=0`, the order id in `ec_id`, the total in `revenue`, and the line items in `ec_items`. No request, no order.
One more thing: run the test in a clean browser with ad blockers off and any consent banner dismissed or accepted. Otherwise you'll spend an afternoon debugging tracking code when the real culprit was a blocked request. If consent turns out to be a live concern rather than a test artifact, that's its own topic.
## Keeping it from breaking again
Order tracking breaks quietly, and almost always on the success page. After any change to your Magento theme or checkout (a new payment method, a redirect tweak, a caching layer on the confirmation page), re-run a single test order and check the Visits Log. Thirty seconds of work that catches the regression before a month of orders goes missing.
If ad blockers or consent opt-outs are measurably costing you order data, move order tracking [server-side](/blog/matomo-http-tracking-api-server-side). On the server you'd call the PHP tracker's `doTrackEcommerceOrder()` (the mode JaJuMa's extension exposes), or send a Tracking HTTP API request with `idgoal=0`, `ec_id`, `revenue`, and `ec_items`. Either one fires from your server when the order is actually placed, so it can't be blocked in the browser and doesn't depend on a JavaScript event surviving a redirect.
## What we'd actually do
If you're on Magento and don't want to touch code, the JaJuMa extension is the fastest path; just go in knowing it's a paid extension, not a free add-on. If you'd rather not pay for one, Matomo Tag Manager gets you there without a license, and where there's already a data layer to read from, it's the route we'd reach for first. Either way, fire the order call server-side for anything that matters, because browser-side order tracking is the part that silently breaks.
And whichever route you take, the check is the same, and it's the habit worth keeping: place a test order, watch it land in the Visits Log, then trust the reports.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-ecommerce-not-tracking) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-ecommerce-not-tracking) if that's relevant.
Empty Ecommerce reports almost never mean Matomo is broken. They mean nobody's feeding it yet.
---
# Matomo, multiple sites, and the data that keeps landing under one site
> If every site reports into the same Matomo, or a second tracking snippet kills the first, or you're hunting for a per-site token_auth that doesn't exist, you're tangling three things Matomo keeps strictly separate: measurables, domains, and tokens. Here's the model that fixes all of it.
Published: 2026-08-03
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-multiple-sites-domains-measurables
---
You run a few websites, maybe a WordPress multisite network, you self-host Matomo, and the data won't behave. Every blog reports into the same Matomo site no matter what you change. You go looking for "a separate `token_auth` for each website" and there isn't one. You paste a second tracking snippet onto a page to split traffic, and the *first* site goes dark. Or you want the same custom dimension on thirty sites and the thought of clicking through each one by hand is enough to close the tab.
These look like four different problems. They're one. Each comes from tangling three things Matomo treats as completely separate: a measurable, a domain, and a `token_auth`. Straighten those out and every one of these has a clean, boring fix.
In Matomo, JavaScript tracking is routed by Site ID (`idSite`) and nothing else, so merged data or a site that suddenly goes dark almost always means a shared or reassigned Site ID, not a missing per-site token. Give each measurable its own Site ID. When you want one page to report into two sites, add a second tracker with `addTracker` queued before `trackPageView` rather than pasting a second snippet, and to push the same custom dimension everywhere, create the slot once from the CLI then loop the Reporting API over your Site IDs. The `token_auth` only authenticates API and plugin calls; it never picks the destination.
## The three things Matomo keeps separate
Start with the measurable. It's the bucket your data lands in, usually a *Website*, and each one carries a unique Site ID: the `idSite` number you pass to `setSiteId`. That number is what separates your data. Not the domain, not the snippet, not a token. If two sources send the same Site ID, their hits pile into the same bucket and merge, and nothing you change elsewhere will pull them back apart.
A domain is just a URL, and it doesn't pair off with a measurable one-to-one in either direction. One measurable can own several domains; you add them as extra URLs or aliases on the site. One domain can feed several measurables. What a domain doesn't do is pick the destination bucket; that's the Site ID's job. It still matters for plenty else: Matomo uses your configured URLs to tell internal links from outlinks, to wire up cross-domain tracking, and to scope cookies. So set your domains up correctly. Just don't expect them to route a hit into the right site.
Then there's `token_auth`. It's an API token tied to a user account, used for the Reporting API, admin actions, and a few advanced tracking jobs like [server-side log import](/blog/matomo-import-logs-not-working). Plain JavaScript pageview tracking needs no token at all. There's no per-site token that routes data, so if you're hunting for one, stop: that's the misconception sitting underneath half of these problems. Sites are routed by Site ID. A token only proves who's asking the API; it says nothing about where a hit lands.
Keep those three apart and the rest is mechanical. Here are the three setups people actually hit.
## Scenario 1: many sites, each into its own measurable (WordPress multisite)
Goal: every blog in the network reports into its own Matomo site.
The classic symptom is everything piling up under site #1, and the instinct is to go hunting for per-site credentials. Wrong layer. Every blog is emitting the same Site ID, and that's the whole bug. The fix is per-site Site IDs, and the auth token has nothing to do with it.
1. Create one measurable per blog in Matomo, each with its own Site ID (or let the WordPress plugin auto-create them).
2. Connect WordPress to your self-hosted Matomo with the [Connect Matomo](https://wordpress.org/plugins/wp-piwik/) plugin (formerly WP-Matomo / WP-Piwik). Set the Matomo Mode dropdown to *Self hosted (HTTP API, default)*, enter your Matomo URL, and paste one auth token. You create that token in Matomo under Administration → Personal → Security → Auth tokens → Create new token. For a network, use a token from a user that can see and create every site; a super-user token is simplest, with the [security trade-offs that come with one](/blog/harden-secure-matomo-installation).
3. Map each blog to its own Site ID. On a network install, enable Connect Matomo as a network plugin and confirm each blog's tracking code carries the correct `idSite`.
Don't confuse the two. *Matomo for WordPress* embeds a whole Matomo *inside* WordPress, a different product for a different goal. For one central, self-hosted Matomo serving many blogs, *Connect Matomo* is the right tool. Network mode in the plugin is still marked experimental, so check the exact menu labels and the permission scope your token needs against the version you install.
One token, many Site IDs. The token is just the plugin's API credential; the Site IDs do the separating.
## Scenario 2: one site, into multiple measurables
Goal: send the same page to two Matomo sites (say, to split a slice of traffic off), so you add a second snippet. And the first site goes silent.
Here's the trap. Both snippets share one global `_paq` queue and one default tracker. The second snippet's `setSiteId` just reconfigures that same default tracker instead of creating a new one, so you don't end up with two destinations. You end up with one that's now pointed at site #2. You didn't add a tracker; you reassigned the one you had.
So don't duplicate the snippet. Add a second tracker to the one you already have, with `addTracker`. The catch is *where* you put it. A tracker only receives a hit if it already exists when that hit is pushed, and the standard snippet queues `trackPageView` right at the top. Tack `addTracker` on at the end and the first pageview has already gone to site #1 alone. So `addTracker` has to be queued before the pageview you want duplicated. The clean way is to fold everything into one snippet:
```html
```
With `addTracker` queued ahead of `trackPageView`, both sites get the first pageview and everything after it, because every `_paq.push` from here on applies to all trackers. Each tracker added through `_paq` inherits the default tracker's config (cookies, user ID, custom dimensions), so you rarely need to set them up twice. One caveat: only the *last* `addTracker` in the `_paq` queue takes effect, so this pattern gives you a second destination, not a third or fourth. And each tracker is a real extra request per hit. If you genuinely need many destinations, that's a [server-side tracking](/blog/matomo-http-tracking-api-server-side) job, not a pile of browser trackers.
But step back, because "I need two measurables" is usually the wrong answer to the real question. The reason is almost always separating internal traffic from external: staff visits versus customers. A second site is a clumsy way to do that. You split your data and lose any apples-to-apples comparison between the two halves. Better to keep one measurable, set a custom dimension like `Internal Traffic = true/false`, and use segments to view either slice or compare them side by side in one dataset. A custom dimension also keeps working once you anonymize IPs, which can make the IP-based segments people reach for first unreliable.
## Scenario 3: the same custom dimension on every measurable
Goal: add one custom dimension, say `Internal Traffic`, to all your sites at once without clicking through each.
There are two layers here, and the confusion is almost always mixing them up.
The dimension *slots* are installation-wide. Adding a slot changes the database schema for every site at once, so you do it once, from the CLI:
```bash
# add 10 new visit-scope slots (default is 5 per scope)
./console customdimensions:add-custom-dimension --scope=visit --count=10
```
Use `--scope=action` for action scope. The `--count` flag is real and saves you running the command ten times. It rewrites tables, so on a large database it's a real migration. Run it in a window where a schema change is acceptable, the same way you'd treat any [archiving or DB-heavy job at scale](/blog/matomo-archiving-memory-cpu-scaling).
The *configuration* of each slot is per-site. The name, whether it's active, the extraction rules: those live per measurable. To apply identical config everywhere, loop the Reporting API over your Site IDs. POST it, and put the token in the request body, not the URL:
```bash
curl "https://your-matomo.example/index.php" \
--data-urlencode "module=API" \
--data-urlencode "method=CustomDimensions.configureNewCustomDimension" \
--data-urlencode "idSite=1" \
--data-urlencode "name=Internal Traffic" \
--data-urlencode "scope=visit" \
--data-urlencode "active=1" \
--data-urlencode "format=json" \
--data-urlencode "token_auth=YOUR_TOKEN"
```
The token belongs in the body for a reason. A token in the query string lands in server logs and browser history, and a Matomo 5 token created with *Only allow secure requests* won't authenticate as a URL parameter at all. `--data-urlencode` also handles the space in `Internal Traffic` for you. The required parameters are `idSite`, `name`, and `scope`; `active`, `description`, `extractions`, and `caseSensitive` are optional.
Two cautions before you run this across a live network. First, `configureNewCustomDimension` isn't idempotent. Call it twice and you get two dimensions, or an error on a site whose slots are already full. So have the loop look before it writes: call `CustomDimensions.getConfiguredCustomDimensions` for each site and only create the dimension where a matching name isn't already there. Second, don't assume the new dimension lands on the same `idDimension` on every site. Matomo assigns the next free index per site, so if your sites started from different custom-dimension states, the same name can end up under different IDs. Capture the returned `idDimension` for each site and keep it; don't hard-code one number and expect it to match everywhere. To edit an existing dimension, use `configureExistingCustomDimension`, which takes `idDimension` and `idSite` first, then the same fields. It overwrites, so any field you leave out gets reset to default. Pass the full config, not just the part you're changing.
Notice what's doing the work in that last call: `token_auth`. Authenticating an admin API request is exactly what the token is for, and exactly what it had nothing to do with back in Scenario 1's tracking. Same word, two different jobs.
## Quick check that it actually worked
- Open Visitors → Visits Log (or Real-Time) on each site and confirm hits arrive where you expect them.
- Look at the All Websites dashboard. Every measurable should show its own traffic, not one site hoarding everything. That's the fastest way to catch a duplicated Site ID.
- For the bulk dimension, confirm it appears and is active in each site's settings, not just created in the schema.
## What we'd actually do
For a WordPress network reporting into one self-hosted Matomo: one measurable per blog, the Connect Matomo plugin in self-hosted mode, one super-user token, and a hard check that each blog emits a distinct `idSite`. The token is plumbing; the Site IDs are the work.
For "two measurables to split internal traffic": don't. Use one measurable, a custom dimension, and segments. You keep one clean dataset you can actually compare against itself, and it doesn't fall over when you turn on IP anonymization.
For the same dimension across dozens of sites: add the slot once from the CLI, then loop `configureNewCustomDimension` over your Site IDs. Treat the schema change like the migration it is.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-multiple-sites-domains-measurables) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-multiple-sites-domains-measurables) if that's relevant.
Almost every "Matomo won't separate my sites" problem is a Site ID problem wearing a token costume. Fix the ID, ignore the token, and the data sorts itself.
---
# Does Matomo need a consent banner? Cookieless tracking, fingerprinting, and making 'Refuse' actually work
> Matomo's cookieless config_id isn't a classic fingerprint, ePrivacy and GDPR are two separate questions, and the reason 'Refuse' doesn't stop your tracking is almost always a double-injected tracker. Here's the config behind all three. Not legal advice, just the mechanics.
Published: 2026-07-29
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-consent-cookieless-tracking
---
Two questions come up constantly in the Matomo forums, and they turn out to be the same question wearing different clothes. Does Matomo's cookieless tracking, the `config_id` that people keep calling a "fingerprint," need consent at all? And once you bolt a consent manager like tarteaucitron onto the site, why does Matomo keep recording visits from people who clicked Refuse?
The part that matters before anything else: this is configuration guidance, not legal advice. Whether you need a banner depends on your jurisdiction, the data you collect, and how your install is configured, and that call belongs to your DPO or legal counsel, not a blog post and not Matomo's defaults. What we can do is explain how Matomo's consent model works and how to wire the config so it matches whatever decision they hand you. Everything below is Matomo 5.x.
Cookieless Matomo drops the consent banner only where your jurisdiction grants a narrow analytics exemption and you meet every condition; France, via the CNIL, is the clearest case. Everywhere else, gate Matomo behind consent. If people who clicked Refuse still show up in your reports, the cause is almost always two trackers loading at once: one auto-injected by your CMS or plugin, one by your consent manager. Disable the auto-injected copy so your consent manager is the only thing loading Matomo.
## What Matomo actually collects (and why the config_id isn't a classic fingerprint)
Start here, because the legal question is downstream of the technical one. Out of the box, Matomo tracks immediately. Consent is something you opt into. It's not the default state, and if you do nothing the tracker fires.
When cookies are disabled, Matomo stops storing an identifier on the device and derives a server-side `config_id` for each visit instead. The `config_id` is a hash of a limited set of attributes (browser, operating system, plugins, IP address, language) mixed with a random salt that rotates every 24 hours and is then deleted. The IP goes in after your anonymisation setting is applied, which by default masks it; an install configured to skip IP anonymisation feeds the full address into the hash, so confirm that setting is on. Three things fall out of the design:
- The same visitor gets a completely different `config_id` the next day, so no person or device can be recognised across days. The salt that would let you reconnect them is already gone.
- The hash is scoped per website ID, so the same visitor browsing two of your sites gets two unrelated values.
- With no cookie, the default look-back window for stitching actions into one visit is only about 30 minutes.
That's the real difference between this and the kind of fingerprinting regulators worry about. A classic fingerprint is built to be stable, to recognise you next week. Matomo's is built to forget. It's a session-grouping hash with a 24-hour shelf life, not a persistent identifier. (Matomo documents this in its [config_id FAQ](https://matomo.org/faq/general/how-is-the-visitor-config_id-processed/), worth a read if you ever need to defend the choice internally.)
## ePrivacy and GDPR are two different questions
Most of the confusion in those forum threads comes from collapsing two legal layers into one. They're separate, and clearing one does not clear the other:
- ePrivacy (Article 5(3), as transposed into your national law) governs access to the device: reading or writing information on the user's terminal. That's the "do you need a cookie banner" question.
- GDPR governs what you do with the data once you have it: your lawful basis, retention, the rights you owe the visitor.
Cookieless Matomo removes the cookie-storage trigger, but it doesn't automatically put you outside ePrivacy. In most European countries the rules can still reach client-side analytics even when no cookie is written; a few jurisdictions, France among them, recognise a narrow exemption for audience measurement if you meet every condition. So going cookieless drops the banner question in some places and not others, and either way you still carry GDPR obligations on the data you collect. The reverse also happens: you can be fully GDPR-documented and still owe a banner because you're setting a cookie. Don't treat them as one switch.
## Route A: configure for exemption (no banner)
In France, the CNIL lists Matomo among the audience-measurement tools that can be exempt from prior consent when you configure it to their guidance. Broadly, exemption asks you to:
- Anonymise IP addresses (mask at least the last two bytes).
- Not cross-reference the data with other processing or share it with third parties.
- Disable cross-visit profiling (visitor profiles, Heatmaps & Session Recording) and avoid User ID and personal data.
- Cap the cookie lifespan (CNIL: 13 months maximum) and data retention (25 months maximum).
- Offer a visible opt-out and still inform visitors through your privacy policy.
As of Matomo 5.9.0 (and on Matomo Cloud), you don't have to hand-check those any more. Administration → Privacy → Compliance runs your config against the CNIL requirements per site and flags each setting as compliant, non-compliant, or unknown. Tick "enforce compliance where possible" and it applies the supported ones in one go: the two-byte IP mask, disabling visitor profiles and heatmaps, stripping campaign parameters, and dropping data retention to 180 days. It won't set up your opt-out or make the legal call for you, but it clears most of the manual fiddling.
Run the assessment first and read what it will change. Enforcing CNIL mode does more than mask IPs: depending on your setup it can switch off the Visits Log, real-time reports, raw-data exports, User ID, cross-domain tracking and ad-conversion exports, and restrict Ecommerce. Enabling it also permanently deletes any A/B Testing experiments. On a live shop or experimentation setup, confirm those losses are acceptable before you enforce, not after.
Two cautions on the route itself. This is a France-specific interpretation of how French law transposes ePrivacy, and another DPA may take a stricter line. And "exempt" means exempt from the prior-consent banner; it does not remove your transparency and opt-out duties. Decide with legal before you lean on it.
## Route B: gate Matomo behind consent
If you (or your regulator) want prior consent, you wire Matomo to wait for it. Matomo gives you two distinct modes, and picking the wrong one (or mixing the two APIs in one callback) is behind a lot of "this isn't behaving how I expected" threads. The difference is what each one gates:
| | `requireConsent` (strict) | `requireCookieConsent` (cookie-only) |
|---|---|---|
| Tracking request before consent | none | fires, cookieless |
| `_pk_*` cookies before consent | none | none |
| What it holds back | the whole tracking request | only the persistent cookies |
| Grant on Accept | `rememberConsentGiven` | `rememberCookieConsentGiven` |
| Revoke on Refuse | `forgetConsentGiven` | `forgetCookieConsentGiven` |
| Use it when | tracking itself needs a lawful basis | cookieless-by-default, only the cookie needs opt-in |
The two APIs are parallel but separate: the consent calls govern whether anything is sent at all, the cookie-consent calls govern only the `_pk_*` cookies. Keep a callback to one column and don't reach across.
### Strict: nothing fires until consent
```js
var _paq = window._paq = window._paq || [];
// No request and no cookie until the visitor consents.
_paq.push(['requireConsent']);
_paq.push(['trackPageView']);
```
Grant and revoke from your banner's callbacks:
```js
// On Accept
_paq.push(['rememberConsentGiven', 720]); // persist ~30 days; omit the number to remember until cleared
// On Refuse, or a later withdrawal
_paq.push(['forgetConsentGiven']);
_paq.push(['deleteCookies']); // clears any _pk_* cookies left from a previous Accept
```
### Hybrid: measure cookielessly first, cookies only after consent
This is the setup for "count visits before consent, set the persistent cookie after." It's the right answer when you want cookieless measurement up front rather than fully blocking Matomo.
```js
var _paq = window._paq = window._paq || [];
// Requests fire cookielessly right away; only the _pk_* cookies wait for consent.
_paq.push(['requireCookieConsent']);
_paq.push(['trackPageView']);
```
```js
// On Accept
_paq.push(['rememberCookieConsentGiven', 720]);
// On Refuse, or a later withdrawal
_paq.push(['forgetCookieConsentGiven']);
_paq.push(['deleteCookies']);
```
Two things that catch people out. The `remember*` calls persist the decision so Matomo re-applies it on the next page; the matching `setConsentGiven` / `setCookieConsentGiven` grant for the current pageview only, so use those only if your consent manager stores the choice itself and re-calls them on every load. And `requireConsent` / `requireCookieConsent` has to be pushed *before* `trackPageView` in the queue, or the gate arrives after the hit has already gone out.
## Why "Refuse" doesn't stop Matomo
Here's the one that fills the forums: you installed a consent manager, the banner shows up, you click Refuse, and Matomo records the visit anyway. More often than not the consent API isn't the problem at all. The Matomo tracking code is loading twice. Your CMS or plugin auto-adds it, and your consent manager adds it too, and the auto-injected copy runs no matter what the visitor clicks. The banner faithfully controls its own copy of the tracker while a second, ungated copy fires underneath it.
The fix is to make the consent manager the only thing that loads Matomo. In WordPress, which plugin you have decides where that switch lives:
- **Matomo for WordPress** (the all-in-one plugin that runs Matomo inside WordPress): go to Matomo Analytics → Settings and set Tracking mode to Disabled.
- **Connect Matomo** (formerly WP-Matomo, which connects to an external Matomo): go to Settings → Connect Matomo → Enable Tracking and set "Add tracking code" to Disabled.
Save, and the plugin stops auto-injecting the tracker. Now let your consent manager load Matomo instead. With the self-hosted [tarteaucitron.js](https://github.com/AmauriC/tarteaucitron.js), configure the Matomo service and run it in explicit-consent mode so nothing fires until the visitor chooses:
```js
tarteaucitron.user.matomoId = 1; // your Matomo Site ID
tarteaucitron.user.matomoHost = '//analytics.example.com/'; // your Matomo URL, with trailing slash
(tarteaucitron.job = tarteaucitron.job || []).push('matomo');
```
The integration key isn't the same across tarteaucitron versions and products: the self-hosted open-source build documents the `'matomo'` job key shown above, while Matomo's own Tarte au Citron guide defines a custom `MatomoAnalytics` service. Check which one your version expects rather than copying a snippet meant for the other. The same overall pattern (disable the plugin's own tracker, then let the consent manager load Matomo) applies to Complianz, Cookiebot and the rest; only the configuration surface changes.
## Verify it in the Network tab
Don't trust the banner; watch the wire. In a fresh incognito window, open DevTools → Network and filter for `matomo.php`:
- Before choosing, with `requireConsent` and an explicit-consent banner, you should see no request to `matomo.php`.
- Click Refuse: still no `matomo.php` request, and no `_pk_id` or `_pk_ses` cookies in the Application tab.
- Click Accept: the request fires, and the hit shows up in Matomo → Visits Log.
- Accept, then withdraw and reload: confirm the `_pk_*` cookies are gone. That's what `deleteCookies` is for, and it's the test people skip.
- In cookieless mode (`requireCookieConsent`), hits should arrive without any `_pk_*` cookies until consent.
If a `matomo.php` request still slips through on Refuse, double injection is the first suspect but not the only one. Walk the loaders in order:
1. The CMS or plugin auto-tracker (the switch above).
2. A hard-coded snippet in your theme.
3. A Matomo Tag Manager container firing a tag outside its consent rule.
4. A consent-manager snippet queued wrong, e.g. `requireConsent` pushed after `trackPageView`, so the gate lands too late.
5. A server-side tracking endpoint. DevTools won't show a server-to-server hit, so the same consent decision has to gate that call too.
6. A cached `matomo.js` or cached HTML still serving an old snippet.
Item 5 is the one to keep in mind if you're routing hits through a [server-side endpoint or the HTTP Tracking API](/blog/matomo-http-tracking-api-server-side). And because the `config_id` is scoped per site, getting this right matters most when you run [multiple sites or domains in one Matomo](/blog/matomo-multiple-sites-domains-measurables). For the broader privacy posture around all of this, our notes on [hardening a Matomo install](/blog/harden-secure-matomo-installation) cover the settings that sit next to these.
## What we'd actually do
If you're France-based and your data stays inside Matomo, run the 5.9.0 Compliance assessment first and see how close you already are to exemption. It's the lowest-effort honest path, and it's now a few clicks. If you operate under a stricter DPA, or you're not sure, default to `requireConsent`, wire it through one consent manager, and verify Refuse in the Network tab before you call it done. Either way the technical work is small. Deciding which route is legitimate for you is the part to hand to someone qualified to make it.
The thing to hold onto is that Matomo's cookieless mode really is different from surveillance fingerprinting. A 24-hour hash that deletes its own salt is built to forget you, not to follow you. That gives you configuration options most analytics stacks don't have. Use them on purpose.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-consent-cookieless-tracking) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-consent-cookieless-tracking) if that's relevant to you.
Configure the tracker to match the legal call. Don't let the legal call get made by a tracker you forgot was running twice.
---
# Why your Matomo reports are suddenly full of bot traffic (and how to get it out)
> If your Matomo visits doubled overnight, your server-side tracker reports two to three times what the JavaScript tag does, or you're drowning in Direct Entry, one-page, zero-second hits, bots are almost certainly the cause. Matomo already filters the honest ones. Here's how to handle the rest and clean up the reports you already have.
Published: 2026-07-27
Categories: Matomo Tracking
Canonical: https://martez.io/blog/exclude-bots-from-matomo
---
A sudden flood of Direct Entry, one-action, zero-duration visits is a strong bot signal. It is not proof by itself. Before filtering, confirm three things: bot tracking is not forced on, the spike is not a duplicate tracker or recent campaign/tracking change, and the extra visits come from user agents, IPs, providers, or log requests your JavaScript tag would not normally see.
A sudden flood of Direct Entry, one-action, zero-second visits is usually bots, but first confirm nobody turned tracking *on* for them with a stray `bots=1` or `--enable-bots`. Once that's clear, install Tracking Spam Prevention and switch on only the filters that are safe for your tracking path, then add a temporary `actions>=2` segment to clean up the visits you already have. Those filters are all off by default because some legitimate setups look automated, so if you track server-side, check the Visits Log before turning on cloud-provider or server-side-library blocking.
If you want the short version, work through it in this order:
1. Search your JavaScript snippets, image-tracker URLs, HTTP API calls, and log-import commands for `bots=1` or `--enable-bots`.
2. Work out where the spike comes from: the JavaScript tag, the HTTP API or a server-side SDK, or log import.
3. If log import is involved, expect a higher bot baseline and filter the access-log traffic. If the HTTP API or an SDK is involved, audit the calls you emit, the visitor IPs, and the visitor IDs before you assume bots.
4. Create a temporary `actions>=2` segment for the reports you already have. Push durable blocking upstream when Matomo has no honest bot signal to match.
It helps to start from what Matomo already does for you. Every tracking request that reaches it runs through the bundled DeviceDetector library, and anything matching the bot list is dropped before it becomes a normal visit, unless you explicitly override that. So detected bots stay out of your reports without any work on your part. What slips through is usually traffic DeviceDetector can't recognize yet, plus the gaps between browser-side, server-side, and log-based tracking.
Before you start filtering, prove the spike really is bots. A few things to check:
- In Visits Log, look at the user agent, IP address, provider, country, entry page, action count, and visit duration for the suspect visits.
- Compare `matomo.php` requests in your access logs with what the browser tag sends. If the server shows far more pageview calls than the browser, find which integration emits them.
- Check for duplicate tracking: Matomo Tag Manager and the raw JavaScript snippet both firing, duplicate pageview calls in a single-page app, or a new server-side tracker running beside the browser tag.
- Check whether consent, adblock handling, campaign tagging, static files, errors, redirects, or log-import options changed when the spike began.
## Why some bots slip past Matomo's default filtering
Two things keep ordinary bots out of your reports by default, and knowing what they are tells you why the survivors get through.
The first is DeviceDetector. Matomo runs every request's user agent through it, and anything matching a known crawler, spider, or monitoring service is dropped before it is stored as a normal visit. That is why the well-behaved bots never show up in the first place: Googlebot, Bingbot, uptime checkers, and other tools that identify themselves.
The second filter is JavaScript itself. The standard Matomo tag only fires when a browser executes JavaScript, and most simple crawlers and scripts don't. They request the HTML, parse it, and move on without ever running your tracker.
That leaves three common paths for bot-looking traffic:
- Log import reads web server access logs. Those logs include requests the JavaScript tag never saw, including requests from bots that do not execute JavaScript.
- The [HTTP Tracking API and server-side SDKs](/blog/matomo-http-tracking-api-server-side) only record the pageviews and events your code sends to `matomo.php`. They bypass browser execution, so bots can appear if your server emits pageviews for bot requests, but a two- or three-times delta can also come from duplicate calls, missing or unstable visitor IDs, consent/adblock differences, or tracking static, error, and redirect requests.
- Modern stealth bots use headless browsers, rotate through cloud or residential IP ranges, and copy real browser user-agent strings. DeviceDetector can only catch a bot that identifies itself, so a crawler wearing a current Chrome user agent can walk through the default filter.
## First, make sure you aren't force-tracking them
Before adding filters, rule out the one setting that does the opposite of what you want. Matomo has a `bots=1` parameter that explicitly *turns bot tracking on*, and it shows up in more places than people expect:
`bots=1` forces detected bots into normal visit/action tracking when Matomo's HTTP API `recMode` parameter is not set. Use it only when you deliberately want bot requests counted as ordinary visits.
```
// JavaScript tracker: remove this line if it's present
_paq.push(['appendToTrackingUrl', 'bots=1']);
// HTTP Tracking API / image tracker: drop &bots=1 from the request URL
https://example.org/matomo.php?idsite=1&rec=1&bots=1
// Log import: drop the --enable-bots flag
python3 import_logs.py --url=https://example.org --enable-bots access.log
```
If any of these is set, often copied from a forum answer or left over from debugging, bot tracking is on and Matomo's default filtering is being overridden. Remove it, and the default exclusion comes back.
## Exclude the bots that identify themselves
For a bot that sends an honest, recognisable user agent that DeviceDetector somehow does not catch yet, exclude it by hand. In Matomo's admin, open Administration > Settings > Websites and use *Global list of user agents to exclude* and *Global list of Excluded IPs*. One user-agent entry per line; Matomo matches the substring anywhere in the visitor's user-agent string. The IP list accepts wildcards, ranges, and CIDR notation. Since Matomo 4.1.1 the user-agent list also accepts a regular expression:
```
/bot|spider|crawl|scanner/i
```
Keep the patterns specific. A short string like `bot` will quietly match legitimate user agents too, so over-broad rules cost you real visits. These exclusions apply going forward only; they never delete visits you have already collected. If you have found a genuine bot that Matomo does not recognize, paste its user agent into Administration > Diagnostic > Device Detection or [devicedetector.net](https://devicedetector.net) to confirm. If it really is missed, open an issue or pull request on the `matomo-org/device-detector` repo so future versions catch it for everyone.
## Catch the ones that don't: Tracking Spam Prevention
User-agent and IP lists only work on bots you can name, and the traffic inflating most reports usually does not give you a clean name to work with. For those, use [Tracking Spam Prevention](https://matomo.org/faq/how-to/block-spam-and-bot-traffic-with-tracking-spam-prevention/), an official Matomo plugin included with Matomo Cloud and available from the Marketplace for On-Premise and WordPress. Every filter is off by default, because some tracking setups send legitimate requests that look automated.
| Filter | Blocks | Enable when | Do not enable when | False-positive risk |
| --- | --- | --- | --- | --- |
| Cloud providers | Tracking requests from IP ranges such as AWS, Azure, Google Cloud, DigitalOcean, and Oracle Cloud. Some providers can also be detected through the geolocation database. | You use the JavaScript tracker and Matomo sees the visitor's real IP address. | Your HTTP API, SDK, replay, proxy, or log-import flow sends legitimate requests from a cloud server. Allowlist those senders first. | Medium |
| Headless browsers | Common headless-browser signatures used by automation and scraping tools. | You only want standard browser traffic in the reports. | Your real traffic includes automated tests, app traffic, IoT devices, or non-traditional browser environments that should be counted. | Medium |
| Server-side libraries | Requests that identify as cURL, HTTP, Guzzle, Postman, and similar clients. | Your site is JavaScript-only and those clients are not valid visitors. | You use Matomo PHP, Java, Python, Android, iOS, or another server-side tracking method. | High |
| Max actions per visit | Visits that exceed a configured action count, after which Matomo stops recording further actions and can block the IP for up to 24 hours. | The same visit or IP records hundreds of actions outside normal user behavior. | You track highly interactive applications where real users can generate many actions. | Medium |
| Country include/exclude | Countries outside the market you intentionally track. | The site has a narrow, stable service area. | You serve a global audience, have unreliable geolocation, or care about travelers and VPN users. | Medium |
If you import logs, start with the filters that match the unwanted log traffic and test them against the Visits Log. If you use the HTTP API or a server-side SDK, first confirm Matomo receives the visitor IP, not your server or proxy IP, and allowlist any legitimate cloud sender before enabling cloud or server-side-library blocking.
## Clean up the reports you already have: Segments
Everything above is forward-only. To get a cleaner picture of traffic that already landed, use a temporary reporting Segment. It filters the view without deleting any data, which is exactly right for visits you cannot un-track.
| Purpose | Segment | Use when | What it removes |
| --- | --- | --- | --- |
| Conservative engaged traffic | `actions>=2` | You need a quick baseline that drops the one-action flood. | Real one-page visits and other legitimate bounces. |
| Engaged traffic with non-zero duration | `actions>=2;visitDuration>0` | The spike is mostly one-action, zero-duration traffic and duration matters for the report. | Real short visits, especially when heartbeat timing is not representative. |
| Germany-only reporting view | `actions>=2;countryCode==de` | The site is genuinely Germany-only and non-German traffic is not part of the business question. | Legitimate traffic from outside Germany, including VPNs and travelers. |
Start with `actions>=2`. If obvious bots remain, test `actions>=2;visitDuration>0`. Do not make `referrerType!=direct` your default cleanup segment; it removes real direct visits. Use it only as a narrow diagnostic view when you intentionally want to inspect the Direct Entry flood without treating that as the cleaned baseline.
## If you'd rather watch the bots than hide them
Sometimes you want bot activity visible, just not mixed into your real numbers. The community [Bot Tracker plugin](/blog/matomo-bottracking-table-doesnt-exist) scans incoming user agents for bot keywords and logs those visits into separate reports, out of your normal visitor log.
Another option is to point bot traffic at a dedicated Matomo site with `bots=1`, so it is tracked deliberately and quarantined away from the site that matters. Be precise about what that does: `bots=1` records detected bots as normal visits/actions in that isolated site. It is not native bot telemetry.
For Matomo 5.7+ HTTP API requests from supported user-triggered AI assistant bots, Matomo also documents `recMode=1` for bot-only tracking and `recMode=2` for automatic routing between bot tracking and normal visit tracking. That is a different path from `bots=1`, and current bot telemetry is limited to those supported AI assistant cases.
## When user-agent filtering isn't enough
Fully disguised bots leave no honest signal to filter on: rotating residential IPs, current real browser user agents, genuine headless rendering. There's no Matomo setting that catches them, because there's nothing for Matomo to match. Push filtering upstream to your firewall, WAF, or Cloudflare, where you can rate-limit and challenge traffic before it reaches the tracker at all, and lean on behavioral segments (multi-page, has a referrer, reasonable dwell time) for whatever still gets through.
One technique you will see floated on the forum is conditional firing: only output the Matomo snippet for requests that carry valid `Sec-Fetch-*` headers and a current real browser user agent. It can help, but it is a community workaround, not a Matomo feature. I have seen this fail with full-page caches and plugin-managed snippets, and header behavior is not a stable analytics contract. Treat it as a last resort you test carefully, not a fix you rely on.
## What we'd actually do
For most JavaScript-only sites the order is short: confirm `bots=1` is not set anywhere, install Tracking Spam Prevention, switch on cloud-provider and headless-browser blocking after checking the Visits Log, then add an `actions>=2` segment to clean up the history.
If you import logs, expect a higher bot baseline as the normal state of things and filter based on access-log evidence. If you track server-side, audit what your application emits before turning on filters that block cloud providers or server-side libraries. Save the user-agent and IP exclusion lists for specific offenders you can actually name; they are precise, but they do not scale to traffic that is actively hiding from you.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=exclude-bots-from-matomo) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=exclude-bots-from-matomo) if that's relevant.
Bots will always find your tracker. You won't shut them out completely, and you don't have to. The goal is keeping them out of the numbers you actually report on.
---
# Where Matomo's country flags come from (and how to change them)
> The little flag next to each visitor in Matomo isn't guessed from language or timezone; it's the country your geolocation database resolved from the visitor's IP. So a wrong flag is almost always a geolocation problem, not a flag problem. Here's how country detection actually works, why one looks wrong, and how to swap the icons.
Published: 2026-07-22
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-country-flags-geolocation
---
Open Visitors → Locations in Matomo and every row carries a little country flag. Two questions usually follow: where does that country actually come from, and can I swap the flag for a different icon? Sometimes there's a sharper third one. A visitor you're certain is in Germany is flying the wrong flag entirely, and now you're wondering whether to trust the report at all.
Here's the model that answers all three. The flag is just the icon for whatever ISO 3166-1 country code Matomo resolved for that visit. It isn't drawn from browser timezone or the domain someone arrived on. So a wrong flag is almost always a wrong *country*, and a wrong country is a location-detection problem, not an icon problem.
There's one catch before you go any further: how Matomo works out the country depends on which geolocation provider you've selected. The Default provider guesses from the browser's language header, which is coarse and frequently wrong. The DBIP / GeoIP 2 providers look the visitor's IP up in a real database. If you've never opened Administration → System → Geolocation, you're probably still on Default, so that's the first thing to check.
(And the old forum gripe that the flags were "blurry," navy indistinguishable from black? Long gone. Matomo ships a flat, redesigned set now.)
The flag is just the icon for whatever country Matomo resolved from the visitor's IP, so a wrong flag almost always means a wrong country rather than a broken image file. Check Administration → System → Geolocation first: the Default provider only guesses from the browser's language header, while DBIP / GeoIP 2 looks the real IP up in a database. If you sit behind a proxy or CDN, confirm the visitor's actual IP is reaching Matomo instead of the proxy's. Only touch the flag PNGs once the country is already correct and you just want different artwork.
## Where the flag actually comes from
When you're on a GeoIP 2 provider, the country is the end of a short chain. The tracker (or the HTTP Tracking API) records the request, Matomo looks the client IP up against a geolocation database to resolve Country, Region, and City, and the flag is the icon for the resolved ISO code: `de`, `gb`, `fr`, and so on.
The Default provider skips this chain entirely; it reads the browser language and never touches an IP database. Once you switch to DBIP / GeoIP 2, the visitor's IP becomes the input, and only two things can go wrong from there: the wrong IP arrives, or the database is too weak to resolve it. Work it backwards in that order.
### The reverse-proxy trap (the most common wrong-country cause)
If you self-host behind Nginx, a load balancer, or Cloudflare, and every visitor resolves to the same country (usually wherever your server lives), Matomo isn't misreading the database. It's geolocating your proxy's IP instead of the visitor's, because that's the IP arriving at PHP. The visitor's real address is sitting in a forwarded header Matomo hasn't been told to trust yet.
The fix has two sides: your proxy has to pass the real client IP, and Matomo has to be told which header to read. Matomo picks up forwarded headers when you list them in `config/config.ini.php` under `[General]`. PHP rewrites header names on the way in, so `X-Forwarded-For` arrives as `HTTP_X_FORWARDED_FOR`:
```ini
[General]
; Trust the client IP your proxy forwards.
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
; Cloudflare sends the real visitor IP in its own header instead:
; proxy_client_headers[] = HTTP_CF_CONNECTING_IP
; Matomo 5 reads the LAST IP in a forwarded chain by default.
; If your proxy puts the visitor's IP first, flip this back:
; proxy_ip_read_last_in_list = 0
```
One safety point: only trust these headers from a proxy you actually control. Any request that reaches PHP directly can forge `X-Forwarded-For` and fake its own location, so if Matomo is reachable without going through the proxy, lock that path down.
This is the single biggest source of "all my visitors are in the wrong country" reports, and it's worth ruling out before you touch anything else. If the proxy layer itself is misbehaving, our [reverse-proxy 502 walkthrough](/blog/self-host-matomo-reverse-proxy-502) covers the header plumbing in more detail.
## Make country detection accurate
Once the right IP is reaching Matomo, point it at a decent database. Go to Administration (the gear icon) → System → Geolocation and pick a provider:
| Provider | What it uses | Best for |
| --- | --- | --- |
| Default | Browser language header | Nothing serious; it's a guess, move off it |
| DBIP / GeoIP 2 (PHP) | IP lookup in a bundled database | Shared hosting, no server module needed |
| DBIP / GeoIP 2 (HTTP Server Module) | An Apache or Nginx module at request time | Your own server, fastest lookups |
For almost everyone, DBIP / GeoIP 2 (PHP) is the right starting point. Then set up automatic database updates so the data doesn't go stale: free DB-IP databases refresh monthly, MaxMind GeoLite2 is free with a license key and a download URL, and commercial databases are more precise again. Pick a weekly or monthly cadence and save.
If you want the full database-and-key path (generating a GeoLite2 key, pasting the URL, scheduling the auto-updates), we wrote that up separately in [setting up GeoIP2 / GeoLite2 in Matomo](/blog/matomo-geoip2-geolite2-setup). It's the natural companion to this post.
Now the caveat that trips people up. Region and City reports only populate for visits tracked while a GeoIP 2 database was active. Switch geolocation on today and the columns fill in going forward; they aren't backfilled automatically for visits you already recorded.
"Not automatically" isn't "never," though. Matomo ships a console command that re-geolocates historical visits from the data it still has:
```bash
# Re-attribute geolocation for visits in a date range
php ./console usercountry:attribute 2025-01-01,2026-06-01
```
A few things to know before you run it:
- It walks every matching visit, so it's slow over large ranges.
- Run it with the PHP provider. It can't use the Apache or Nginx server modules, which resolve location by issuing a web request the command can't make.
- It only works from the visit data Matomo still has. If your privacy settings anonymized stored IPs aggressively, how far back it can re-resolve is limited.
- When it finishes, reprocess the affected reports. The command updates the visit log, but archived report tables can keep showing the old country until archiving runs again.
It's exactly the tool you want after you finally configure a real database, or after a bulk [server-side log import](/blog/matomo-import-logs-not-working) where the visits landed before geolocation was dialed in.
## Replace or customize the flag icons
If the country is right and you just want a different flag style (a regional flag, a custom design), the icons live in the Morpheus theme, named by lowercase ISO 3166-1 alpha-2 code:
```
plugins/Morpheus/icons/dist/flags/
us.png de.png gb.png fr.png ...
```
They used to live under `plugins/UserCountry/` and moved here when Matomo consolidated its icon assets, so older forum posts pointing at the old path are out of date. The set is built from the [matomo-icons](https://github.com/matomo-org/matomo-icons) repository: SVG sources resized to PNGs 48px tall on a 4:3 ratio, which the UI then renders around 16px tall.
To swap one:
1. Export your replacement as a PNG matching the existing file's dimensions. Open the current `de.png` (or whichever you're replacing) and match it exactly instead of guessing.
2. Name it by ISO code: `de.png` for Germany, `gb.png` for the United Kingdom, `us.png` for the United States.
3. Drop it into `plugins/Morpheus/icons/dist/flags/`, replacing the file that's there.
4. Hard-refresh the Matomo UI (clear the browser cache) to see it.
Two caveats that matter. A Matomo upgrade will overwrite your file, because the `dist/` folder ships with core and gets replaced when you update. Keep a copy of your custom icons and re-apply them after each upgrade; the durable route is to contribute the change upstream to the matomo-icons repo and let it flow back into the build. And I'm not aware of a documented, supported way to override these PNGs from a theme or plugin, so for a single self-hosted instance, replacing the file directly is the pragmatic call. Just treat it as non-permanent.
## Verify it worked
For geolocation, with a provider selected on the Geolocation screen, open Visitors → Locations & Provider and check a recent visit. The country (and region and city, if your database resolves them) should look right.
For custom flags, open Visitors → Locations or the Visitor Map after clearing the cache, and confirm your replacement renders for the relevant country.
One last thing you'll run into: entries like Unknown aren't broken flags. They're placeholders for visits the database couldn't pin to a single country, usually a missing or stale database, or that reverse-proxy IP problem again.
## What we'd actually do
A wrong country is nearly always geolocation, not the flag, so fix the chain in order. First confirm the visitor's real IP reaches Matomo (the proxy header is the usual culprit), then put a proper GeoIP 2 database behind it with auto-updates. For most instances, GeoIP 2 (PHP) with MaxMind GeoLite2 on a scheduled update is the sweet spot: accurate, free, and no server module to babysit. Only reach for the icon files if you genuinely want a different look, and when you do, keep a backup, because the next upgrade will clobber it.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-country-flags-geolocation) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-country-flags-geolocation) if that's relevant.
The flag is downstream of the IP. Get the IP right and the flag takes care of itself.
---
# Your Matomo map shows every visitor as 'Unknown'? Set up GeoIP2 with the free GeoLite2 database
> If your self-hosted Matomo resolves visitors to 'Unknown' or one default country, you're almost always missing a GeoIP2 database. The old .dat downloads are dead and GeoLite2 now needs a free license key. Here's the current Matomo 5.x setup, why it broke, and how to keep it updating itself.
Published: 2026-07-20
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-geoip2-geolite2-setup
---
Your map shows "Unknown" because Matomo has no readable GeoIP2 database, not because tracking is broken. On Matomo 5, create a free MaxMind license key (answer "No" to the GeoIP Update question), paste the `GeoLite2-City` download URL into Administration → System → Geolocation, and set the provider to `DBIP / GeoIP 2 (Php)`. Keep your archiving cron running so the database refreshes itself instead of going stale. One thing to rule out first: if every visitor resolves to the same IP, that's a proxy/`X-Forwarded-For` problem, and no database will fix it.
If you've opened Visitors → Locations in a self-hosted Matomo and found every visitor resolving to "Unknown," a single default country, or nothing at all on the map, the tracking isn't broken. Matomo is recording the visits fine. What's usually missing is the geolocation database it needs to turn an IP address into a country, region, and city: either there's no GeoIP2 database installed, or the one you have sits in an old format Matomo can no longer read.
The other way people land here is an old `wget` cron job that used to download `GeoLiteCity.dat.gz` and now fails quietly. The URL 404s or bounces to a login page, and nobody notices until the map goes blank. Same root cause, different symptom. The fix is the same either way: move to Matomo's current GeoIP2 workflow with a free MaxMind license key, and let Matomo refresh the database for you.
## What actually changed
Two things shifted, and most of the older tutorials never caught up.
The first is that the old `.dat` databases are gone. MaxMind removed the free GeoLite Legacy builds (`GeoLiteCity`, `GeoLiteCountry`) on January 2, 2019, and retired its broader paid GeoIP Legacy `.DAT` products later. Matomo dropped its old GeoIP plugin in favour of GeoIP2, which reads the modern binary `.mmdb` format. Any guide that tells you to download a `.dat` file is pointing at something that no longer exists. If a script of yours still fetches one, that script is the problem.
The second is that free GeoLite2 now needs an account. Since the end of 2019 you can't download GeoLite2 anonymously. MaxMind wants a free account and a license key, which you pass inside the download URL. The data is still free; you're authenticating, not paying. This is the step the old `wget` lines skipped, which is exactly why they break.
The plugin you need, GeoIp2, ships bundled with Matomo 5.x, and Matomo can download and refresh the database on a schedule for you. In almost every case there's no custom shell script left to write.
A missing database isn't the only reason a map goes blank. Open Visitors → Visits Log and look at the IP on a few recent visits. If you see real, varied public addresses, a GeoIP2 database is what you're missing. Continue below. But if every visit shares the same IP, a private RFC1918 range (`10.x`, `192.168.x`), `0.0.0.0`, or your proxy/server's own address, no geolocation database will save you. Matomo is geolocating the wrong IP. Fix proxy IP detection first (the `proxy_client_headers` setting and your reverse proxy's `X-Forwarded-For` handling), then come back to this.
## The fix on Matomo 5.x
### 1. Create a free MaxMind account and a license key
Sign up at maxmind.com for the free GeoLite2 product and confirm your email. In the account portal, go to Manage License Keys → Generate new license key, give it a description like `Matomo`, and answer No to "Will this key be used for GeoIP Update?". That last answer matters. "No" gives you a key that works with the direct download URL Matomo uses; "Yes" produces a key tied to MaxMind's separate `geoipupdate` protocol, which the built-in updater doesn't touch. Copy the key right away, because MaxMind shows it to you exactly once.
### 2. Build your download URL
Take this template and append your key to the end:
```
https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&suffix=tar.gz&license_key=YOUR_LICENSE_KEY
```
`GeoLite2-City` gives you country, region, and city. If you only care about country and want a smaller, faster file, swap in `edition_id=GeoLite2-Country`. If you just generated the key, give it a minute or two before the link goes live. Worth a sanity check first: paste the finished URL into a browser and it should immediately start downloading a `tar.gz`. If it returns an error instead, the key is wrong, not active yet, or the URL is malformed. Fix that before you touch Matomo.
### 3. Point Matomo at it
Log in as a super user and open Administration (the cog) → System → Geolocation. Scroll to the "Setup automatic updates of GeoIP databases" section, paste your full URL into the "Location Database Download URL" field, choose weekly or monthly, and save. Matomo downloads the `tar.gz`, unpacks it, and installs the `.mmdb` into its `misc/` directory. You'll end up with something like `misc/GeoLite2-City.mmdb` under your Matomo root.
### 4. Activate the GeoIP2 provider
Back at the top of the same Geolocation page, select "DBIP / GeoIP 2 (Php)" as your location provider and save. That's the right pick on shared hosting and most installs. It reads the `.mmdb` in pure PHP, and it's faster still if your host has the `maxminddb` PHP C extension, though it works fine without one. Only choose "DBIP / GeoIP 2 (HTTP Server Module)" if you've actually installed the Apache or Nginx `mod_maxminddb` module and configured the server to set the lookup variables. If you haven't, that option has no data to read.
One provider covers both MaxMind and DB-IP databases. Matomo merged them under a single GeoIP2 reader, which is why the label says "DBIP / GeoIP 2" even though you fed it a MaxMind file.
## Make sure it actually keeps updating itself
Here's the part that bites people six months later, so it's worth stating plainly: Matomo's geolocation auto-updates ride on its scheduled tasks, and those run whenever the archiving process runs. The weekly or monthly refresh isn't a separate timer. In production that scheduled run is almost always `console core:archive` fired by cron (web cron counts too). No scheduled archive run, no auto-update, and the database you installed today slowly goes stale as MaxMind reassigns IP ranges.
So if you haven't set up the [Matomo archiving cron](/blog/set-up-matomo-cron-archiving) yet, do that first and the GeoIP refresh rides along for free. And if archiving is set up but [throwing an "invalid response" or failing silently](/blog/matomo-cron-archiving-invalid-response), fix that too, because a broken archiving run takes the geolocation update down with it.
Two other things quietly break this. The license key can stop working: if updates start failing months in, check the key's status in your MaxMind account, replace it if it's been revoked, and confirm you haven't hit the GeoLite download limit, then update the URL in Matomo. And outbound requests get blocked: if your Matomo sits behind a [reverse proxy or a locked-down firewall](/blog/self-host-matomo-reverse-proxy-502), confirm the server itself can reach `download.maxmind.com`, follow the HTTPS redirect, and reach the Cloudflare R2 host MaxMind now serves the actual file from (`mm-prod-geoip-databases.…r2.cloudflarestorage.com`). The browser test in step 2 runs from your machine, not Matomo's, and the two don't always share the same egress. From the server itself, `curl -sI "$YOUR_URL"` should end in a `200` after the redirect.
Matomo geolocates a visit when it's tracked, so the database you just installed only helps visits recorded from now on. Last month's "Unknown" rows stay Unknown on their own. To backfill them, run `php ./console usercountry:attribute START_DATE,END_DATE` (for example `2026-01-01,2026-05-31`) from your Matomo root, then reprocess the reports for that range so the Locations data reflects the re-attributed visits.
## Verify it worked
Three quick checks:
- On the Geolocation page, the provider you selected should show a working status and a sample lookup (usually your own IP) resolving to the right place.
- Record a fresh visit and open Visitors → Locations. Countries, regions, and cities should populate, and the [country flags on the map](/blog/matomo-country-flags-geolocation) should fill in instead of reading as unknown.
- After the next scheduled archiving run, check the modification date on `misc/GeoLite2-City.mmdb`. A recent timestamp proves the auto-updater is wired up, not just the first manual download.
## What we'd actually do
For most self-hosted installs the whole job is short: free MaxMind account, a license key with "No" to the GeoIP Update question, the `GeoLite2-City` download URL pasted into the Geolocation admin, set to weekly, provider set to "DBIP / GeoIP 2 (Php)", and a working archiving cron behind it. Don't hand-roll a `wget` script. There's nothing left for one to do that the built-in updater doesn't do more reliably.
If you have a hard requirement for OS-level updates, say you manage the database centrally across a few apps, use MaxMind's official `geoipupdate` package with a `GeoIP.conf` (this is where the "Yes, for GeoIP Update" key belongs):
```ini
# /etc/GeoIP.conf
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_GEOIP_UPDATE_KEY
EditionIDs GeoLite2-City
```
There's a catch worth knowing: Matomo doesn't give you a field to point at an arbitrary path like `/var/lib/GeoIP/`. It reads from its own `misc/` directory. So either configure `geoipupdate` to write straight into `path/to/matomo/misc/`, or copy or symlink the resulting `GeoLite2-City.mmdb` into `path/to/matomo/misc/GeoLite2-City.mmdb` after each run, with permissions the Matomo PHP user can read. On Docker that usually means a bind mount or symlink from the host file into the container's `misc/`. That's the exception, though, not the default. And if you'd rather not create a MaxMind account at all, Matomo can one-click download a free DB-IP database with no account whatsoever. It's a notch less accurate at city level than GeoLite2, but it's genuinely zero-config and a fine place to start.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-geoip2-geolite2-setup) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-geoip2-geolite2-setup) if that's relevant.
Get the key, paste the URL, check the cron. Your map fills itself in from there.
---
# Matomo archiving is eating your CPU and RAM: fixing 'Allowed memory size exhausted' and scaling on-premise
> core:archive dies on month and year with a DataTable.php fatal, MySQL CPU pins for no reason, and raising memory_limit never helps. It's almost always one thing: archiving, not tracking, is what makes Matomo heavy. Here's why more RAM doesn't fix it and what to tune instead.
Published: 2026-07-15
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-archiving-memory-cpu-scaling
---
If your `core:archive` run finishes day and week fine, then dies on month or year with `PHP Fatal error: Allowed memory size of 2147483648 bytes exhausted in .../DataTable.php`, you've already noticed the part that makes no sense: you raised `memory_limit` from 512M to 2G to 8G, and each time the same error came back at the new ceiling. Maybe alongside it, MySQL CPU and PHP-FPM memory jumped one morning and stayed pinned even though traffic never changed, with `climulti:request` processes piling up and never finishing.
These look like several different problems. They're mostly one. Archiving, not tracking, is what makes Matomo heavy, and on a busy or large install it has to be configured on purpose rather than left on defaults. Once you stop treating tracking as the suspect and start treating archiving as the load source, the fixes line up in a sensible order.
Archiving, not tracking, is what makes Matomo heavy, and the memory a run needs scales with cardinality (distinct URLs, page titles, search terms), not with how much RAM you give PHP. That's why raising `memory_limit` never stops the `DataTable.php` fatal; it just moves the crash to a higher ceiling. The fix that holds is to cap the archiving row limits like `datatable_archiving_maximum_rows_actions` and exclude noisy query parameters, after moving archiving to a CLI cron job with browser triggering off. If MySQL CPU stays pinned while traffic is flat, that's a different problem: an invalidation backlog looping, which you clear with `core:invalidate-report-data` rather than more hardware.
## Why "add more memory" keeps failing
The archiver aggregates raw visits into report tables, and it does that aggregation in memory. How much memory it needs scales with cardinality: the count of distinct URLs, page titles, entry and exit pages, search terms, and segments. It does not scale with how much RAM you hand PHP.
That's the whole trap. A site that imported a file directory as tens of thousands of unique URLs, or that tracks query strings full of session IDs, builds an enormous in-memory DataTable for the Pages/Actions report. Give that process 8 GB and it will happily try to use 8 GB. The ceiling was never the constraint. The table was. So the durable fix is to bound the table and cut the cardinality, not to keep lifting the limit and hoping the next run squeaks under it.
If your install grew this way through log imports, the [log-import path](/blog/matomo-import-logs-not-working) is usually where the unique-URL explosion got into the database in the first place. Worth tracing before you tune anything.
## The fixes, in order
### 1. Archive from cron, and turn off browser archiving
If the UI is still generating reports on demand, you have two uncoordinated systems competing for the same CPU. Move all archiving to a scheduled CLI run as your web-server user:
```bash
# Run hourly from cron, as the same user that owns your Matomo files
./console core:archive --url=https://your-matomo.example
```
`--url` takes the full address Matomo runs at, sub-path and all, so it works on every install. Newer builds also accept `--matomo-domain` as a shorthand when Matomo sits on a bare domain with no path. If your install lives under something like `/matomo`, stay with `--url`, and run `./console help core:archive` before you touch an existing cron entry.
Then stop the browser from triggering its own jobs. In `config/config.ini.php`, under `[General]`:
```ini
[General]
enable_browser_archiving_triggering = 0
browser_archiving_disabled_enforce = 1
archiving_range_force_on_browser_request = 0
```
The default for `enable_browser_archiving_triggering` is `1`, which is why a fresh install feels fine until traffic grows and report requests start kicking off live archiving. If users or API clients request segmented reports, `browser_archiving_disabled_enforce = 1` stops those segment requests from bypassing the browser-archiving disablement. Custom date ranges are different: `core:archive` does not pre-archive arbitrary ranges, so use `archiving_range_force_on_browser_request = 0` only if you deliberately accept that range reports may not be freshly generated on browser request. The same main toggle is exposed in Administration → System → General Settings if you'd rather not edit the file. If you haven't set the cron side up yet, we've written a [full walkthrough of Matomo cron archiving](/blog/set-up-matomo-cron-archiving).
### 2. Give the CLI enough memory, once
Set a real `memory_limit` in your CLI `php.ini`. That's the CLI SAPI, a different file from the FPM pool that serves the web UI, and editing the FPM one does nothing for archiving. People lose afternoons to this. While a run is in progress, Matomo also enforces a floor:
```ini
[General]
; Matomo refuses to archive below this many MB; default is 768
minimum_memory_limit_when_archiving = 768
```
Run `php --ini` and `php -i | grep memory_limit` as the same user and in the same container or host context that cron uses. If those commands show a different file than PHP-FPM, change the CLI file.
Raising the CLI limit is the right move when a run is genuinely close to the edge and finishing. But if every bump just postpones the same `DataTable.php` fatal to a higher number, stop. That's the symptom from the top of this post, and the next step is the real fix.
### 3. Cap the archiving table sizes
This is the lever that actually stops the memory exhaustion. First check whether these limits were raised above Matomo's defaults. If they were, put them back. If they are already at the defaults and month or year archives still die, temporarily lower the action-heavy limits enough to get the failing archives through.
| Setting | Matomo 5 default | Pathological-site trial | Report tradeoff |
| --- | ---: | ---: | --- |
| `datatable_archiving_maximum_rows_actions` | `500` | `250` or `100` | More long-tail pages land under "Others". |
| `datatable_archiving_maximum_rows_subtable_actions` | `100` | `50` | More child rows under each page land under "Others". |
| `datatable_archiving_maximum_rows_events` | `500` | `250` or `100` | Event categories/actions/names with a long tail are compressed. |
| `datatable_archiving_maximum_rows_site_search` | `500` | `250` or `100` | Long-tail internal search terms are compressed. |
```ini
[General]
datatable_archiving_maximum_rows_actions = 250
datatable_archiving_maximum_rows_subtable_actions = 50
datatable_archiving_maximum_rows_events = 250
datatable_archiving_maximum_rows_site_search = 250
```
Everything past the cap is rolled into the "Others" row, which is almost always what you want for a directory of one-hit URLs nobody analyses individually. The events and site-search caps matter only when those reports also have high cardinality. These limits affect newly processed archives; if you expect historical report shape to change, invalidate and reprocess the affected site/date range deliberately rather than the whole install.
### 4. Cut URL and action cardinality at the source
Capping the table keeps memory bounded. Cutting cardinality makes every run faster and the archives smaller. The biggest single win is excluding noisy query parameters. As a Super User, go to Administration → Websites → Manage and set per-site "Excluded Parameters", or set the "Global list of Query URL parameters to exclude" in the global website settings. Strip session IDs, click IDs, and anything that makes otherwise-identical pages look unique:
```text
sessionid
PHPSESSID
fbclid
gclid
utm_term
```
That turns a stream like `/download?id=1`, `/download?id=2`, and `/download?id=10000` back into one report row when `id` is not analytically useful. If the parameter changes the content, do not exclude it; normalize only the noise that creates fake uniqueness.
Note that exclusions only affect data going forward, so they shrink future archives, not the rows already stored. For log imports specifically, collapse directory-style and one-hit URLs before importing rather than letting thousands of unique paths land in the database.
### 5. Keep segments under control
Matomo archives by site, period, and segment. Each pre-processed segment adds another set of segment archives for the sites and periods it applies to. A handful of segments you genuinely watch is fine. Ten or forty stale segments that someone created during an experiment two years ago can turn one archive queue into many queues. Audit them and delete the ones nobody reads.
### 6. Clear stuck archivers and the invalidation backlog
This is the fix for the "CPU spiked for no reason" morning. A sustained jump in MySQL CPU and PHP-FPM memory with flat traffic almost always means archiving is looping, not tracking misbehaving. The usual cause is a small set of invalidations (frequently year-period reports, or one plugin's reports) being reprocessed on every run, so each cron cycle stacks more `climulti:request` processes that never finish.
```bash
# See exactly which idSite / period / segment is slow
./console core:archive --url=https://your-matomo.example -vvv
# Re-process a scoped set instead of invalidating everything at once
./console core:invalidate-report-data --help
```
Inspect `archive_invalidations`, not necessarily a literal table named `archive_invalidations`. The prefix is in the `[database]` section of `config/config.ini.php`; many installs use `matomo_`.
```sql
SELECT COUNT(*) AS pending_invalidations
FROM archive_invalidations;
SELECT *
FROM archive_invalidations
WHERE ts_started IS NOT NULL
ORDER BY ts_started ASC
LIMIT 50;
```
Rows with `ts_started IS NOT NULL` are not automatically bad. Compare the timestamp with live `core:archive` / `climulti:request` PIDs, Matomo logs, and whether the verbose archive output is still progressing before you kill anything.
Make sure two cron runs can't overlap by using a lock file or one scheduler entry. Only kill a process after you have confirmed it is stale: old PID age, matching command, no fresh log output, and no movement in the verbose archive. Then let one clean run catch up. Use `core:invalidate-report-data` to reprocess a specific site and date range rather than invalidating the whole install and setting off a stampede. Run it with `--help` first, since the exact flags move between point releases.
### 7. Scale the database and parallelism for many sites
At large site counts, the database often becomes the limiting factor. Check DB CPU, I/O wait, slow queries, and the invalidation backlog before increasing archiver concurrency. Add concurrency only when the database has headroom; reduce it or tune the database first when MySQL/MariaDB is already saturated.
| Increase concurrency when... | Tune or reduce concurrency when... |
| --- | --- |
| PHP archivers are idle or waiting and DB CPU/I/O still has room. | DB CPU is pinned, I/O wait is high, or slow queries pile up. |
| The invalidation backlog is large but each archive finishes cleanly. | The same site/period/segment repeats on every run. |
| You have enough workers and memory for parallel CLI processes. | Parallel runs push PHP into swap or make reports slower. |
```bash
# Run several archivers in parallel, with bounded
# concurrency per website. concurrent-requests-per-website defaults to 3;
# concurrent-archivers is not 3 by default, so set it deliberately.
./console core:archive --url=https://your-matomo.example \
--concurrent-archivers=3 --concurrent-requests-per-website=3
```
```ini
# In your MySQL/MariaDB config, on a dedicated DB host:
# size the buffer pool to roughly 60-80% of that server's RAM
innodb_buffer_pool_size = 24G
```
Put MySQL/MariaDB on its own server for larger installs, size `innodb_buffer_pool_size` to roughly 60–80% of RAM on a dedicated DB host, and parallelise archiving with `--concurrent-archivers` only after you have measured DB headroom. Keeping the invalidation backlog small (step 6) makes scaling much less fragile. If you're getting raw DB errors rather than just slowness, our notes on [fixing Matomo database errors](/blog/fix-matomo-database-errors) cover the table-level problems that show up under this kind of load, and the guide on [running multiple sites and measurables](/blog/matomo-multiple-sites-domains-measurables) covers the multi-site setup itself.
## Confirm it worked
- Run `./console core:archive --url=… -vvv` and watch the log name the exact `idSite` / period / segment that's slow.
- Confirm month and year periods now complete without a `DataTable.php` fatal.
- After disabling browser archiving, check the UI still shows fresh data. It now reads pre-archived reports, and `time_before_today_archive_considered_outdated` (default `900` seconds) controls how stale "today" is allowed to be.
- Watch MySQL CPU and PHP memory drop back to baseline between cron runs instead of staying pinned.
## Prevent regressions
Run a modern PHP 8.x line. Matomo 5 works well with PHP 8, and the latest 8.x release is the most memory-efficient and fastest, which directly helps archiving headroom. Enable OPcache. Stay on the latest Matomo 5.x, since several archiving and invalidation-query performance fixes have landed across point releases. And periodically re-audit your segment and custom-report counts, because both creep upward and both tax every run.
## What we'd actually do
Move archiving to cron and turn off browser triggering first. That one change removes most of the surprise load and makes everything else measurable. Then, the moment you see a `DataTable.php` fatal, skip straight to capping the row limits and excluding query parameters. Don't spend a week walking `memory_limit` up by gigabytes; the limit was never the thing. For a large multi-site install, give the database its own tuned server, run archivers in parallel, and keep the invalidation backlog short so it stays that way.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-archiving-memory-cpu-scaling) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-archiving-memory-cpu-scaling) if that's relevant to you.
More memory was never the fix. Smaller tables were.
---
# Why your Matomo Campaigns report is empty (and why the URL says gad_source=1)
> Two campaign-tracking symptoms come up constantly in Matomo 5.x: the Campaigns report stays empty even though tagged links get visits, and the visit log shows ?gad_source=1 instead of your UTM parameters. Neither is usually a bug. Here's the mechanism behind each and exactly how we fix them.
Published: 2026-07-13
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-campaign-tracking-utm-gad-source
---
You tagged your links, the visits are clearly landing, and **Acquisition → Campaigns** is still empty. Or you open a visit in the log and the landing page reads `/pricing?gad_source=1`, with none of the `utm_source` and `utm_campaign` values you appended. Just that one stray parameter clinging to the URL. Both look like Matomo quietly threw your campaign data away.
It usually didn't. The empty report is almost always a tracking-side config problem you can find in about thirty seconds, and the `gad_source=1` URL is two normal behaviours happening at once. Different problems, different fixes, so we'll take them one at a time.
If your Campaigns report is empty, open the `matomo.php` request in DevTools and read its `url=` value. If your `utm_*` params aren't in there, the problem is on the page or in the tracker, not in Matomo's reports: usually `disableCampaignParameters` in the snippet, `setCustomUrl` sending a bare path, or a `utm_*` param on an exclusion list. If they are in there, it's a Matomo-side setting. The `gad_source=1` is a separate, harmless thing: a Google Ads parameter Matomo doesn't recognise, so it lingers in your stored URLs until you add it to the excluded parameters.
## Symptom 1: the Campaigns report is empty
When tagged links get traffic but the Campaigns report stays blank, the campaign parameters aren't reaching Matomo's tracker. Before you touch any Matomo setting, confirm that in the browser. It tells you which side of the wire the problem is on.
Open your landing page with a real campaign URL, open DevTools (F12) → **Network**, and filter for `matomo.php` (or whatever your tracker endpoint is). Click the tracking request and look at the `url=` parameter it sent:
- If `url=` is **missing** your `utm_*` / `mtm_*` params, the tracker never saw them. The problem is on the page, in the tracking code, or in something cleaning the URL before Matomo reads it.
- If `url=` **contains** them but the report is still empty, the tracker is fine and the issue is on the Matomo side: a reporting or config setting.
That one check splits the whole problem in half. Here's what to look at on each side.
### When the params are missing from `url=`
**`disableCampaignParameters` is in your tracking code.** Matomo 5.1 added `disableCampaignParameters` as a privacy switch. When it's present, the JavaScript tracker deliberately strips campaign parameters out of the request before sending it, so campaigns are never recorded:
```js
// If this line is in your snippet, campaign params never reach Matomo.
_paq.push(['disableCampaignParameters']);
```
This is the single easiest cause to miss, because everything else in the snippet looks correct. Grep your tracking template and your tag manager for `disableCampaignParameters` and pull it out unless a privacy requirement genuinely needs it. There's no recovering campaign data for visits tracked while it was active, because the parameters never left the browser. Fix the code first, then move on.
**`setCustomUrl` is dropping the query string.** On SPA and React/Next/Vue sites, it's common to push the path by hand. If you send only the pathname, you throw the campaign params away before `trackPageView` fires:
```js
// Wrong: discards ?utm_source=... entirely
_paq.push(['setCustomUrl', window.location.pathname]);
// Right: keeps the full URL, query string included
_paq.push(['setCustomUrl', window.location.href]);
```
**Your campaign params are on the excluded list.** This is Matomo's own first answer in the [official "missing campaign links" FAQ](https://matomo.org/faq/troubleshooting/missing-campaign-links-in-campaign-reports/), and it bites people who got aggressive with URL cleanup. Check two places: **Administration → Websites → Manage → Edit → Excluded Parameters** for the site, and the Matomo Tag Manager configuration variable's advanced **"Set query params to exclude from URL"** field if you track through MTM. If a `utm_*` param is sitting in either of those, Matomo strips it from the URL before it can be read as a campaign.
**On self-hosted installs, check `exclude_requests` too, but know it's a different thing.** The `[Tracker] exclude_requests` setting in `config/config.ini.php` doesn't clean up query parameters; it discards whole tracking requests that match an expression (for example `url=@utm_`). It's rarely the cause, but if someone wrote a rule that happens to match your campaign landing pages, those hits never get recorded at all, which looks identical to an empty Campaigns report.
**Something upstream is cleaning the URL.** A Cloudflare "Cache Everything" rule, a reverse-proxy rewrite that returns 200 while dropping the query string, or a consent-management script that "tidies" the URL before the tracker runs will all remove the params without leaving a trace in your code. If you self-host behind a proxy and see other oddities too, our note on [reverse-proxy 502s and header handling](/blog/self-host-matomo-reverse-proxy-502) covers the same class of "the proxy ate part of the request" problem.
### When the params are present but the report is still empty
If `url=` already carries your `utm_*` values, the tracker is doing its job and the problem is on the Matomo side. Work through these roughly in order:
- **Right site and date range.** The dull one that catches everybody at least once: the report is empty because the wrong site is selected, or the date range predates the campaign.
- **Archiving.** Reports populate from archived data, so a recent campaign may not appear until an archive run completes. Trigger one before you conclude the data isn't there. If your archiving isn't running on a schedule at all, our [cron archiving setup](/blog/set-up-matomo-cron-archiving) covers that.
- **Custom campaign parameter overrides.** If someone changed which parameters Matomo treats as the campaign, the defaults quietly stop working. That can live in `[Tracker]` or `[MarketingCampaignsReporting]` config, or in the JavaScript via `setCampaignNameKey` / `setCampaignKeywordKey`. Make sure your links and Matomo agree on which parameter carries the campaign name and keyword.
- **Privacy or compliance mode.** A CNIL-style compliance configuration can discard campaign values as they arrive. If you turned one on for legal reasons, an empty Campaigns report may be working as designed.
- **The Marketing Campaigns Reporting plugin**, but only if you're missing the *richer* reports, not all campaign data. Matomo core already recognises `utm_campaign`, `utm_source`, and `utm_medium` for the campaign name and `utm_term` for the keyword. The free [Marketing Campaigns Reporting](https://plugins.matomo.org/MarketingCampaignsReporting) plugin adds separate reports for campaign name, source, medium, keyword, content, and campaign ID, including `utm_content` and `utm_id`. It's a Marketplace install on self-hosted Matomo, not bundled with core, so confirm it's installed and active if the basic name and keyword data shows up but the channel breakdown doesn't.
This is the same "tracking fires but the report stays empty" situation we wrote up for [ecommerce orders that don't show up](/blog/matomo-ecommerce-not-tracking). The request reaching the tracker and the report populating are two separate gates, and it helps to know which one you're stuck at before you start changing things.
## Symptom 2: the URL shows gad_source=1 instead of your UTM params
This one isn't broken at all. It's two normal behaviours happening together.
**Matomo strips recognised campaign params out of the stored page URL by design.** Once `utm_source`, `utm_campaign`, and friends are digested into the Campaign dimension, Matomo drops them from the page URL it stores. That's intentional. It stops one page from fragmenting into dozens of variants (`/pricing?utm_campaign=a`, `/pricing?utm_campaign=b`, and so on) in your Pages report. Your attribution is fully intact at the visit level. The campaign is recorded; it's just no longer duplicated in the raw URL. This is expected behaviour, not data loss.
**`gad_source=1` comes from Google, and Matomo doesn't recognise it.** Google Ads and Google's tags add their own parameters to ad-click landing URLs. `gad_source` is one of them; auto-tagging also adds the classic `gclid` click identifier, and you'll see `gbraid` or `wbraid` in specific measurement contexts. The detail that matters here is the same for all of them: none are Matomo campaign parameters, so Matomo never digests or strips them. That's why `gad_source=1` is the one query param still hanging off the URL in your visit log. Nothing is removing your UTMs *instead of* showing `gad_source`. The UTMs were absorbed into the Campaign dimension, and `gad_source` just survived because nothing was looking for it.
So your campaign attribution still works. Check the Campaigns report and the visit-level referrer if you want to see it. The `gad_source=1` is just unstripped Google noise sitting in your Pages report.
To get it out of Matomo's stored Page URLs and the Pages report, add it to your excluded parameters. This only changes what Matomo stores from now on. It doesn't rewrite the visitor's browser URL, touch your Google tags, or clean up URLs already sitting in your database:
```text
gad_source
gclid
gbraid
wbraid
```
Per site, that's **Administration → Websites → Manage → Edit → Excluded Parameters**. To cover every site at once, use **Administration → Websites → Settings → "Global list of Query URL parameters to exclude"** and put one parameter per line. Either way, the rule only touches new traffic, so your historical Pages report won't tidy itself up.
If you run several properties, this is exactly the kind of setting that drifts out of sync between them. Our walkthrough of [managing multiple sites, domains, and measurables](/blog/matomo-multiple-sites-domains-measurables) goes into keeping per-site config aligned.
## If you actually need per-campaign page detail
Some teams want to compare the *same* page across different campaigns, and the URL stripping seems to take that away. Don't fight it by un-stripping the params and re-polluting your Pages report. Use **Segments** instead, one per campaign name, plus the segment **comparison** toolbar in the report view. You get the per-campaign breakdown without splitting every page into a dozen URL variants. It's the clean version of what people are usually reaching for when they ask to keep the params on the URL.
## What we'd actually do
Run the thirty-second DevTools check first, every time. Whether `url=` carries your campaign params decides everything downstream, and it stops you from changing Matomo settings to fix a problem that lives in the page. If the params are gone there, hunt the tracking side: `disableCampaignParameters` in the snippet, `setCustomUrl` sending a bare pathname, or a `utm_*` param sitting on an exclusion list.
For `gad_source=1`, add the Google parameters (`gad_source`, `gclid`, and friends) to your excluded list and leave the by-design UTM stripping alone. It's keeping your Pages report sane, not eating your data. And standardise on one prefix across all your links, either Matomo-native `mtm_*` or Google-style `utm_*`, so you're never debugging mixed attribution on top of everything else.
If JavaScript tracking keeps mangling your campaign URLs no matter what you do, whether that's a heavy CMP, a stubborn CDN, or an SPA you can't fully control, moving campaign attribution to the [server-side HTTP Tracking API](/blog/matomo-http-tracking-api-server-side) takes the browser out of the equation. It's more work, but the params arrive exactly as you send them.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-campaign-tracking-utm-gad-source) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-campaign-tracking-utm-gad-source) if that's relevant.
Your UTMs are only really missing if they're missing from the `url=` value sent to Matomo. If they're there, Matomo already pulled them into your campaign data, and `gad_source` is just an unrelated Google parameter waiting for a cleanup rule.
---
# Fix Matomo database errors: access denied, connection refused, and missing privileges
> Matomo's database errors look cryptic, but the SQLSTATE code in the message tells you exactly which half of the problem you're in. Here's how to read [1045], [2002], and the missing-privilege warnings, and the specific fix for each in Matomo 5.x.
Published: 2026-07-08
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/fix-matomo-database-errors
---
If Matomo just stopped talking to its database, you're looking at one of a small set of `SQLSTATE` errors. They show up on the installer's database step, on a normal page load, or in the system check on a site that was fine yesterday. They read like noise. They aren't.
```text
SQLSTATE[28000] [1045] Access denied for user 'matomo'@'localhost' (using password: YES)
SQLSTATE[HY000] [2002] No connection could be made / Connection refused
System Check: CREATE TEMPORARY TABLES / LOAD DATA INFILE not available
```
Before you touch anything, read the bracketed number. `[1045]` means the database server answered and rejected you: an authorization problem. `[2002]` means the server never answered at all: a connectivity problem. The privilege warnings split in two: `CREATE TEMPORARY TABLES` is a required grant, while `LOAD DATA INFILE` is optional performance plumbing. Get the error into the right bucket and you've done most of the work.
| Error | Meaning | First check | Likely fix |
| --- | --- | --- | --- |
| `[1045]` | MySQL rejected the user, host, password, or grant | `mysql -h ... -u ... -p dbname` from the Matomo runtime | Fix password, user host, or grants |
| `[2002]` | No MySQL connection or handshake happened | `mysqladmin status`, then host, port, socket, firewall, disk | Start MySQL or fix reachability |
| `CREATE TEMPORARY TABLES` | Required database-level grant is missing | `SHOW GRANTS` | Add the required grant on the Matomo database |
| `LOAD DATA INFILE` | Optional archiving performance check failed | System Check details | Enable it safely or disable the feature |
## `[1045]` Access denied: the server answered and said no
This error is MySQL's, not Matomo's. The connection succeeded, MySQL looked up the user, host, and password you sent, and turned them down. So the fix lives on the database side every time. Here's the order I'd work through it, roughly sorted by how often each one turns out to be the culprit.
Start with stray whitespace. A trailing space in the username or password, usually pasted in by browser autofill on the installer, is the single most common cause. Re-type the credentials by hand, and check that nothing is hiding inside the quotes in `config/config.ini.php`:
```ini
[database]
host = "127.0.0.1"
username = "matomo"
password = "secret"
dbname = "matomo_db"
```
That's `"secret"`, not `" secret"`. MySQL treats those as two different passwords.
Next, confirm the login on its own, with Matomo mostly out of the picture. Run the exact credentials from the same runtime that executes Matomo's PHP, not from your laptop and not necessarily from the database host. If this fails there, Matomo was never going to succeed:
```bash
mysql -h 127.0.0.1 -u matomo -p matomo_db
```
On a VM, SSH into the app server. In Docker Compose, use `docker compose exec matomo sh` or a temporary MySQL client on the same Compose network. In Kubernetes, `kubectl exec` into the Matomo pod or run a temporary client pod in the same namespace. The point is to test the path MySQL sees from Matomo.
Once you are connected, ask MySQL which account it actually matched:
```sql
SELECT CURRENT_USER(), USER();
SHOW GRANTS;
```
Then check the user's host, which is the part that catches containerised and cloud setups. In MySQL, `'matomo'@'localhost'`, `'matomo'@'10.0.2.%'`, and `'matomo'@'%'` are separate accounts with their own passwords and grants. On a single host the first one is fine. Split Matomo and the database across two servers, Docker containers, Kubernetes pods, or an RDS instance, and MySQL sees the connection coming from the app server's address rather than `localhost`, then matches it against an account that might not exist.
Prefer a specific host, a private subnet such as `'matomo'@'10.0.2.%'`, or the managed database's recommended private-network rule. Use `'matomo'@'%'` only when firewall rules, security groups, or network policy already restrict who can reach MySQL.
Those grants are the next thing to get right. This is the set Matomo's install docs ask for:
```sql
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, DROP, ALTER,
CREATE TEMPORARY TABLES, LOCK TABLES
ON matomo_db.* TO 'matomo'@'localhost';
FLUSH PRIVILEGES;
```
Finally, if a working install throws `[1045]` out of nowhere, Matomo didn't do it. It never edits the database password itself. Something moved on the database side: a password reset, a user or host change, a `FLUSH PRIVILEGES` that picked up a stricter grant table, a server upgrade. Diff `config/config.ini.php` against the live credentials and they'll disagree somewhere.
## `[2002]` Connection refused: the server never answered
`[2002]` is a different animal. Matomo's MySQL client opened a connection and got nothing back: no rejection, no handshake. Credentials don't matter here, because the conversation never started. You're debugging whether the server is reachable at all.
Start with the obvious one, since it's free to check and it's often the answer: is the database even running?
If `mysqladmin` itself can't reach the server, check the service directly:
```bash
systemctl status mariadb # or: systemctl status mysql
```
If it's down, start it. If it won't start, its own error log is your next stop, not Matomo's.
The next trick fixes `[2002]` more often than it has any right to: swap `localhost` for `127.0.0.1`. On a lot of Linux systems the literal string `localhost` tells the MySQL client to connect over a Unix socket, while `127.0.0.1` forces a TCP connection to port 3306. If the socket path is wrong or the server isn't listening where you think, `localhost` fails and `127.0.0.1` quietly works. Change `host` in `config/config.ini.php` and try again.
If that doesn't do it, work outward: confirm the port (3306 by default) and check that nothing between Matomo and the server is dropping packets. For a remote or containerised database that's usually a host firewall or a security group. One cause people forget, and one Matomo's own FAQ calls out: a database server that has run out of disk space starts refusing connections. Run `df -h` on the database host.
If you actually want a socket connection, separate the installer from an already-installed Matomo. During installation, Matomo still accepts a socket path in the database host field, such as `/var/run/mysqld/mysqld.sock`. In `config/config.ini.php` after install, prefer the dedicated key; when it's set, it overrides whatever host and port you've configured:
```ini
[database]
unix_socket = "/var/run/mysqld/mysqld.sock"
```
| Config value | What it usually does | Use it when |
| --- | --- | --- |
| `host = "localhost"` | May use the default MySQL socket on Unix-like systems | Matomo and MySQL are on the same host and the socket path is correct |
| `host = "127.0.0.1"` | Forces TCP to the local MySQL listener | `localhost` is trying the wrong socket |
| `unix_socket = "/path/to/sock"` | Uses the explicit socket and overrides host/port | You know the socket path after installation |
This same connectivity layer is what trips up scheduled archiving. If your cron job can reach the database from the shell but archiving still fails, the problem is usually downstream of the connection itself, which we pulled apart in [why Matomo's cron archiving returns an invalid response](/blog/matomo-cron-archiving-invalid-response).
## Treat missing grants and load-data checks separately
If the system check is waving red flags about `CREATE TEMPORARY TABLES` or `LOAD DATA INFILE`, don't treat them as the same class of problem. `CREATE TEMPORARY TABLES` belongs in Matomo's required database grants. `LOAD DATA INFILE` is a performance feature; enabling it can speed archiving, but it comes with security and hosting constraints.
`CREATE TEMPORARY TABLES` is already in the grant statement above, and in any `GRANT ALL ... ON matomo_db.*`. If you ran that, you have it.
| Check | Required for Matomo? | Scope | Fix | Security note |
| --- | --- | --- | --- | --- |
| `CREATE TEMPORARY TABLES` | Yes | Matomo database | Include it in the database-level grant statement | Do not ignore this one |
| `LOCK TABLES` | Yes | Matomo database | Include it in the same grant statement | Usually safe when scoped to the Matomo database |
| `LOAD DATA INFILE` | No, but useful for performance | Server-side MySQL file import | Make MySQL able to read Matomo's `tmp/assets` files and grant `FILE` if needed | `FILE` is global and should be weighed carefully |
| `LOAD DATA LOCAL INFILE` | No, but useful for performance | Client-side upload from the Matomo/PHP side | Enable MySQL client/server `local-infile` support and `mysqli.allow_local_infile = On` when using mysqli | Often the better route when MySQL is remote |
For `CREATE TEMPORARY TABLES`, no: fix the grant. For `LOAD DATA INFILE`, maybe: Matomo can keep working without it, but archiving can be slower on medium or high traffic installs. If your host forbids it, disable the feature explicitly and monitor archive time.
`LOAD DATA INFILE` is the fussy one. Matomo checks both `LOAD DATA INFILE` and `LOAD DATA LOCAL INFILE`, and either one working is enough. The server-side form may need the global `FILE` privilege, and that one, unlike the Matomo database grants, can't be granted at the database level:
```sql
GRANT FILE ON *.* TO 'matomo'@'localhost';
```
Matomo's own docs note that handing out `FILE` is generally not recommended for security reasons, so weigh it before you do. Server-side loading also means the database server process has to read the files Matomo creates under `tmp/assets`, which can collide with AppArmor, `secure_file_priv`, or a separate database host that cannot see the Matomo filesystem.
The local form is different. It can work when the database lives on another server, but it needs `local-infile` support in the MySQL configuration and, for the mysqli driver, `mysqli.allow_local_infile = On` in `php.ini`.
Two more things worth knowing. There's a known false positive where some versions report a `LOAD DATA INFILE` warning even when the privilege is correctly in place (matomo-org/matomo issue #19267). And if you accept the performance tradeoff and want Matomo to stop trying this feature, turn it off:
```ini
[General]
enable_load_data_infile = 0
```
Matomo keeps working. Archiving is a little slower on large data sets, and that's the whole cost.
## Confirm the fix
Don't trust the absence of an error. Re-run the check. In the UI that's Administration → Diagnostics → System Check; from the CLI:
```bash
./console diagnostics:run
```
And to see what the database actually thinks your user can do:
```sql
SELECT CURRENT_USER(), USER();
SHOW GRANTS;
-- From an admin account, replace this with the account CURRENT_USER() showed:
SHOW GRANTS FOR 'matomo'@'localhost';
```
A clean diagnostics page and a dashboard that loads means you're done.
If your error turned up right after a Matomo upgrade rather than at install time, rule out the other classic upgrade-time database failure too: a half-applied migration that leaves a table behind, which we covered in [Matomo's "table doesn't exist" error after an upgrade](/blog/matomo-bottracking-table-doesnt-exist).
## What we'd actually do
Read the code, then fix the matching half. A `[1045]` is never a Matomo bug. It's credentials, host, or grants, and you fix it on the database side. A `[2002]` is reachability: service down, wrong host string, firewall, disk. Credentials are a red herring until the connection opens. For privilege rows, add required database grants first; decide separately whether `LOAD DATA INFILE` is worth enabling or whether `enable_load_data_infile = 0` is the right tradeoff for your install.
To keep all three from coming back, create a dedicated database user scoped to the Matomo database with exactly the grant statement above, and run a current stack instead of the stale floor. Matomo's current docs recommend the latest PHP 8.x release and MySQL 8+ or MariaDB for new or maintained installs. Persist `config/config.ini.php` too, so a container restart doesn't drop you back into the installer pointing at the wrong host.
The error already told you which half you're in. For a simple single-server install, this is often a five-minute fix. For Docker, managed databases, or Kubernetes, the same buckets apply, but the important part is testing from the Matomo runtime and granting the narrowest host that actually connects.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=fix-matomo-database-errors) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=fix-matomo-database-errors) if that's relevant.
---
# How Matomo counts visitors: unique visitors, visits, and unique pageviews explained
> Matomo's visitor numbers stop being mysterious once you know how it decides who is who and when it starts counting again. Here's the recognition model underneath unique visitors, visits, and unique pageviews in Matomo 5, plus why daily uniques don't sum and how server-side tracking splits one person into many.
Published: 2026-07-06
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-unique-visitors-vs-visits
---
Three questions come up again and again from people staring at a Matomo dashboard. *"If the same person comes back in a year, are they still one unique visitor, or does the clock reset?"* *"What's the actual difference between unique pageviews and visits — sometimes they look like they should be identical?"* And *"I'm getting duplicate entries for the same person, how do I make Matomo merge them?"*
They look like three separate problems. They're the same one: how does Matomo decide who's who, and when does it start counting again? Every visitor number you see (unique visitors, returning visitors, unique pageviews) comes out of that one decision. Get the recognition model and the rest stops being mysterious. Here's how it works in Matomo 5.
Matomo recognizes a visitor by User ID first, then the `_pk_id` cookie, then a short-lived `config_id` hash that resets every day. Unique Visitors is counted per period you pick, so daily counts never add up to a weekly or monthly total; read the longer period directly instead of summing them. Server-side tracking is the other trap: it can't see the cookie, falls back to `config_id`, and splits one person into a dozen visitors. Send a stable 16-character `_id` yourself and that goes away.
## How Matomo recognises a visitor
When a tracking request arrives, Matomo works out which existing visitor it belongs to. The real matcher has special cases for cookies, a manually set `cid`, the User ID, and the `trust_visitors_cookies` setting, so it isn't a single rigid ranking. But for reading your dashboard, the practical model is a fallback chain:
1. User ID: if you've set a [User ID](https://matomo.org/docs/user-id/) (a logged-in account ID, a CRM ID, anything stable you own), Matomo treats it as the same person across devices and browsers, because it's a value you assign rather than something it has to infer. It's the strongest signal you can hand it.
2. Visitor ID (the cookie): the JavaScript tracker stores a 16-character visitor ID in the first-party `_pk_id` cookie. As long as that cookie survives, the same browser is recognised as the same visitor on every return trip.
3. config_id hash: with no User ID and no cookie, Matomo falls back to an anonymised `config_id` built from operating system, browser, plugins, IP, and language. Matomo deliberately keeps this short-lived rather than running it as a persistent fingerprint.
That short life is the whole point, and it explains a lot of "why are my returning visitors so low?" confusion. The random seed behind `config_id` gets thrown away and regenerated every 24 hours, so the same device produces a different hash each day. And when Matomo matches on it, it only looks back `window_look_back_for_visitor` seconds, 30 minutes by default. So without a cookie or a User ID, a returning visitor is basically invisible across days. Recognising someone from one day to the next is a cookie job, not a `config_id` job, and that's by design. It's part of what makes Matomo's [cookieless mode](/blog/matomo-consent-cookieless-tracking) workable for consent-exempt analytics in some jurisdictions, when it's configured for that — check your local law before relying on it.
One more boundary: recognition is scoped per site. Visitor IDs and `config_id` hashes don't carry across [separate sites or measurables](/blog/matomo-multiple-sites-domains-measurables), so the same person on two of your properties counts as two visitors. If you want several domains to count as one journey, configure them as alias URLs under a single Website with cross-domain linking; don't assume a shared User ID merges the aggregate counts across separate site IDs without testing it.
## How long someone stays the "same" visitor
This is the "unique visitor length" question, and the answer is the cookie lifetime. The JavaScript `_pk_id` visitor cookie lasts 13 months by default, and you set it with `setVisitorCookieTimeout()`:
```js
// Default is 13 months (33696000 seconds). Shorten or extend as needed.
_paq.push(['setVisitorCookieTimeout', 33696000]);
```
So if the same browser returns within 13 months and the cookie hasn't been cleared, Matomo matches it to the same visitor ID and counts it under "Returning visits", "Days since last visit", and so on. (The companion `_pk_ses` session cookie is separate and expires after 30 minutes, which is the visit timer, not the identity timer.)
Worth clearing up one thing: a 13-month cookie doesn't mean everyone collapses into one giant lifetime "unique visitor" count. The cookie is about recognition. The metric is scoped separately, which is the next piece.
## Unique Visitors is always per period
The Unique Visitors metric is always scoped to the period you're looking at: unique per day, unique per week, unique per month. Someone who visits Monday, Wednesday, and Friday is three daily uniques but one weekly unique, as long as the cookie or User ID survives so Matomo recognises them across those days. That one fact explains the most common visitor-counting mistake.
You can't add up daily unique visitors to get a weekly or monthly total. Summing them double-counts everyone who came back more than once. Read the week or month period directly instead.
There's a performance wrinkle too. Self-hosted Matomo processes Unique Visitors for day, week, and month by default. Matomo Cloud defaults to day and week; monthly can need a support request depending on your plan, and year or custom-range uniques aren't a self-service flag there. On self-hosted, yearly reports and custom date ranges are off by default, because computing uniques across a long span is expensive. Turn them on in your config file:
```ini
[General]
enable_processing_unique_visitors_year = 1
enable_processing_unique_visitors_range = 1
```
After you change these, you have to re-process the relevant historical archives, and on a busy site it costs real CPU. Turn on only the periods you actually report on.
## What counts as a "visit"
A visit (a session) starts on the first action and stays open until 30 minutes of inactivity (`visit_standard_length = 1800` seconds). An action after that gap opens a new visit: same person, second visit. A visit also splits at midnight in the site's timezone. A campaign change starts a new visit by default (you can switch that off with `create_new_visit_when_campaign_changes`), while a website-referrer change does not, unless you turn it on. So "visits" counts sessions, and one person can rack up plenty of them.
## Unique pageviews vs visits
This is the comparison that trips people up, and it mixes two different things. Pageviews and Unique Pageviews are page-report metrics; Visits is a site-level metric.
For a single page, the two page metrics differ only on reloads:
- **Pageviews** is every load of the page.
- **Unique Pageviews** counts one per visit that loaded the page, no matter how many times it was loaded in that visit.
So say four visits land on `/pricing` and load it 1, 2, 3, and 3 times:
| Visit | Loads of /pricing |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 3 |
| **Pageviews** | **9** (every load) |
| **Unique pageviews** | **4** (one per visit that loaded the page) |
Reload the same page inside one visit and pageviews climb while unique pageviews don't. They only match when nobody reloads.
Visits is a different number altogether: the count of sessions across the whole site. Unique Pageviews for one page is a subset of that, the visits that happened to include the page. They line up only when every site visit touched that page, which is rare; usually total site Visits is the larger number.
## Why server-side tracking splits one person into many
Now the duplicate-visitor problem. It almost always shows up the same way. You're tracking server-side, say a Cloudflare Worker logging PDF downloads through the [HTTP Tracking API](/blog/matomo-http-tracking-api-server-side), and one person fans out into a dozen separate visitor entries.
The cause comes straight from the recognition order above. A server-to-server request doesn't carry the browser's `_pk_id` cookie, and unless your worker reads it off the incoming request and forwards an equivalent ID, Matomo falls back to `config_id`, which resets every 24 hours and only looks back 30 minutes. Every gap longer than half an hour mints a fresh "visitor". The fix is to stop leaning on `config_id` and send a stable visitor ID yourself.
The `download` parameter is what logs the request as a file download. Sending only `url=` records it as a pageview, which is the quiet reason a lot of server-side "download tracking" never shows up under Downloads.
```bash
# Log a PDF download for a known visitor.
# _id is a 16-char hex that stays the same for that person on every request.
curl "https://matomo.example.com/matomo.php" \
--data-urlencode "idsite=1" \
--data-urlencode "rec=1" \
--data-urlencode "_id=a1b2c3d4e5f60718" \
--data-urlencode "url=https://example.com/whitepaper" \
--data-urlencode "download=https://example.com/whitepaper.pdf" \
--data-urlencode "cip=203.0.113.9" \
--data-urlencode "token_auth=YOUR_TOKEN"
```
Two rules make this work. First, the `_id` has to be a 16-character hexadecimal string that stays identical for the same person on every request. The durable way to get one is to issue it yourself: set a first-party cookie when you serve the page and reuse its value, or for logged-in users derive it from the account ID with a salted hash (or just send their `uid`). Don't use a per-request value like `cf-ray`, and skip the tempting shortcut of hashing IP plus user-agent: it merges everyone behind a shared IP, falls apart when mobile addresses rotate, and quietly turns a cookieless setup into a persistent pseudonymous identifier you'd then have to justify. Second, set `cip` to the real visitor IP so geolocation reflects the visitor instead of your worker's datacenter; that parameter needs `token_auth`.
One more gotcha the PDF case exposes. If your worker logs show duplicate edge executions or prefetches firing near-identical requests seconds apart, dedupe at the worker (a short-lived cache keyed by `_id` plus URL) before you ever call Matomo, rather than hoping it merges them afterwards. And once requests do land in one visit, repeated downloads still show up as separate download actions by design, so compare "unique downloads" against "downloads" in the report instead of expecting Matomo to fold them together.
## What we'd actually do
If you care about recognising real people across days, the order of leverage is simple. Set a User ID for anyone who logs in. Keep the visitor cookie on. Lean on `config_id` only for the anonymous tail, and don't expect it to remember anyone past the same afternoon. For server-side tracking, own the `_id`. A stable identifier you control beats any amount of config tuning, and it's the one change that turns "twelve duplicate visitors" back into one. And when a number looks wrong, check which period it's scoped to before you blame the tracker. Nine times out of ten you're adding up daily uniques that were never meant to be added.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-unique-visitors-vs-visits) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-unique-visitors-vs-visits) if that's relevant.
Matomo isn't counting wrong. It's counting exactly what you told it to recognise.
---
# Tracking Matomo from your server: the HTTP Tracking API and token_auth
> The event happened on your server, not in a browser, so the JavaScript snippet never saw it. Here's how to record it with Matomo's HTTP Tracking API: the parameters that actually matter, when you need token_auth, and whether you can stop strangers spamming the public matomo.php endpoint.
Published: 2026-07-01
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-http-tracking-api-server-side
---
The order was confirmed by a webhook. The signup came in through a mobile app calling your REST API. The refund got processed in a back-office tool nobody opens in a browser. None of it shows up in Matomo, because the JavaScript snippet that does your tracking never ran. There was no page, no browser, no `matomo.js` to hook into.
You don't fix this by faking browser requests from the server. Matomo has a first-class way to record events from anywhere your code runs: the HTTP Tracking API. It's a plain HTTP request to `matomo.php` that you can send from your own code in any language, on any platform. The parts that trip people up are always the same: which parameters you actually need, when you need `token_auth`, and whether you can stop strangers spamming a public endpoint.
Send a plain HTTP request to `matomo.php` with `idsite` and `rec=1`, and you can record events from any backend or app where the JavaScript snippet never runs. You only need `token_auth` for trusted overrides like `cip` (the real client IP); ordinary hits carry no token. And no token can lock the endpoint down: as long as a browser snippet posts to `matomo.php`, it has to accept anonymous hits.
## Three things people call "the tracking code"
Before the API itself, it's worth separating the things that all get called "tracking" in Matomo, because mixing them up is its own source of bugs:
- `_paq`: the JavaScript Tracking Client in `matomo.js`. A low-level API you call in the browser.
- `_mtm`: the Matomo Tag Manager data layer. You push events onto it; a Trigger then fires a tag that does the actual tracking. A bare `_mtm.push` does nothing on its own until that tag is wired up.
- The HTTP Tracking API: an HTTP request to `matomo.php` from your own code, no browser involved.
The first two only run in the visitor's browser. The third runs wherever you run it, which is why it's the one you want for backend and app tracking. (And don't run both a standalone `matomo.js` snippet and a Tag Manager container that also tracks page views, or every pageview counts twice.)
## The HTTP Tracking API, minimally
Every hit is a `GET` or `POST` to one endpoint:
```
https://your-matomo.example/matomo.php
```
Exactly two parameters are mandatory:
- `idsite`: the site ID you're recording against
- `rec=1`: "record this hit"
Everything else is optional, but you'll want a few. Here's a realistic server-side hit you can paste into a terminal. `curl -G` builds the query string and URL-encodes each value for you, so you don't have to escape spaces by hand:
```bash
curl -G 'https://your-matomo.example/matomo.php' \
--data-urlencode 'idsite=1' \
--data-urlencode 'rec=1' \
--data-urlencode 'action_name=Order confirmed' \
--data-urlencode 'url=https://shop.example/checkout/complete' \
--data-urlencode 'uid=user_123' \
--data-urlencode 'rand=489634' \
--data-urlencode 'apiv=1'
```
`rand` is just a cache-buster and `apiv` is the API version (always `1`). The same request shape records conversions too: add `idgoal` for a goal, or the ecommerce parameters (`idgoal=0` with `ec_id`, a `revenue`, and `ec_items`) for an order.
You can assemble these query strings by hand, but for anything real use one of Matomo's official clients so you're not hand-rolling URL encoding: the [PHP MatomoTracker class](https://github.com/matomo-org/matomo-php-tracker), the [iOS / tvOS / macOS SDK](https://github.com/matomo-org/matomo-sdk-ios), and the [Android SDK](https://github.com/matomo-org/matomo-sdk-android) are all maintained by Matomo. If your raw material is web-server access logs rather than live events, log import is the other server-side path, with [its own set of gotchas](/blog/matomo-import-logs-not-working).
### Which identity parameter to send
There isn't one true identity field, and the right one depends on what you know about the visitor:
- `uid`: a User ID for someone who is logged in. This is Matomo's recommended choice when you have a real account identity; it stitches a user's activity together across devices and browsers.
- `cid`: forces a specific visitor ID (a 16-character hex string). Reach for it when you want a backend hit to join the same anonymous browser session, by passing the visitor ID Matomo already assigned in the browser.
- `_id`: the unique visitor ID, also 16-character hex. If you leave it out, Matomo still records the hit; only the unique-visitors metric gets a bit less accurate.
So the failure mode is softer than it's often described: an omitted ID doesn't turn every event into an orphan visit, it just makes visitor matching more heuristic. If you do have an identity, prefer `uid`; only fall back to `cid` when you're explicitly tying a hit to an existing browser visitor ID.
## When you actually need token_auth
Here's the part the docs bury: ordinary tracking needs no token at all. The JavaScript snippet doesn't carry one, and a plain server-side hit doesn't either. `token_auth` exists to authenticate a small set of trusted overrides: the parameters where Matomo has to take your word for something it would normally measure for itself.
Server-side, the one that bites everyone is `cip`. Without it, Matomo logs the IP of the machine sending the request, which is your application server. Geolocation then points every visitor at your server's location, and because they all share that one address, visitor matching gets badly skewed. Pass `cip` with the real client IP and that's fixed. But `cip` is an override, so it needs `token_auth`.
Here's the full set, and what each one stands in for:
| Parameter | Needs `token_auth`? | What it overrides |
|---|---|---|
| `cip` | Yes | The visitor's IP address |
| `cdt` | Yes, when the timestamp is more than 24h old | The hit's date and time |
| `country`, `region`, `city`, `lat`, `long` | Yes | Manual geolocation |
| `ua` | No | The user-agent string |
| `uid`, `cid`, `_id` | No | Visitor / user identity |
`ua` doesn't need a token, but you'll almost always send `cip` and `ua` together server-side, so the token comes along anyway:
```
&cip=203.0.113.45&ua=&token_auth=YOUR_WRITE_TOKEN
```
One Matomo 5 gotcha if you put the token in a URL like this: a token marked **Only allow secure requests** is ignored when it arrives as a GET query parameter, and the hit fails to authenticate. So either leave that option off for tokens you send in a GET over HTTPS, or, better, send the hit as a `POST` with the token in the request body, which is exactly what secure-only tokens are for.
For the token itself, Matomo's own advice is to create a dedicated user with only write permission on the relevant site(s) and use that user's token, rather than embedding an admin or Super User token in app code. Admin and Super User tokens do work; they're just a much bigger blast radius if the token leaks.
The `cdt` cutoff is configurable on self-hosted installs. The default 24-hour window is the `[Tracker]` setting `tracking_requests_require_authentication_when_custom_timestamp_newer_than` in `config.ini.php`, expressed in seconds (default `86400`). Widen it and you can back-date hits further without a token, which is exactly why you should think twice before touching it. And if you do backfill old hits, reprocess the archives for those dates afterwards, or the reports for that period won't pick the data up.
## Sending many hits at once
If you're flushing a queue of events, don't fire one request each. Bulk tracking is a single `POST` to `matomo.php` with a JSON body: an array of query strings, plus one `token_auth` if any of them use overrides. Order the requests chronologically, oldest first. Matomo stores them as it receives them, so a shuffled queue produces out-of-order visits.
```json
{
"requests": [
"?idsite=1&rec=1&url=https://shop.example/a&_id=1a2b3c4d5e6f7a8b",
"?idsite=1&rec=1&url=https://shop.example/b&_id=1a2b3c4d5e6f7a8b"
],
"token_auth": "YOUR_WRITE_TOKEN"
}
```
## "Can I stop people spamming my tracking endpoint?"
Short answer: not with `token_auth`, and not by hiding the URL. The tracking endpoint is public by design. Every browser running your snippet has to reach `matomo.php`, so the URL sits in plain view in the Network tab and anyone can copy and replay it. `token_auth` authenticates overrides; it does not gate ordinary hits.
No. SSO and SAML plugins secure logging into the Matomo *UI*, not the tracker. They won't make `matomo.php` reject anonymous tracking requests.
If you genuinely must control who submits hits, the fix is architectural, not a parameter. Track server-side only, and restrict `matomo.php` at the web-server or firewall level so it only accepts requests from your application servers' IPs, or put it behind a private network or reverse proxy. Your app then decides what counts as a legitimate event before forwarding it. (Our [hardening checklist](/blog/harden-secure-matomo-installation) covers locking the surface down.)
For almost everyone, that's overkill. There's a lighter, built-in option worth knowing first: under Administration → Websites → Manage, *Only track visits and actions when the action URL starts with one of the above URLs* tells Matomo to drop hits whose `url` doesn't match your configured site URLs. It isn't authentication (anyone replaying a real URL still gets through), but it quietly discards a lot of junk aimed at domains you don't own. Past that, handle junk after the fact with IP exclusions, spam-referrer filtering, and [excluding known bots](/blog/exclude-bots-from-matomo), rather than bolting authentication onto an endpoint that was built to be open.
## Checking it works
1. Send one test hit and confirm an HTTP 200 with a 1×1 GIF in the body (add `send_image=0` and you'll get a `204` instead, handy for health checks).
2. Open Visitors → Visits Log, or Real-time, and watch for the visit to land within a minute or two.
3. If overrides aren't sticking, go to Administration → Diagnostics → Tracking Failures. An insufficient `token_auth` shows up here as a request that wasn't authenticated but should have been.
And if *nothing* tracks at all on a self-hosted install, and your PHP or web-server logs mention disabled functions, check `disable_functions` before you start debugging the API itself. The tracker calls `ignore_user_abort`, so a host that's disabled it can break tracking. Confirm it against the logs rather than assuming, but re-enabling the function is a common fix.
## What we'd actually do
Track server-side from the start for anything that doesn't reliably happen in a browser: orders, refunds, app events, webhooks. Use an official SDK rather than hand-built URLs, and a dedicated write-only token for the `cip` override. Don't try to lock the public endpoint down unless you have a concrete reason to; spend that effort on bot and spam exclusion instead. And keep one tracking path per pageview: `_paq` or a Tag Manager container, never both.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-http-tracking-api-server-side) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-http-tracking-api-server-side) if that's relevant.
The browser was never the only place your business happens, and your analytics shouldn't pretend otherwise.
---
# Why your self-hosted Matomo returns a 502 the moment it sits behind a reverse proxy
> Matomo loads fine on its direct port, then throws a 502 Bad Gateway the second Caddy or Nginx is in front of it. The proxy log says 'connection refused', and that one word tells you exactly what's wrong: your proxy is dialing the host-published port from inside the Docker network, where it doesn't exist. Here's the fix, plus how to size the box Matomo runs on.
Published: 2026-06-29
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/self-host-matomo-reverse-proxy-502
---
If you hit Matomo's port directly and it loads, then put Caddy or Nginx in front of it and get a 502 Bad Gateway, the proxy is doing its job. It reached out to your Matomo container and got nothing back. The tell is in the proxy log:
```
dial tcp 192.168.x.x:8080: connect: connection refused
```
`connection refused` is not a timeout, and it's not a DNS failure. The proxy found the container's network address fine. It dialed a port, and nothing was listening there to answer.
A 502 here almost always means the proxy is dialing the host-published port (`app:8080`) from inside the Docker network, where nothing is listening. For the default Apache Matomo image, point the proxy upstream at `app:80` instead and reload it. The FPM image is the exception: there's no HTTP server in that container, so you proxy to a separate web container on port 80, and a `/matomo` sub-path needs its own forwarded-URI handling on top.
## The published port is not the container port
Here's the compose snippet that produces it:
```yaml
app:
image: matomo
ports:
- 8080:80
```
`8080:80` publishes the container's internal port 80 to 8080 on the host. The official `matomo` image runs Apache, and Apache listens on port 80 inside the container. The `8080` is a host-side mapping only. It exists on your VM's network interface so you can reach Matomo from a browser during setup. It doesn't exist inside the Docker network the containers use to talk to each other. [Docker's own Compose docs](https://docs.docker.com/compose/how-tos/networking/) make the same distinction: service-to-service traffic uses the container port, while the host port is for access from outside the Docker network.
When your reverse proxy is itself a container on that same network, it reaches Matomo by service name plus the container's internal port, never the host-published port. Docker's embedded DNS resolves `app` to the container's IP, and from there you dial whatever port the process is actually bound to. That's 80, not 8080.
Switch the upstream to `app:80` and the 502 clears. The host mapping (`8080:80`) can stay or go; it has nothing to do with traffic between containers. If anything, drop it once the proxy works, since you no longer want Matomo reachable on a raw HTTP port.
### The FPM image is a different animal
The fix above assumes the Apache image. If you're running a `matomo:*-fpm` variant, there's no HTTP server inside that container at all. It speaks FastCGI on port 9000. Use a web container like the [official Nginx example](https://github.com/matomo-org/docker/tree/master/.examples/nginx): Nginx serves `/var/www/html` and forwards PHP to `app:9000` with `fastcgi_pass`. Your public reverse proxy should target that Nginx web container on port 80, not the FPM container.
### Two gotchas that waste an afternoon
1. Caddy labels versus a mounted Caddyfile. Plenty of compose files float around with `caddy.reverse_proxy` labels on the Matomo service. Those labels only do something with the [`lucaslorentz/caddy-docker-proxy`](https://github.com/lucaslorentz/caddy-docker-proxy) image, which reads container labels and builds the config from them. With the stock `caddy:latest` image and a mounted `Caddyfile`, the labels are ignored and the Caddyfile is your only source of truth. Edit it there, reload Caddy, and stop waiting for the labels to do anything.
2. Containers on separate networks. If the proxy and Matomo aren't on the same Docker network, the service name won't resolve at all, and you'll see a name-resolution error rather than `connection refused`. Put both on a shared network and the name starts resolving again.
## First prove which failure you have
Before changing Matomo itself, read the proxy log and classify the failure. The log wording matters.
| Proxy log says | What it means | Fix |
| --- | --- | --- |
| `connect: connection refused` | The service name resolved, but the container port you dialed is closed. | Use `app:80` for the Apache image, or target the separate web container if you run FPM. Test both from inside the proxy container first. |
| `host not found` / `no such host` | The proxy and Matomo aren't sharing Docker DNS. | Put both services on a shared Compose network, then reload the proxy. |
| `timeout` | The name may resolve, but traffic isn't reaching a healthy listener. | Check container health, firewall rules, and whether the web server is actually running before touching Matomo config. |
| Redirect loop once the 502 is gone | Matomo is getting HTTP from the proxy and doesn't know the original request was HTTPS. | Add Matomo's reverse-proxy settings and forwarded headers (next section). |
## Then tell Matomo it's actually behind a proxy
Fixing the port gets traffic flowing again. Now every request reaches Matomo from the proxy's IP over plain HTTP, which breaks two things: visitor-IP logging and HTTPS URL generation. The [official reverse-proxy FAQ](https://matomo.org/faq/how-to-install/faq_98/) covers the fix, and the settings live under `[General]` in `config/config.ini.php`:
```ini
[General]
assume_secure_protocol = 1
force_ssl = 1
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
proxy_host_headers[] = HTTP_X_FORWARDED_HOST
trusted_hosts[] = "analytics.example.com"
; only if Matomo is served from a sub-path:
; proxy_uri_header = 1
```
`assume_secure_protocol` tells Matomo the original request was HTTPS even though the proxy forwarded it as HTTP, which is what stops the redirect loops and mixed-content warnings. `force_ssl` makes Matomo build HTTPS links everywhere, including in scheduled-report emails. The `proxy_client_headers[]` and `proxy_host_headers[]` entries tell Matomo which forwarded headers carry the real client IP and host. `trusted_hosts[]` should contain the real public hostname; do not disable the trusted-host check just to clear the warning.
PHP uppercases header names, prefixes `HTTP_`, and turns dashes into underscores, which is why `X-Forwarded-For` becomes `HTTP_X_FORWARDED_FOR` in the config.
The proxy has to send the same headers Matomo is configured to trust. Caddy's `reverse_proxy` adds `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` by default. In Nginx you add them by hand:
```nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
```
The Matomo config names the PHP server variables it should trust. If your proxy does not send a forwarded host header, do not configure Matomo to depend on one.
If Matomo is served under a path such as `/matomo`, `proxy_uri_header = 1` on its own is not enough. The proxy also has to send `X-Forwarded-Uri`. Matomo's FAQ uses `proxy_set_header X-Forwarded-Uri /matomo;` for Nginx; in Caddy, set the same upstream header in the route that handles the sub-path, and keep any prefix stripping or rewrite rules explicit.
With a CDN or load balancer in front of Caddy, there's one more step: configure Caddy [`trusted_proxies`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#headers) so it trusts the upstream `X-Forwarded-*` values, then check Matomo's visit log. On Matomo 5, if visits still show local or proxy IPs after you've enabled `proxy_client_headers[]`, try `proxy_ip_read_last_in_list = 0`, which Matomo's FAQ suggests for multi-proxy chains.
Don't disable the trusted-host check just to silence Matomo's warning. Setting `enable_trusted_host_check = 0` clears the warning and opens you up to host-header attacks in the same move. Add your real domain to `trusted_hosts[]` and leave the check on. Same quiet dashboard, none of the exposure. We go deeper on that in [hardening a Matomo install](/blog/harden-secure-matomo-installation).
## Verify the whole thing works
Start with the public URL. The exact protocol version and status can vary, especially if Matomo redirects to a canonical host or path. The important part is that the proxy is no longer returning 502.
Then check the application-level symptoms:
| Check | Expected result | If it fails |
| --- | --- | --- |
| Visit log | Real client IPs, not only the proxy container IP | Re-check forwarded headers, CDN trusted-proxy settings, and Matomo's proxy IP order |
| Browser URL | HTTPS pages with no redirect loop or mixed-content warning | Re-check `assume_secure_protocol`, `force_ssl`, and forwarded proto |
| Trusted host | No Matomo trusted-host warning for the public domain | Add the domain to `trusted_hosts[]`; do not disable the check |
| Archiver | `core:archive` finishes without memory errors | Increase CLI memory and move archiving to cron before resizing the VPS |
## After the 502 is fixed: prevent the next slowdown
The other question that comes up constantly is *what VPS specs for X sites?* The framing is usually off. Site count barely moves the needle. What actually drives the load is total traffic, how many segments you run, retention, plugins, database size, and archive concurrency.
Treat VPS numbers as field heuristics, not guarantees. For a handful of low-traffic sites with few segments and normal retention, a practical starting point is 2 vCPU, 4 GB RAM, and an SSD on a host that can resize without a rebuild. A 1-core, 2 GB box is a floor for experiments and very small installs, not a target for production planning.
Rather than guess at specs, pull the levers that actually move the load:
1. Move archiving to cron. This is the single biggest lever. By default Matomo processes report archives on browser requests, so one dashboard view can kick off a heavy archiving run and spike CPU and RAM with no warning. Put the `core:archive` console command on a cron (hourly is typical), then under Administration → System → General settings → Archiving set *Archive reports when viewed from the browser* to No. To stop browser-triggered archiving even for custom segments, add `browser_archiving_disabled_enforce = 1` under `[General]`. We've written up the [full cron-archiving setup](/blog/set-up-matomo-cron-archiving) separately, since it's the change that clears most "Matomo is slow" and "Matomo ate my server" reports.
2. Give PHP-CLI room to work. Archiving is the memory-hungry part of Matomo, and a stingy `memory_limit` produces failed or partial archives that look an awful lot like data loss. Run the archiver under a CLI config with a generous limit instead of the default web one. There's more on [scaling archiving CPU and memory](/blog/matomo-archiving-memory-cpu-scaling) once the database grows.
3. Tune MariaDB or MySQL. Matomo is database-bound. As the data grows, the InnoDB buffer pool and `max_allowed_packet` matter far more than any web-server tuning, and an undersized database is where most [Matomo database errors](/blog/fix-matomo-database-errors) come from anyway.
4. Plan disk for growth and backups. Raw data piles up, and a `mysqldump` needs at least as much free space as the database itself. Run out mid-backup and you've traded one problem for a worse one.
Scale CPU first when archiving runs start lagging, then RAM, then disk. Keep an eye on your `core:archive` run-times. They're the early warning that you've outgrown the box, well before the dashboard feels slow.
## What we'd actually do
If you're staring at a 502 right now, don't touch `config.ini.php` yet. Read the proxy log first. `connection refused` means a port mismatch, almost always `8080` in the upstream where it should be `80` for the Apache image. Fix that one line and reload the proxy. A *different* error, like a timeout or a name-resolution failure, is a different problem, usually the network setup or the FPM image, and none of the Matomo config settings help until traffic actually flows.
Once the 502 is gone, do the proxy-awareness settings in one pass: `assume_secure_protocol`, `force_ssl`, the forwarded-header lines your proxy actually sends, and your domain in `trusted_hosts[]`. Then move archiving to cron before you think about sizing at all. That one change settles more "I need a bigger server" complaints than a bigger server ever does.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=self-host-matomo-reverse-proxy-502) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=self-host-matomo-reverse-proxy-502) if that's relevant.
A 502 behind a proxy almost never means Matomo is broken. It means the proxy is knocking on the wrong door.
---
# Matomo cron archiving fails with "Got invalid response from API request" (or "Processed 0 archives")
> Your scheduled core:archive job either errors out with 'the response was empty' or runs clean, prints 'Processed 0 archives,' and leaves dashboards frozen on yesterday's numbers. The two symptoms have different causes. Here's how we diagnose and fix both on Matomo 5.
Published: 2026-06-24
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-cron-archiving-invalid-response
---
Your scheduled `core:archive` job is doing one of two infuriating things. Either the log fills with `Got invalid response from API request: ...&trigger=archivephp. The response was empty. This usually means a server error`, or it runs clean, prints `Processed 0 archives`, and your dashboards stay frozen on yesterday's numbers while live visits keep arriving. Tracking isn't broken. Flip "Archive reports when viewed from the browser" back to Yes and the data shows up instantly, which proves the raw data is already in the database. What's broken is archiving, and the two symptoms have different causes even though they land in the same place.
These are two separate problems with the same symptom. `Got invalid response from API request` means an archiving worker died before it could report back, so run `core:archive -vvv` by hand and read the log for the path that failed: the CLI PHP error log for a subprocess, or the web server log and `matomo.log` for an HTTP call. The first fatal it prints is usually an out-of-memory hit against the wrong CLI `php.ini`. `Processed 0 archives` is the opposite case, not a crash but stale data, so confirm reports really are behind, then check the archive throttle, browser archiving, and the site's reporting timezone before you invalidate the stale dates and re-run.
This is the troubleshooting post. If you haven't wired up the cron job yet, start with [setting up Matomo cron archiving](/blog/set-up-matomo-cron-archiving) and come back here when a run misbehaves. Everything below is for Matomo 5.x.
## What "the response was empty" actually means
`core:archive` doesn't build your reports inside the cron process itself. It launches separate PHP workers to compute each archive: CLI subprocesses, and in some configurations HTTP calls back to your own Matomo URL. When one of those workers dies partway through, the parent process gets nothing back. The worker might have run out of memory, hit a fatal PHP error, lacked an extension, failed an SSL handshake, or thrown a database error while writing the archive. The parent can't tell which. All it can report is what it got: an invalid response, the response was empty.
The message points at `memory_limit` because that's the most common killer, but it's really a generic "the worker I spawned returned nothing." Where the real error landed depends on how that worker ran. If it was a CLI subprocess, the fatal is in the PHP CLI error log, written by the process that died before it could report anything upstream. If your archiver reaches Matomo over HTTP instead of running the API in-process, look on the web side: the web server error log, `matomo.log`, and the usual HTTP suspects (an SSL or CA validation failure, DNS, a proxy or firewall in the way). For that case, test whether archiving works against `localhost` or the direct IP to isolate the network layer from Matomo itself.
One variant to rule out first. If the archiver goes over HTTP and you sit Matomo behind nginx or Apache as a proxy, an empty `502` from the proxy looks exactly like a dead worker. Check [why your reverse-proxied Matomo returns 502](/blog/self-host-matomo-reverse-proxy-502) before you go chasing PHP limits.
## Find the real error before you change anything
Don't start raising limits on a hunch. Get the actual fatal first.
1. Find the CLI error log. Run this as the same user that owns the cron job, because the CLI environment is what matters here, not the web one:
```bash
php -i | grep error_log
```
2. Run the archiver by hand with full verbosity. This surfaces the underlying failure directly instead of through the "empty response" wrapper:
```bash
/usr/bin/php /path/to/matomo/console core:archive \
--matomo-domain=https://analytics.example.com -vvv
```
One flag worth getting right: `--matomo-domain` is the newer option and points the archiver at the domain Matomo runs on. `--url` is still valid and is the one you want when Matomo lives under a sub-path (`--url=https://example.com/analytics/`) or when the archiver has to reach it over plain HTTP at a specific address. Neither is deprecated, so pick whichever matches your install. Then read the first fatal the `-vvv` run prints. Nine times out of ten, that line is the whole story.
## The memory trap: CLI php.ini is not your web php.ini
If the CLI log shows `Allowed memory size of N bytes exhausted`, you need more memory for the command-line PHP build. Two traps catch almost everyone.
The web server (php-fpm) and the command line use different `php.ini` files. A generous `memory_limit` you set in the hosting panel applies to php-fpm, not to cron. That's the whole reason archiving works in the browser and dies on the schedule. Check what CLI actually sees, again as the cron user:
```bash
php -i | grep memory_limit
```
And watch the units. Use `512M`, not `512MB`. The `B` suffix isn't a valid PHP shorthand multiplier, so modern PHP warns about it and either reads the leading number as raw bytes or keeps the previous limit, depending on where it's set. Either way you don't get the 512 MB you thought you asked for, and the value is easy to mistype and never notice. Confirm the real number with `php -i | grep memory_limit` rather than trusting what's in the file.
You can pin the limit per run instead of editing ini files, which is handy for a quick test:
```bash
php -d memory_limit=-1 /path/to/matomo/console core:archive \
--matomo-domain=https://analytics.example.com
```
While you're in there, confirm the CLI build is otherwise sane. `php -m` should list the same extensions as your web server, and `date.timezone` should be set in the CLI `php.ini`. A missing timezone or extension throws the same empty-response symptom on cron-only runs.
Raising memory is the right fix for a typo or a default that was just too low. It's the wrong fix as a permanent answer on a large instance that genuinely outgrew its ceiling. That's a capacity question, and we've written separately on [how archiving memory and CPU scale](/blog/matomo-archiving-memory-cpu-scaling). And if the CLI log points at the database rather than memory (a lock wait timeout, `max_allowed_packet`, a crashed table), the worker is dying for a different reason. See [fixing Matomo database errors](/blog/fix-matomo-database-errors).
## When it runs clean but "Processed 0 archives"
This one isn't a crash. No dead worker, no error to find. Matomo just decided there was nothing to re-archive. That's the right call when your reports are current, and a problem when they're stale. So before you debug anything, confirm it's actually stale: on Matomo 5 you can run `./console diagnostic:archiving-config` to dump the relevant settings in one place and `./console diagnostic:archiving-queue` to see whether anything is waiting to be processed. If the queue is empty and the config looks deliberate, "Processed 0 archives" is Matomo working as intended. If reports really are behind, a few things put you in the stale-but-silent state.
Browser archiving is the first setting to normalize, not a smoking gun on its own. Leaving "Archive reports when viewed from the browser" on means viewing a report can trigger its own archiving, which muddies what cron is responsible for; it doesn't by itself freeze a dashboard. Still, you want cron to own archiving outright. In Administration → System → General Settings, set it to No. The config equivalent is `[General] enable_browser_archiving_triggering = 0`. If you run reports on custom segments, also add `browser_archiving_disabled_enforce = 1`, which keeps segment requests from quietly triggering archiving on demand. Date-range reports are governed by a different key, `archiving_range_force_on_browser_request`; ranges can still archive on a browser request unless you set that to `0`, and you should only do that if you accept that range reports then depend entirely on cron.
The freshness throttle is set high. The same screen has "Archive reports at most every X seconds" (config key `time_before_today_archive_considered_outdated`). High-traffic setups are often told to raise it to `3600`. Set high, today's archive counts as valid for that whole window, so a run in between won't refresh recent data. That's by design, and it's why "today" can look frozen for up to an hour.
The site's reporting timezone isn't what you think. Each website in Matomo has its own timezone, and the "today / yesterday" boundary follows that, not the server clock. If a site's configured timezone crosses its day boundary at a different moment than you expect, an archive Matomo already built can still count as valid for the period you're staring at. Check the website's timezone under its settings and compare it to the report day you're expecting, rather than blindly forcing it to match the server, especially on multi-region installs where sites legitimately run on different zones.
You're on an old 4.x release. There were reports around Matomo 4.7.1 / 4.8.0 (matomo-org/matomo [#19023](https://github.com/matomo-org/matomo/issues/19023)) of a previously created, possibly empty archive being treated as usable and skipped, producing exactly this "Processed 0 archives, no error" with frozen reports. If you're still on that vintage, upgrade to current 5.x before you spend time debugging old 4.x archiving behavior. On 5.x, the cause is configuration or timezone, not that bug.
To force stale dates through, invalidate them and re-archive:
```bash
./console core:invalidate-report-data \
--dates=2026-06-01,2026-06-03 --sites=1 --periods=day
./console core:archive --matomo-domain=https://analytics.example.com
```
Invalidating days automatically marks the containing weeks, months, and years stale too, so the day's reprocessing flows up into them on the next archive run. The `--cascade` flag works the other direction: reach for it when you invalidate a week, month, or year and also want every lower period inside it rebuilt. A blunter option, when you don't feel like enumerating dates, is `./console core:archive --force-all-websites`, which reprocesses everything with new visits.
## What we'd actually do
Stop reading Matomo's own log first. For the empty-response case, find the log for the path that failed: run `core:archive --matomo-domain=... -vvv` by hand, and if the dead worker was a CLI subprocess read the CLI error log (`php -i | grep error_log` as the cron user), or if it went over HTTP read the web server log and `matomo.log` and check SSL, DNS, and any proxy. The first fatal it prints is almost always the answer: an out-of-memory hit against the wrong `php.ini`, a `512MB` typo, a missing extension, or a database error. Fix that one line and the empty-response runs stop.
For the "Processed 0 archives" case, treat it as configuration, not a crash. Confirm it's genuinely stale with `diagnostic:archiving-config` and `diagnostic:archiving-queue`, disable browser archiving so cron owns archiving outright, check the site's reporting timezone, and if you're on an old 4.x, upgrade before you debug anything else. Then invalidate the stale dates and re-run.
Either way, make the next failure easy to read instead of a mystery. Run the job hourly, unless a single run starts approaching or exceeding the hour, in which case lengthen the interval or split low- and high-traffic sites across separate schedules. Send both streams to a log (`>> /var/log/matomo-archive.log 2>&1`), and keep an eye on Administration → System → Diagnostics for "Last Successful Archiving Completion." That should read in minutes, not days. The moment it starts drifting, you catch the failure on the next run instead of when a client asks why the dashboard hasn't moved since Tuesday.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-cron-archiving-invalid-response) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-cron-archiving-invalid-response) if that's relevant to you.
The empty response is never the root cause. It's the symptom that a worker, or an HTTP request back to Matomo, returned nothing the parent could parse. Work out which path failed, then go read the log for that path.
---
# Matomo says to set up a crontab for core:archive. Here's how to do it right
> Matomo's System Check tells you to run core:archive on a schedule, and your dashboards have started to crawl. Both are the same thing: browser-triggered archiving doesn't scale. Here's how to turn it off and run the archiver on cron correctly on Matomo 5.
Published: 2026-06-22
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/set-up-matomo-cron-archiving
---
Matomo's System Check has started nagging you: *"You should set up a crontab to run core:archive."* Maybe the dashboard got slower too, or a report timed out while a client was watching. Those are the same problem wearing two faces. Out of the box, Matomo builds ("archives") your reports the moment someone opens a dashboard. That's browser-triggered archiving, and it's fine on a fresh install with a few hundred visits. As traffic grows, every dashboard load turns into a heavier query, the UI stalls, and Matomo starts telling you to move archiving onto a schedule. The fix is to turn off the browser trigger and run Matomo's archiver from cron instead. Here's how to do it right on Matomo 5.x.
## First, confirm you're actually tracking data
Before you touch cron, open **Visitors → Visits Log**. This sounds obvious, but it's the most common false alarm there is: people wire up the cron job, see an empty log and no reports, and decide cron is broken. The real reason is usually that no visits have been tracked yet. No visits means nothing to archive, and that looks identical to a job that isn't running. Confirm you have traffic first, then carry on.
## Step 1: disable browser-triggered archiving
In Matomo, go to **Administration (⚙) → System → General Settings** and set:
- **Archive reports when viewed from the browser:** `No`
- **Archive reports at most every X seconds:** `3600`
The first option stops dashboards from triggering archiving on demand. The second caps how often a report can be recomputed, so even an edge case can't hammer your database. For ordinary day, week, month, and year reports, the dashboard now reads prebuilt archives instead of building them on the fly. Custom segments and custom date ranges are the exception: they can still archive on request, so if you want to close that gap too, keep reading.
Worth knowing: the UI toggle doesn't cover every path. Custom segments can still trigger archiving, and someone could flip the setting back on from the interface. To enforce the off state, including for segment-triggered archiving, add this to `config/config.ini.php` under the `[General]` section:
```ini
[General]
browser_archiving_disabled_enforce = 1
```
## Step 2: find your three values
You need three things to write the command:
- The PHP CLI path. Run `which php`. It's usually `/usr/bin/php`, but it varies by host, so check rather than assume.
- The Matomo path: the directory that holds the `console` file, like `/var/www/matomo`.
- Your Matomo domain: the base URL of the install, like `https://analytics.example.org`.
One thing trips people up: `console` is Matomo's command-line entry point. It isn't `matomo.php` (that's the tracking endpoint) and it isn't `index.php`. Point the archiver at the wrong file and you'll get HTML back, including the giveaway line *"This file is the endpoint for the Matomo tracking API."* That message means you aimed at the tracker by mistake.
## Step 3: test the command by hand first
Run the archiver by hand before it ever goes into cron, and run it as the same user your web server runs as, usually `www-data`. That keeps the file permissions in Matomo's `tmp/` cache consistent with what the web UI expects:
```bash
sudo -u www-data /usr/bin/php /path/to/matomo/console core:archive --url=https://analytics.example.org/
```
`--url` tells the archiver the base URL of your install. Give it the whole thing, including the subdirectory if Matomo doesn't sit at the root: `--url=https://example.org/matomo/`. You'll also see `--matomo-domain` in some guides. That one is a console-wide option that takes only the host (`analytics.example.org`) and discards any path you hand it, so it works as a shorthand for a bare-domain install but breaks a subdirectory one. When in doubt, use `--url`; it covers both. If you want to see exactly which options your version accepts, run `./console help core:archive` on the server.
You should see it loop through your sites and periods and report what it processed. An error or an "invalid response from the API" message means something's wrong; sort it out now, because cron will only hide the output. A *"Processed 0 archives"* summary is different. It's often normal, since the archiver only does work when there are new visits or invalidated reports to rebuild. Treat it as a problem only if you have recent traffic and the System Check still says archiving has never run. We've written a full companion post on those failures: [why Matomo's archiver returns "invalid response" or processes 0 archives](/blog/matomo-cron-archiving-invalid-response). This post is about getting the job set up; that one is about why a set-up job comes back empty.
## Step 4: schedule it
There are two ways to install the cron line, and mixing them up is the number-one source of "it just doesn't run." The only difference that matters is whether the line includes a user field.
**Option A: system cron** (you have root; Debian/Ubuntu). Create `/etc/cron.d/matomo-archive`:
```cron
MAILTO="you@example.com"
5 * * * * www-data /usr/bin/php /path/to/matomo/console core:archive --url=https://analytics.example.org/ > /var/log/matomo/archive.log 2>&1
```
Files in `/etc/cron.d/` require a user field. That's the `www-data` sitting in the sixth position, before the command. This one runs every hour at five past.
**Option B: user crontab** (shared hosting, no root). Run `crontab -e` and add the same line, minus the user field, because a personal crontab already runs as you:
```cron
5 * * * * /usr/bin/php /path/to/matomo/console core:archive --url=https://analytics.example.org/ > /var/log/matomo/archive.log 2>&1
```
Two details cause most of the grief here:
- The user field belongs only in `/etc/cron.d/` files. Put `www-data` in a `crontab -e` line and it breaks; leave it out of `/etc/cron.d/` and it breaks the other way.
- Log somewhere the cron user can write and the web server can't serve. The `> ... 2>&1` redirect sends every line of output to a file, and that output can include install details, so keep it out of the web root. `/var/log/matomo/archive.log` is a good default; create the directory and make sure the cron user can write to it. If you'd rather log inside Matomo's own folder, first confirm the file returns 403 or 404 from a browser. And don't point the log at `/home/youruser/...` while the job runs as `www-data`: you'll get an empty or missing log, which once again looks exactly like cron not running.
## Step 5: verify it ran
After the first scheduled run:
- Re-open **Administration → System → General Settings**; it shows when archiving last completed.
- Check the log file from Step 4 for a clean run.
- Re-run the **System Check** under **Administration → Diagnostics**. The crontab warning clears once a scheduled run has succeeded.
## No cron on your host? Use the web-cron
Some shared hosts don't expose cron at all. Matomo can still be archived over HTTP. Point an external scheduler, or your host's URL-based "cron" tool, at the archive endpoint:
```
https://analytics.example.org/path/to/matomo/misc/cron/archive.php
```
POST the super user's `token_auth` to that URL. On Matomo 5, new auth tokens are POST-only by default, so a token sitting in a GET query string will usually be rejected outright. And even when a GET does work, the token ends up in access logs and proxy logs along the way. If your scheduler can only send GET requests, create a token that's explicitly allowed for GET and accept that exposure.
One more thing to watch: the web-cron endpoint has to be reachable over HTTP. (The console command itself usually archives through internal CLI processes and only falls back to curl over HTTP when it can't.) So if your web cron starts failing with gateway errors behind a reverse proxy, that's a proxy-config problem rather than an archiving one. See [fixing 502s on a reverse-proxied Matomo](/blog/self-host-matomo-reverse-proxy-502).
## How often, and what happens as you grow
Hourly (`5 * * * *`) is the standard recommendation, and it's cheaper than it looks. `core:archive` works out for itself which sites and periods actually need reprocessing, so most runs do very little. Want fresher reports? Run it every 15–30 minutes. Once you're archiving dozens of sites, or a couple of very busy ones, a single hourly pass can get heavy. That's the point where you split low- and high-traffic sites onto separate schedules and start tuning concurrency and memory, which we get into in [scaling Matomo archiving CPU and memory](/blog/matomo-archiving-memory-cpu-scaling).
## What we'd actually do
Disable the browser trigger, find your three values, and run the command by hand once before it goes anywhere near cron. That one manual run is the difference between a five-minute setup and an afternoon of guessing; it shows you the real output cron would otherwise swallow. Then install the line with the right user-field rule for where it lives, log to a file outside the web root, and re-run the System Check to confirm.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=set-up-matomo-cron-archiving) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=set-up-matomo-cron-archiving) if that's relevant.
Get the archiver onto cron once, and the warning goes away. So do the slow dashboards.
---
# "Table log_bot_request doesn't exist - in plugin BotTracking": fixing Matomo archiving after an upgrade
> After upgrading to Matomo 5.6–5.8 (or installing through Softaculous), archiving dies with "Table '…_log_bot_request' doesn't exist - in plugin BotTracking". The BotTracking plugin's table never got created. Here's why, and how to put it back so the error doesn't come straight back.
Published: 2026-06-17
Categories: Matomo Self-Hosting
Canonical: https://martez.io/blog/matomo-bottracking-table-doesnt-exist
---
If your Matomo archiver started failing right after an upgrade into Matomo 5.7 or 5.8, including a jump from 5.6.x to 5.8.x, and the log keeps pointing at the BotTracking plugin, the report data isn't corrupted and your config isn't wrong. A database table the plugin expects simply isn't there. A scheduled or manual `core:archive` run ends like this (your database name and prefix will differ):
```text
ERROR [...] Got invalid response from API request: ...method=CoreAdminHome.archiveReports&idSite=1&period=day...
Response was '{"result":"error","message":"SQLSTATE[42S02]: Base table or view not found:
1146 Table 'yourdb.yourprefix_log_bot_request' doesn't exist - in plugin BotTracking."}'
...
X total errors during this script execution, please investigate and try and fix these errors.
```
The archiver fails because BotTracking's `log_bot_request` table never got created during the upgrade or install, so MySQL throws error 1146 every time a site with traffic is archived. Confirm the exact table is missing for your prefix, then run `./console core:update` and let Matomo build it. If Matomo reports nothing pending but the table is still gone, it already thinks BotTracking is installed, so create the table by hand from your own `plugins/BotTracking/Dao/BotRequestsDao.php`. Toggling the plugin off and on won't help: the schema step only runs once, at first install.
The same failure shows up on the dashboard or in the system check as `Mysqli prepare error: Table '..._log_bot_request' doesn't exist`. Every site that has had at least one visit trips it, because archiving for that site reads the bot-request table and the table was never built.
The table name in the error already tells you what to replace later: `yourdb` is the database, and `yourprefix_` is the Matomo table prefix from `config/config.ini.php`. The trap a lot of people fall into first is to deactivate BotTracking, run archiving once (it works), then re-enable the plugin and watch the error come straight back on the next visit. That's the tell: it's a missing table, not a broken setting, and toggling the plugin won't create it.
## Why the table is missing
BotTracking is a bundled plugin in Matomo versions that include the 5.7+ bot-tracking feature. It powers Matomo's AI assistant / AI chatbot bot-request reports, not a general all-crawlers analytics system; Matomo's current [tracking docs](https://developer.matomo.org/api-reference/tracking-api#tracking-bots) say other bot requests may be detected but discarded. Matomo 5.8 expanded this area with dedicated AI chatbot reports. When the plugin installs, its install step is supposed to create one table, `_log_bot_request`, defined in `plugins/BotTracking/Dao/BotRequestsDao.php`. The archiver reads from that table on every run.
The table goes missing when that install step never finished against your database. We've seen three ways into the same hole.
- An interrupted or incomplete update. The browser's one-click updater times out partway through. Core updates, the dashboard looks fine, and BotTracking's table step never runs.
- An auto-installer that skips Matomo's migrations. A lot of these reports trace straight back to Softaculous: a hand install from the official package on the same host has the table, the Softaculous-provisioned copy doesn't. The plugin shows as active either way. Its schema just never ran.
- A database old enough to reject the `CREATE TABLE`. The `created_time` column is `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP`. Matomo's [published requirements](https://matomo.org/faq/on-premise/matomo-requirements/) still list MySQL 5.5+ and recommend MySQL 8+ or MariaDB, but this BotTracking DDL needs a database that supports `DATETIME DEFAULT CURRENT_TIMESTAMP`, such as MySQL 5.6.5+ or a compatible MariaDB. On an older engine the create can fail during install and the plugin can end up marked as installed with nothing behind it.
All three land you in the same state. BotTracking is enabled, the archiver queries `_log_bot_request`, and MySQL returns error 1146 because the table isn't there.
MySQL 5.5 allowed `CURRENT_TIMESTAMP` defaults on `TIMESTAMP`, not on `DATETIME`. Changing `created_time` to `TIMESTAMP` just to make the statement run gives you a hand-edited schema that no longer matches the plugin. Upgrade the database engine instead.
This is also why the disable-then-re-enable trick fails. Matomo records BotTracking as already installed in its internal `option` table, and it only runs the plugin's schema-creation step once, at first install. Flipping the plugin off and on doesn't re-run that step, so the table never reappears. The one run that succeeds is the one where the plugin is off and nothing queries the table.
## How to fix it
Do these in order: read your prefix and check whether the exact table exists; run `core:update`; if Matomo has no pending update and the table is still absent, create it from your installed Matomo source; only disable BotTracking if you accept losing future BotTracking telemetry.
| Path | Best for | Keeps BotTracking telemetry? | Verify |
| --- | --- | --- | --- |
| `core:update` | Interrupted update or installer skipped a migration | Yes, if it creates the table | Exact table exists, then `core:archive` succeeds |
| Manual `CREATE TABLE` | BotTracking is marked installed but the table is missing | Yes, if the schema matches your local plugin | Compare `BotRequestsDao.php`, create table, run `core:archive` |
| Deactivate BotTracking | You do not need AI assistant / bot-request reports | No, not while disabled | `core:archive` succeeds without querying BotTracking |
### 1. Read your prefix and check the exact table
Open `config/config.ini.php` and read `tables_prefix` under `[database]`. If the prefix is `matomo_`, the intended table is `matomo_log_bot_request`; if the prefix is `dbuser_`, it is `dbuser_log_bot_request`.
Then check that exact table name, not a loose wildcard:
```sql
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'matomo_log_bot_request';
```
Replace `matomo_` with your real prefix before running the query. If multiple Matomo installs share one database, this exact match matters; `%log_bot_request%` can match the wrong prefix or stale tables.
### 2. Re-run Matomo's database update from the command line
From your Matomo root directory (where the `console` file lives):
On Windows/IIS use `php console core:update`. This applies any pending updates, including installing bundled plugins that aren't installed yet. If BotTracking genuinely never installed (the interrupted-updater or Softaculous case), this is the supported way to let Matomo build the table itself, and you want a clean "Database upgrade complete" at the end.
The catch: if Matomo already recorded BotTracking as installed but the table creation had silently failed (the old-MySQL case), `core:update` finds nothing pending and reports it has nothing to do. It won't recreate the table. When that happens, use the manual table step.
### 3. Create the missing table by hand
This is the fix that holds when Matomo has BotTracking marked as installed but the table is still absent. Back up the database first. Open `plugins/BotTracking/Dao/BotRequestsDao.php` in your own install, find the column definition, and reproduce it as a `CREATE TABLE` that matches your installed plugin version.
Use the SQL below only after comparing it with your own `plugins/BotTracking/Dao/BotRequestsDao.php`. If your file differs, your installed file wins. In Matomo 5.8.0 builds the structure is:
```sql
CREATE TABLE `matomo_log_bot_request` (
`idrequest` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`idsite` INT UNSIGNED NOT NULL,
`server_time` DATETIME NOT NULL,
`created_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`idaction_url` INT UNSIGNED NULL,
`bot_name` VARCHAR(100) NOT NULL,
`bot_type` VARCHAR(50) NOT NULL,
`http_status_code` SMALLINT UNSIGNED NULL,
`response_size_bytes` INT UNSIGNED NULL,
`response_time_ms` INT UNSIGNED NULL,
`source` VARCHAR(50) NULL,
PRIMARY KEY (`idrequest`),
INDEX `index_idsite_server_time` (`idsite`, `server_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
Three things before you run it:
1. Replace `matomo_` with your real table prefix; it's under `tables_prefix` in the `[database]` section of `config/config.ini.php`, and it might be `mt8k_`, `dbuser_`, or anything else.
2. Match the engine, character set, and collation to the rest of your Matomo tables: run `SHOW CREATE TABLE matomo_log_visit;` on an existing one and copy its `ENGINE`, `DEFAULT CHARSET`, and `COLLATE` if present.
3. Check the database version with `SELECT VERSION();` before running DDL. If it is too old for `DATETIME DEFAULT CURRENT_TIMESTAMP`, upgrade the engine instead of changing the column type.
### 4. Turn BotTracking off if you don't need it
If AI assistant / bot-request reports aren't part of how you read your analytics, deactivating the plugin removes the failing query for good:
```bash
./console plugin:deactivate BotTracking
```
Leave it off and the archiver stops touching the table entirely. This is a real long-term option, not only a workaround: core visit tracking, archiving, conversions, and every standard report run fine without BotTracking. You lose no standard visit or conversion data. You will not collect BotTracking / AI chatbot request telemetry while the plugin is disabled, and rows that were never recorded cannot be recovered later. Turn it back on once the table exists if you decide you want those reports.
If your actual goal is keeping bots out of your human visit numbers, that's a separate job worth getting right; see [excluding bots from your Matomo reports](/blog/exclude-bots-from-matomo).
## Verify the fix
After any option that keeps BotTracking enabled, confirm the exact table exists:
```sql
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'matomo_log_bot_request';
```
Again, replace `matomo_` with your real prefix. You should get the one intended table name back for that prefix. Then confirm archiving completes cleanly:
```bash
./console core:archive --force-all-websites
```
A run with no "SUMMARY OF ERRORS" block means you're clear. Then reload a report in the dashboard and check that data has moved past the upgrade date. If the archiver still throws "got invalid response" errors but stops mentioning a missing table, the cause has shifted; our walkthrough of the [cron archiving "invalid response" error](/blog/matomo-cron-archiving-invalid-response) covers the other usual triggers.
## A different 5.8.0 bug that looks similar
Matomo 5.8.0 also shipped a separate BotTracking bug that breaks archiving: an undefined-constant error, `METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS`, raised inside the plugin's AI-assistant reports. It is tracked upstream as [matomo-org/matomo issue #24192](https://github.com/matomo-org/matomo/issues/24192), reported for 5.8.0, and GitHub still marked it open when checked on June 5, 2026.
If your log says `Undefined constant Piwik\Plugins\BotTracking\Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS` rather than `Table ..._log_bot_request doesn't exist`, creating `log_bot_request` will not help. Deactivate BotTracking or move to a Matomo release whose official changelog explicitly fixes that issue.
It hits the same [AI chatbot reports](/blog/matomo-ai-chatbot-reports) BotTracking was expanded to provide, but it is not the missing-table problem this article fixes.
## Prevent it next time
- Finish every upgrade with `./console core:update` from the CLI instead of trusting the browser updater alone, and watch for a clean "Database upgrade complete."
- Back up your database before upgrading, so you can diff the schema if something comes out missing afterward.
- If you provision Matomo through a hosting auto-installer like Softaculous, check that the plugin tables exist once it's done, or install from the official Matomo package and skip the whole class of problem.
For the wider pattern of archiving falling over on a schema mismatch after an upgrade, our guide to [fixing Matomo database errors](/blog/fix-matomo-database-errors) covers the rest of the family.
## What we'd actually do
If your archiver has been down since an upgrade and you need reports moving again today, build the table by hand from `BotRequestsDao.php` after checking the exact prefix and local schema. It's the one fix confirmed to stick when Matomo already thinks BotTracking is installed, and it leaves Matomo owning the schema from then on. Reach for `core:update` first only when this is a fresh or visibly interrupted install where the plugin never installed at all; that's the case it clears cleanly.
If you're on MySQL 5.5 or an ancient MariaDB, don't paper over the `CURRENT_TIMESTAMP` rejection. Upgrade the database engine. It's the root cause, and it'll keep biting you on future migrations until you do. And if you never look at AI assistant / bot-request traffic anyway, deactivate the plugin and move on.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-bottracking-table-doesnt-exist) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-bottracking-table-doesnt-exist) if that's relevant to you.
A missing table reads like a disaster in the archiver log. In the common missing-table case, the fix is a single correctly matched plugin table. The important part is matching your installed Matomo version and table prefix before you run it.
---
# What Matomo's new AI Assistants and AI Chatbot reports actually mean (and how to hide the column)
> Matomo 5.8 added an 'AI Chatbot Requests' column to the All Websites dashboard plus a set of AI Assistants reports, and the names all sound the same. Here's what each of the three reports measures, why the chatbot numbers look so small, and how to get that column off your dashboard without switching tracking off.
Published: 2026-06-15
Categories: Matomo Tracking
Canonical: https://martez.io/blog/matomo-ai-chatbot-reports
---
You upgraded to Matomo 5.8, opened the All Websites dashboard, and there's a new "AI Chatbot Requests" column sitting next to your visits. There's a new AI Assistants menu at the top level, and a second AI Assistants entry under Acquisition. The chatbot numbers are tiny or empty, the names all sound alike, and you want to know what you're looking at, plus how to get that column off your dashboard without turning tracking off.
The "AI Chatbot Requests" column comes from the `BotTracking` plugin that ships in Matomo 5.8. On self-hosted Matomo, deactivate `BotTracking` under Administration → Plugins to remove both the column and the chatbot reports, or keep it active and hide just the column with custom CSS. Deactivating it also stops chatbot tracking, so weigh that first. The separate Acquisition → AI Assistants referral report, which counts real human click-throughs, stays either way.
None of this is your fault. Matomo 5.8 shipped three different things under names that overlap, all in the same release. The chatbot count doesn't create visits or sessions, though it does feed the All Websites Total Hits summary. Once you can tell the three reports apart, the rest of the questions get easy.
## Three reports, three different meanings
There are three separate reports, and they measure three different things:
| Report | Where it lives | What it counts | How it's detected |
|---|---|---|---|
| **AI Assistants** (referrals) | Acquisition → AI Assistants | *Humans* who clicked a link from an AI tool (ChatGPT, Gemini, Copilot, Perplexity) and landed on your site | Referrer, standard JavaScript tracking |
| **AI Chatbots Overview** | AI Assistants → AI Chatbots Overview | Supported AI chatbot retrieval requests, processed as server-side no-visit telemetry | Supported User-Agent substring, via a server-side integration or the HTTP Tracking API |
| **AI Agent Overview** | AI Assistants → AI Agent Overview | Requests from autonomous AI agents acting on a user's behalf | Standard tracking requests identified as agents (available since Matomo 5.6) |
The distinction that actually matters: the Acquisition → AI Assistants report is about *people* who arrived because an AI tool pointed them at you. The AI Chatbots Overview is about supported chatbot retrieval requests that Matomo receives through server-side telemetry. If you're a marketer asking whether you show up in AI answers, you want the first one. It counts real click-throughs from AI tools. If you want to see which supported chatbots are fetching your content, use the second. Just don't treat it as your full AI scraping or training-crawler report.
The same words show up in two places because Matomo put the chatbot and agent reports under a new top-level AI Assistants menu, while the human-referral report stayed where referral channels have always lived, under Acquisition. Same name, opposite audience. That's the whole source of the "what's the difference?" confusion.
## Why the chatbot numbers look so small
This complaint comes up on the Matomo forum a lot, and there's a real reason behind it.
The traffic this report is built for does not execute JavaScript. Matomo's normal tracking tag, the `_paq` snippet you put on every page, only fires in a real browser, so it never sees a chatbot fetching your content server to server. To fill the AI Chatbots Overview report, Matomo 5.8 ingests telemetry through the HTTP Tracking API, fed by a server-side integration that sits in front of your site. The integration reads each incoming request, matches the User-Agent against Matomo's supported chatbot substrings, and forwards the matches to Matomo. Three integrations ship today: a Cloudflare Worker, an Amazon CloudFront setup that processes access logs with Lambda, and a WordPress plugin. If none of those fit, you can post bot telemetry to the tracking API yourself.
That design is exactly why the numbers feel low. It only catches requests that include one of Matomo's supported chatbot User-Agent substrings, such as `ChatGPT-User`, `Perplexity-User`, or `Claude-User`. Anything without a supported substring goes uncounted. Some newer agent traffic also needs verification by signed headers at the edge, and Matomo's chatbot report is not a signature-verification system. So the number isn't wrong, it's just a floor: the supported chatbots it can see, not the full volume of AI crawling.
| Counted in AI Chatbots | Not counted, or not complete there |
|---|---|
| Supported chatbot User-Agent substrings sent through Cloudflare, CloudFront, WordPress, or a custom Tracking API integration | Training-data crawlers, autonomous crawlers, and other bot requests outside Matomo's supported user-triggered AI assistant scope |
| Server-side chatbot retrieval requests processed with `recMode=1` or `recMode=2` | JavaScript-capable AI agents that create normal tracked requests; read AI Agent Overview for those |
| Page and document retrieval telemetry that reaches `matomo.php` | Unknown or spoofed User-Agents, requests blocked before telemetry, and WordPress pages served from static HTML without PHP execution |
If you want to catch the masked crawlers too, that job belongs at the edge: Cloudflare bot rules, WAF logic, signed-header checks, or log scoring that weighs many signals instead of one user-agent string. That's a different tool than an analytics report, and Matomo isn't trying to replace it. For the marketing question, do we appear in AI answers, skip the chatbot count and read the Acquisition → AI Assistants referral report instead. It counts the humans who actually clicked through.
If you're already pushing bot detection to your edge, the same plumbing behind chatbot telemetry runs over the [HTTP Tracking API](/blog/matomo-http-tracking-api-server-side), and it goes hand in hand with [excluding ordinary bots and spiders from your normal reports](/blog/exclude-bots-from-matomo).
## How to set up AI Chatbot tracking, if you want it
Open AI Assistants → AI Chatbots Overview. With no integration set up, it shows "No data collected" and a "Set up AI Chatbot tracking now" link. Before you start, check the platform split: Matomo Cloud has the feature enabled, while On-Premise installs need the `BotTracking` plugin active. If you already see the All Websites AI Chatbot Requests column, BotTracking is already active.
1. On-Premise only: go to Administration → Plugins → Manage Plugins, then activate `BotTracking`.
2. Pick your integration: Cloudflare, Amazon CloudFront, WordPress, or a custom HTTP Tracking API integration.
3. Deploy the supplied component: the Worker, the Lambda, the WordPress plugin path, or your own server-side middleware.
4. Enter your Matomo URL and site ID when prompted.
5. Do any filtering at the edge *before* sending telemetry, so noise never reaches Matomo.
6. Send a test request with a known chatbot user agent, wait for your normal processing window, then reload the report to confirm hits are arriving.
| Method | Where it runs | Use it when | Watch for |
|---|---|---|---|
| [Cloudflare or CloudFront](https://matomo.org/faq/how-to/install-ai-chatbot-tracking/) | Cloudflare Worker, or AWS Lambda reading CloudFront access logs from S3 | Your site already sits behind that edge layer | Filter before sending telemetry; CloudFront data is log-based and can arrive in batches |
| [WordPress](https://matomo.org/faq/install-ai-chatbot-tracking-wordpress/) | Matomo's WordPress plugin path, inside PHP execution | WordPress serves the request and you do not already track the same chatbot request at the CDN | Static HTML caching that bypasses PHP prevents telemetry; do not enable CDN and WordPress tracking for the same request path |
| [Custom HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api#tracking-bots) | Middleware, reverse proxy code, edge function, or another server-side component you control | None of the bundled integrations fit | Send bot telemetry fields, not a normal pageview hit |
For a custom integration, send bot telemetry to `matomo.php` with `idsite`, `rec=1`, `recMode=1` or `recMode=2`, `ua`, and either `url` or `download`. Add `http_status`, `bw_bytes`, `pf_srv`, `source`, and `cdt` when your edge layer has them.
```text
https://analytics.example.com/matomo.php
?idsite=1
&rec=1
&recMode=1
&ua=ChatGPT-User%2F1.0
&url=https%3A%2F%2Fexample.com%2Fdocs
&source=CustomEdge
```
Nothing else needs configuring once telemetry starts flowing; detection runs automatically on its own processing path. If your Matomo already sits behind a reverse proxy or CDN, the same forwarded-header gotchas that cause [502s and broken IPs behind a proxy](/blog/self-host-matomo-reverse-proxy-502) apply here too, so get the proxy headers right first.
## How to remove or hide the AI Chatbot Requests column
This is the question with the unsatisfying answer, so let's be clear about the trade-off before you touch anything.
Want to drop AI chatbot tracking entirely? The clean switch is the plugin. On self-hosted Matomo, the chatbot reports and the dashboard column both come from the BotTracking plugin that ships in 5.8. Go to Administration (the gear icon) → System → Plugins, which some builds label Manage Plugins, find BotTracking, and deactivate it. That pulls both the AI Chatbots Overview report and the AI Chatbot Requests column off the All Websites dashboard.
Deactivating BotTracking also stops chatbot request tracking. It is not a column toggle. The Acquisition → AI Assistants referral report is a separate subsystem, so human-referral numbers stay put.
The steps above are self-hosted controls. Matomo Cloud has BotTracking enabled by Matomo, and you should not assume plugin deactivation or custom theme CSS is available there.
I could not find a Matomo 5.x UI toggle for hiding only this All Websites metric. If you want the tracking but not the column on a self-hosted install, the practical workaround is custom CSS while BotTracking keeps collecting. One caution: the column selector isn't documented and can change between versions, so don't copy a class name off a forum post. Find the real one in your own build and keep the rule in a custom theme or plugin stylesheet.
Open the All Websites page, right-click the AI Chatbot Requests header, choose Inspect, and confirm the cell in the console before you rely on it:
```js
// Run in DevTools console on the All Websites page.
// Logs the header cell so you can read off its real class/column index.
[...document.querySelectorAll('th, .column')]
.filter(el => /AI Chatbot/i.test(el.textContent))
.forEach(el => console.log(el.className, el));
```
Once you know the selector, drop a rule into a custom theme or plugin stylesheet (replace the placeholder with what you found):
```css
/* Hide only the AI Chatbot Requests column; BotTracking keeps collecting.
Replace the selector with the one you confirmed via Inspect above. */
#mtmwidget-all-websites .col-ai-chatbot-requests {
display: none !important;
}
```
And if it's just the clutter that bugs you, ignore the All Websites summary and work from the per-site Visitors and Acquisition reports, where the chatbot column doesn't show up at all.
One related gotcha: if BotTracking is active but the report errors out or its underlying table looks missing, that's a separate problem. See [the BotTracking "table doesn't exist" fix](/blog/matomo-bottracking-table-doesnt-exist).
## What we'd actually do
For most teams, leave the Acquisition → AI Assistants report on. Human referrals from AI tools are a real and growing channel, and watching it costs you nothing, since the JavaScript tag already collects it.
The AI Chatbots Overview is only worth the edge integration if crawl behaviour is a live concern for you: content coverage, crawl volume, which bots hit which URLs. If it isn't, deactivating BotTracking is a perfectly reasonable call, and the referral report survives it. No need to keep an empty "No data collected" report on your dashboard out of guilt.
And whatever you do, don't read the chatbot count as "how much AI is scraping us." It's the supported chatbot telemetry floor. Training crawlers, autonomous crawlers, masked traffic, and traffic that needs signature verification belong at your edge, not in this report.
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=matomo-ai-chatbot-reports) connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=matomo-ai-chatbot-reports) if that's relevant.
The names overlap. The reports don't. Now you know which one you're reading.
---
# How to set up Matomo Heatmaps so the screenshot matches the page
> Setup is two jobs, not one. Turning the plugin on takes ten minutes. Making the screenshot Matomo produces actually look like your live site is the longer half. Here's the path we walk through on every client setup.
Published: 2026-06-10
Categories: Marketing Analytics, Matomo
Canonical: https://martez.io/blog/how-to-set-up-matomo-heatmaps
---
Setting up Matomo heatmaps is two jobs, not one. The quick one takes about ten minutes: enable the Heatmap & Session Recording plugin, confirm `matomo.js` is shipping the tracker, create a heatmap with a matching target URL, and mask sensitive DOM before the first capture. The slower one is getting the screenshot Matomo reconstructs to actually match your live page, since late rendering, blocked CSS or fonts, a sticky header, or consent that never enables the tracker can each break it. Look at that first snapshot before you trust any click, move, or scroll data.
If this is the week you're finally adding heatmaps to your Matomo, the install itself is a 10-minute job. The hard part is what comes back. Half the time the fonts are wrong, the hero image is a broken icon, the sticky header is stamped down the page like a postage mark, or the product grid stops at the first row.
That's usually not a configuration mistake. Heatmaps and session recordings are client-side browser features. The tracker captures a sanitized page structure and interaction data in the visitor's browser, then sends that data to Matomo. When you view the heatmap later, Matomo rebuilds that stored structure, and the images and fonts have to load again from your site or CDN. Matomo stores some of your CSS itself, but the rest is fetched live. Anything that depended on late JavaScript, cookies, blocked assets, or viewport-specific layout can look wrong.
That split is why setup never feels like one job. Turning the plugin on, configuring a heatmap, and masking sensitive content is the short half. Making the reconstructed snapshot render your site honestly is the long one. This post covers the short half in detail and points at the per-issue posts when the longer one bites.
A note before we start: heatmaps and session recordings ship in the same plugin and use the same tracking code, but you configure them independently. Both work in browsers only, both need JavaScript on the visitor's side, and both need an accessible `configs.php` on your Matomo server. iOS, Android, and desktop apps don't get heatmaps.
## Turn the plugin on
On Matomo Cloud, availability depends on your plan; on Cloud Business, Matomo documents the plugin as configured and ready to go. On a self-hosted instance, install [Heatmap & Session Recording](https://plugins.matomo.org/HeatmapSessionRecording) from the Marketplace UI or the CLI:
```bash
./console plugin:install HeatmapSessionRecording
./console plugin:activate HeatmapSessionRecording
```
Then go to Administration → Diagnostic → System Check and confirm the Heatmap & Session Recording check is green.
If it's not green, the most common reason is that `matomo.js` isn't writable by the webserver or PHP user. The plugin injects its tracker into that file when possible, and if the file is read-only your site can keep shipping the old tracker code. Prefer ownership over broad write permissions, then rebuild:
```bash
chown $phpuser matomo.js
./console custom-matomo-js:update
```
Matomo's docs show `chmod a+w matomo.js` as one way to make the file writable. Treat that as a troubleshooting shortcut, not the final state. Tighten ownership or permissions again according to your deployment model after `custom-matomo-js:update` has rebuilt the tracker.
If you have Cloudflare or another CDN in front of Matomo, purge the cache for `matomo.js` after that rebuild. Otherwise visitors keep getting the cached version that has no heatmap tracker in it, and you'll spend an hour wondering why nothing is recording.
## Confirm the tracker is in place
You don't need to add anything to your tracking code. The standard Matomo JS snippet from Administration → Websites → Tracking Code already loads `matomo.js`, and the heatmap tracker is now part of that file.
Two cases where you do need to do something extra.
First, if `matomo.js` genuinely can't be made writable on your server (some hosted setups), include the heatmap tracker manually next to your normal Matomo tag:
```html
```
Second, if your site is a single-page app or otherwise renders late, the default initial snapshot can happen when the browser considers the page loaded, before your route, lazy images, or client components are actually ready. The result is a perfectly empty screenshot with all the right click coordinates plotted on top of nothing. The fix is to disable the heatmap tracker early, then enable it once your app has rendered:
```js
_paq.push(['HeatmapSessionRecording::disable']);
// ... after your app/route is fully rendered:
_paq.push(['HeatmapSessionRecording::enable']);
```
Interactions before the re-enable call are not recorded, so only use this when the early snapshot is actually broken.
If you use Matomo tracking consent, call `_paq.push(['requireConsent'])` before tracking. If Matomo remembers consent, call `_paq.push(['rememberConsentGiven'])` after the user opts in. If your CMP remembers consent, call `_paq.push(['setConsentGiven'])` on every page after consent.
If you use cookie consent rather than tracking consent, use `requireCookieConsent`, `setCookieConsentGiven`, `rememberCookieConsentGiven`, and `forgetCookieConsentGiven` instead.
If Heatmap & Session Recording itself must stay off until consent, call `_paq.push(['HeatmapSessionRecording::disable'])` early and `_paq.push(['HeatmapSessionRecording::enable'])` only after consent is granted. Disable it again when consent is withdrawn.
## Create your first heatmap
In Heatmaps → Manage Heatmaps → Create New Heatmap:
| Field | Recommended first value | Why | When to change it |
| --- | --- | --- | --- |
| Name | `Home / May 2026` | You will need to find it later. | Add campaign, locale, or template names when many pages share a layout. |
| Target page rule | `equals simple` for a single canonical page | It ignores protocol, URL parameters, and trailing slashes, which is usually what you want. Use the validator before saving. | Use `equals exactly` only when the whole URL must match. Use `starts with`, `contains`, or regex for groups of pages. |
| Screenshot URL | The clean canonical URL | This is the page Matomo uses for the visual snapshot when multiple URLs match the rule. | Set it when the target rule catches UTM URLs, A/B variants, localized paths, or query-heavy URLs. |
| Sample rate | 100% on low-traffic pages | You get data quickly while you are validating the setup. | Drop to 10 to 25 percent on high-traffic pages to reduce tracking requests, stored events, and reporting volume. It does not mean Matomo re-renders a screenshot for every visit. |
| Sample limit | A concrete number you can review | Keeps a runaway heatmap from collecting forever. | Raise it for seasonal pages or low-traffic pages that need more time to reach significance. |
| Breakpoint widths | Use Matomo's desktop, tablet, and mobile views | Matomo can analyze heatmaps by device type and width, so one heatmap can still be read separately by viewport class. | Create separate heatmaps only when you need different page rules, screenshot URLs, sampling, or campaign boundaries. |
| Excluded elements | Cookie banners, popups, chat widgets, survey overlays | Element hiding is for visual clutter that would obscure the screenshot. | Do not use this as privacy masking. Sensitive DOM should be masked in your HTML before capture. |
| Manual snapshot | Leave off for a normal static page | Matomo automatically captures the initial snapshot on page load. | Enable it on Heatmap & Session Recording 5.1.0+ when SPA/lazy content needs an explicit `captureInitialDom` after the page is ready. |
Capture and scheduling limits are set globally under Administration → System → General Settings → Heatmap & Session Recording. If you're capturing across many sites, set a reasonable global cap so one runaway heatmap doesn't fill your storage.
From Matomo 5.4+, the heatmap list has a copy icon to duplicate an existing heatmap to another site and tweak. Useful when you've finally got one site dialled in and now have to do the same thing on the next nine.
## Mask sensitive content before the first capture
Heatmaps and session recordings minimize data in the browser before it is sent to Matomo, but you should still mask account-specific text before the first capture. Add `data-matomo-mask` to any element whose text shouldn't end up in screenshots or recordings:
```html
{{ user.email }}
```
Password fields and credit-card inputs are always masked and you cannot opt back in, which is correct. Since Heatmap & Session Recording 3.2.0, keystroke recording is off by default and has to be enabled in the recording configuration. Leave it off unless you have a specific, consented need. If you want to enforce no keystroke capture even when someone enables it in the UI, do it once at tracker level:
```js
_paq.push(['HeatmapSessionRecording::disableCaptureKeystrokes']);
```
`data-matomo-unmask` exists for the rare case where a parent element is masked but a specific child should stay readable. Use it sparingly. The default should be that text is masked unless you've explicitly checked it's safe to capture.
## Take the first screenshot before you trust any data
This is the step most setup guides skip and it's the one that matters. Matomo automatically captures the initial heatmap snapshot on page load after a matching pageview. After you save a heatmap, visit the target page in a normal browser session, then open the heatmap report and actually look at the snapshot before you trust any data.
Check the snapshot against the live page: same fonts, same images, same layout, one sticky header at the top, complete scroll containers, and separate desktop, tablet, and mobile views that each look right at their own width.
If the first snapshot was captured too early, you have three supported options:
| Situation | Recapture workflow |
| --- | --- |
| Static page, assets restored | Delete the existing heatmap screenshot in Matomo. Matomo will create a fresh one on a later matching pageview. You can force a sample by appending `?pk_hsr_forcesample=1` to the page URL. |
| SPA or lazy page on Heatmap & Session Recording 5.1.0+ | Edit the heatmap, enable "Capture Heatmap Snapshot Manually," copy Matomo's generated `_paq.push(['HeatmapSessionRecording::captureInitialDom', {idHeatmap}])` command, then run it after the page has fully rendered. |
| Late-rendered page where you control the app | Disable Heatmap & Session Recording early and enable it after the route/content is ready, using the snippet from the tracker section above. |
If everything renders cleanly, you're done. Configure the rest of your heatmaps and move on. If something looks wrong, you've hit the longer half of setup. Read on.
## Where it usually breaks
Six patterns we see on basically every site we set up. Most sites hit at least three.
- Custom fonts fall back to Times or Arial because the font or CSS files are CORS-protected when Matomo tries to reconstruct the heatmap view. We've written a [post on Matomo heatmap fonts not loading](/blog/matomo-heatmap-fonts-not-loading) that walks through self-hosting and the CORS settings.
- CDN-hosted images come back as broken icons because the heatmap viewer or snapshot cannot load them later, the file moved, the asset is private, or hot-link protection blocks the request. The [images-not-loading post](/blog/matomo-heatmap-images-not-loading) covers CORS, hot-link protection, and inlining LCP images at build time.
- Relative URLs like `/images/hero.jpg` resolve against your Matomo host instead of your site, and 404. The [relative-URLs post](/blog/matomo-heatmap-relative-urls-not-loading) covers `` and rewriting paths in postbuild.
- Sticky and fixed headers either repeat down the page or freeze across the middle. The [sticky header post](/blog/matomo-heatmap-sticky-header-repeating) ships a console snippet and a permanent CSS pattern scoped to Matomo's `html.matomoHeatmap` class.
- `overflow: hidden` and `overflow: auto` containers get serialised at their visible slice, not their `scrollHeight`, so anything below the fold inside them is missing. Covered in [content cut off](/blog/matomo-heatmap-content-cut-off).
- Iframes collapse to zero height and videos render black. The [iframes post](/blog/matomo-heatmap-iframes-collapsed) and the [videos post](/blog/matomo-heatmap-videos-not-loading) handle each separately.
If you're seeing none of those but the heatmap is empty, the issue is upstream of the screenshot. Common causes: `matomo.js` isn't writable, so the tracker code never got injected; a strict Content Security Policy is blocking the inline Matomo snippet (move it to an external file or attach a CSP nonce); a WAF or nginx rule is blocking `/plugins/HeatmapSessionRecording/configs.php`; consent never gets granted, so the tracker never enables; or the SPA dance from the previous section was skipped. Our [longer fix-broken-screenshots post](/blog/fix-broken-matomo-heatmap-screenshots) walks through the full triage.
## What we'd actually do
If you control the site, ship the structural fixes once and forget about them. CORS headers on your CDN, self-hosted fonts, absolute URLs, and a heatmap-only CSS branch that converts sticky headers to relative and expands scroll containers when Matomo reconstructs the page:
```css
html.matomoHeatmap .site-header {
position: relative;
}
html.matomoHeatmap .product-grid-wrapper {
max-height: none;
overflow: visible;
}
```
Annoying the first time, zero work afterwards.
| Situation | Best path |
| --- | --- |
| You own the templates and CDN | Fix CSS, CORS, font hosting, image URLs, and masking in the site. Every future heatmap benefits. |
| You own the app but the page renders late | Add the disable/enable or manual `captureInitialDom` workflow around the route's ready state. |
| You do not control the templates | Use a capture-time helper, document the snapshot flaws, or budget template changes before presenting the heatmap as evidence. |
If you don't control the templates, or you're capturing across client sites where shipping CSS is a multi-week request, the [Matomo Heatmap Helper](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) Chrome extension does this at capture time. It embeds images and fonts as base64, expands scroll containers, unsticks headers, rewrites relative URLs, resizes iframes, pauses videos, waits for SPA content, then triggers Matomo's screenshot API and reverses every change after. We built it because we ran into the second half of this guide one too many times on client work. The [code is on GitHub](https://github.com/martez-io/matomo-heatmap-helper).
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=how-to-set-up-matomo-heatmaps) is the larger project the extension came out of. It connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and multi-touch attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=how-to-set-up-matomo-heatmaps) if that's relevant.
Most of setup is the ten-minute job. The other half is the part nobody tells you about.
---
# How we pick between Matomo Heatmaps, Hotjar, and Microsoft Clarity
> Three tools that look similar in screenshots but solve different problems. Here's how we pick between them on client work, and what each is honestly good at.
Published: 2026-06-08
Categories: Marketing Analytics, Matomo
Canonical: https://martez.io/blog/matomo-heatmaps-vs-hotjar-vs-microsoft-clarity
---
If you've ever sat in a kickoff where the client says "we want heatmaps, what do you use," you know there isn't a clean answer. There are three serious options most teams shortlist, and they're priced and architected differently enough that picking wrong is expensive in different ways. Hotjar will bill you. Clarity will keep your data on Microsoft's infrastructure. Matomo will make you run something.
These three look similar at a glance. A heatmap, a session recording, maybe some rage-click signals on top. Look at them side by side as buyer decisions and they're not the same product at all.
- Hotjar is a behavior analytics suite. Heatmaps, recordings, surveys, feedback widgets, funnels, all in one tool. Owned by Contentsquare since 2021, which mostly shows up in the pricing and, lately, in which signup page you land on.
- Microsoft Clarity is Microsoft's free entry into the category. Unlimited sessions, unlimited heatmaps, no credit card. The catch is that the data lives in Azure and is processed by Microsoft.
- Matomo Heatmaps & Session Recording is a plugin for Matomo Analytics. Same product surface (clicks, scrolls, recordings), but you can run the whole stack on your own infrastructure.
Three different bets. The decision axes that actually matter, in order: who owns the data, how the bill scales with traffic, and whether you also need surveys and feedback widgets in the same tool.
No universal winner here; we land on different answers for different clients. Pick Matomo Heatmaps when data ownership or EU residency is non-negotiable, but budget time for its screenshot pipeline: heatmap backgrounds are re-rendered cold from a stored DOM snapshot, which breaks on cross-origin assets and SPAs. Hotjar is the buy when you want surveys and feedback next to your recordings and can absorb a bill that climbs fast across many sites; Clarity gives you free unlimited sessions and the best frustration signals of the three, as long as you skip surveys and accept that the data goes to Microsoft. Either way, recordings need consent in the EU.
## At a glance
| | Matomo Heatmaps | Hotjar | Microsoft Clarity |
|---|---|---|---|
| Pricing entry | From €220 a year, 1–4 users, unlimited sites (self-hosted plugin) | Legacy free tier (35 sessions/day); new buyers go through Contentsquare | Free, unlimited |
| Self-hosting | Yes | No | No |
| Data residency control | Anywhere you run PHP/MySQL | AWS, Ireland region; no choice | Azure; EU customers contract with Microsoft Ireland, SCCs to the US |
| Click and scroll heatmaps | Yes | Yes | Yes |
| Session recordings | Yes | Yes | Yes |
| Surveys and feedback | Separate paid plugins | Yes, core feature | No |
| Funnels | Plugin, paid | Yes, gated by tier | Yes |
| Frustration signals | Plugin-dependent | Rage clicks, u-turns | Rage clicks, dead clicks, quick-backs, JS errors |
| AI summaries | Limited | Hotjar AI for surveys and recordings | Clarity Copilot, free |
| Consent in the EU | Needs consent; no third-party processor if self-hosted | Needs consent; DPA available | Needs consent; Consent Mode supported |
| Recording retention | Indefinite self-hosted, 3 months on Cloud | ~365 days on paid plans | 30 days for recordings, 13 months for aggregates |
| Open source | Platform yes, plugin commercial | No | Tracker/replay library (MIT); hosted service no |
This won't decide anything on its own. The interesting part is in the gaps between rows.
All three vendors have moved pricing, packaging, and retention in the last year. The figures here were current in June 2026. Confirm Matomo's tier on plugins.matomo.org, Hotjar's path on the Contentsquare pricing page, and Clarity's retention in the Microsoft docs before any of this goes into a contract.
## Who owns the data, and where does it live
If your compliance team has ever sent back a vendor review with "where exactly does the data go," you know this is the question that quietly kills procurements.
Matomo is the only one of the three you can run somewhere your legal team controls. Self-hosted means a server you own, in a region you pick, with no third-party processor. The plugin runs alongside the analytics, no extra vendor relationship. Matomo Cloud is also an option and runs in EU data centers, but the meaningful version of the privacy story is the self-hosted one.
Hotjar is cloud-only. The data sits in AWS, Ireland region. There's a DPA and a sub-processor list (AWS, plus Contentsquare's own infrastructure since the 2021 acquisition). Compliant in most EU contexts with explicit consent, but you're handing data to a vendor, and I haven't found evidence that you can pick your own region the way the marketing sometimes implies.
Clarity is the interesting case, because the data goes to Microsoft itself, not to a vendor that happens to use Microsoft underneath. It's stored in Azure. EU customers contract with Microsoft Ireland, which leans on Standard Contractual Clauses for transfers to Microsoft in the US. For most teams that's fine. For teams whose compliance review has flagged Microsoft as a processor (we've seen this in EU public sector and German healthcare), Clarity is a non-starter regardless of price.
One thing that trips people up: Matomo Analytics can sometimes be configured for consent-exempt measurement, depending on how you set it up and which national regulator you answer to. That story does not carry over to heatmaps and session recording. Hotjar and Clarity both set non-essential cookies and need consent in the EU, though Clarity now ships a Consent Mode that can hold cookies until a signal arrives and run a limited no-consent mode when consent is denied.
Don't carry Matomo's cookieless analytics story over to heatmaps. Matomo's own Heatmaps & Session Recordings docs say these features need prior consent under ePrivacy laws, the same as Hotjar and Clarity. Self-hosting changes who processes the data; it doesn't remove the consent requirement for recording what someone does on the page.
## How the bill scales
The honest pricing comparison isn't tier-to-tier. It's how the bill behaves as you grow.
Hotjar's legacy free tier (35 sessions a day) is fine for testing, and existing accounts can still use it. New buyers now land on the Contentsquare pricing page, where the free plan captures a much larger session volume but caps how many recordings you can replay and keep. Paid tiers are billed per site, and the price has climbed steadily since the Contentsquare acquisition, which is the thing existing customers complain about most. At three or four sites, the bill is real money. At ten or twenty client sites, it's a budget line item.
Clarity is free at any volume. There's no caveat there. Unlimited sessions, unlimited heatmaps, unlimited projects. The cost is the data going to Microsoft, not the invoice.
Matomo is the messiest to price honestly. The Heatmap & Session Recording plugin is licensed by user tier, not by site: the entry tier runs €220 a year for 1–4 users and unlimited websites (check plugins.matomo.org, the Matomo team adjusts it). Add the cost of running the server, plus your team's time. Matomo Cloud bills by hits and may fold Heatmaps into higher plans depending on the current packaging. If you already run Matomo, adding heatmaps is a small marginal cost. If you don't, you're costing in a whole platform.
Rough numbers, for a 250K-monthly-visit setup across three domains:
- Clarity: $0/year. Period.
- Matomo self-hosted: €220/year for the plugin (one license covers all three domains, since it's priced by users, not sites), plus your existing Matomo hosting.
- Hotjar via Contentsquare: in the low thousands per year per site, depending on session volume and current packaging.
That's not a fair comparison, because you're getting different things, but it sets the order of magnitude.
## Heatmap rendering
This is the part I find most interesting, because it's a real product difference and it almost never makes it into vendor comparisons.
Matomo serializes the page's DOM in the browser, sends it to your Matomo server, and re-renders that stored snapshot later, when you open the heatmap, to paint the screenshot. The advantage is privacy. You don't have screenshots streaming to a third party. The disadvantage is that re-rendering stored HTML cold (different origin, no cookies or session, assets re-fetched at view time) breaks a lot of modern websites. CORS-protected images come back as broken icons. Custom fonts fall back to system defaults. Sticky headers collapse onto the content. SPAs get captured before React or Vue have finished rendering. We've written about [each of these issues in detail](/blog/fix-broken-matomo-heatmap-screenshots), and we maintain [an open source extension](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) that fixes most of them at capture time. It's the price you pay for keeping the screenshot pipeline private.
Hotjar takes a different route. The tracker opens a WebSocket on page load, captures the initial DOM, and then uses the browser's `MutationObserver` API to record every change plus clicks, mouse moves, scrolls, and keystrokes. The "screenshot" you see under a heatmap is a frame from one of those recorded sessions, reconstructed from the DOM stream. Because the recording happens inside the user's real browser, with their cookies, fonts, and CDN access intact, it sidesteps a lot of the snapshot timing and origin problems we hit on Matomo. It doesn't make them disappear: Hotjar's own docs cover recordings that look broken when CSS or images aren't reachable. The costs are script weight (Hotjar's tracker has historically been one of the heavier ones, which shows up in LCP on slow connections) and a few real blind spots: canvas and iframe content aren't captured, because `MutationObserver` can't see into them.
Clarity works the same way Hotjar does, mechanically. It instruments the page with `clarity-js`, records DOM mutations via `MutationObserver`, ships the data, and reconstructs sessions in `clarity-visualize`. The heatmap screenshot is a frame from a recorded session, the same pattern as Hotjar. The differentiator isn't the capture mechanism, it's the aggregation: Clarity aggregates clicks by CSS element selector by default, which is why you'll see "click on the primary CTA in the hero" as a unit rather than coordinates on a screenshot. The same `MutationObserver` blind spots apply, and Clarity has additional reported issues with shadow DOM (where mutations don't always reach the standard observer) and some heavily customized React apps.
So Matomo is the odd one out, not Clarity. Matomo captures the DOM once and re-renders it cold from storage. Hotjar and Clarity both record DOM mutations in the user's browser and reconstruct the page from that stream. If you have a static-ish site without iframes, custom fonts, or sticky headers, none of this matters and all three will give you usable heatmaps. If you have a SPA with cross-origin assets, you're going to spend setup time on Matomo that you wouldn't on the other two. And all three have replay blind spots: Matomo has its own documented iframe and cross-domain limits, so if your page leans on canvas, iframes, or shadow DOM, test it in each tool before you assume any of them captures it cleanly.
## Frustration and friction signals
This is the category Clarity quietly dominates.
Clarity has rage clicks, dead clicks, excessive scrolling, quick-backs, and JS errors as first-class signals. They're surfaced in the dashboard, filterable, and used in the Copilot summaries. For a free tool, the friction analytics are stronger than what most teams build on top of paid alternatives.
Hotjar has rage clicks and u-turns, plus a frustration score (the naming has shifted a couple of times). It's competent and integrated with the rest of the suite, but gated behind paid tiers.
Matomo has basic insights via plugins, but there's no first-class "rage click" metric out of the box. You can build it with Custom Dimensions and event tracking, but it's work. If frustration analytics are the main thing you need, Matomo is the wrong choice unless you're happy rolling your own.
## Surveys and feedback
If you want heatmaps and surveys in the same tool, Hotjar wins.
Hotjar has full-featured surveys, feedback widgets, and AI-assisted survey design. The link between a recording and a survey response from the same user is the thing existing customers cite when asked why they're paying.
Clarity has neither. No surveys, no feedback widgets. There's a real product gap here.
Matomo has separate Form Analytics and Surveys plugins, which work, but they're less polished than Hotjar's and they're additional plugin licenses on top of the heatmaps one.
## Who picks what
Honest version, based on procurements we've actually run.
Pick Matomo Heatmaps if you already run Matomo Analytics, or you need data ownership and EU residency, or your compliance team has flagged Microsoft as a processor. The catch is the screenshot rendering. Be willing to manage CORS, iframe, and font issues, or use [our extension](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd) to handle them at capture time.
Pick Hotjar if you want one tool for heatmaps, surveys, feedback, funnels, and recordings, you have the budget, and you're not under strict data-residency rules. The polish is real, and the survey-to-recording link is genuinely useful when you need to understand what someone was doing right before they filled out a feedback widget.
Pick Microsoft Clarity if you want unlimited sessions for free, you're fine with Microsoft processing the data, you don't need surveys, and you want strong frustration signals at zero cost. We use it on client sites where the compliance posture allows it, mostly as a friction microscope on top of Matomo.
A few anti-recommendations. Don't pick Clarity if your compliance team has flagged Microsoft as a processor. Don't pick Matomo if you're running a complex SPA with cross-origin assets and you don't have engineering capacity for the rendering edge cases. Don't pick Hotjar if you have many domains and a tight budget.
Running two in parallel works fine, by the way. We've had clients run Matomo Heatmaps for the data-ownership case and Clarity for the frustration signals on the same site, with Matomo as the canonical analytics and Clarity as the friction microscope. The tracker overhead is real, so don't add a third.
None of these three tools is universally better. We've ended up with different answers on different clients, depending on whose data lives where, what the team can run, and whether surveys belong next to the recordings. The boring answer is the honest one.
---
# Why every Matomo paid-media review ends in a spreadsheet
> Matomo tracks campaigns and runs multi-touch attribution, but it doesn't know what your ads cost. That one missing input is why every paid-media review ends in a spreadsheet.
Published: 2026-06-03
Categories: Marketing Analytics, Matomo, Attribution
Canonical: https://martez.io/blog/tracking-ad-campaign-roi-in-matomo
---
Most Matomo marketers we talk to have a second browser tab open during a paid-media review. Matomo on one side. Meta Ads Manager and Google Ads on the other. The blended ROAS number that ends up in the client deck lives in a spreadsheet that both tabs feed into separately.
That's the frustrating thing. Matomo's side of the review is fine on its own. When campaign parameters reach the tracker, Matomo attributes goals and ecommerce revenue back to the campaign that drove them. Core tracking captures campaign name and keyword; the Marketing Campaigns Reporting plugin adds the full UTM breakdown for source, medium, content, and campaign ID. Matomo's [Multi Channel Conversion Attribution plugin](https://plugins.matomo.org/MultiChannelConversionAttribution) (MCCA) gives you last-interaction, last-non-direct, first-interaction, linear, position-based, and time-decay models per goal in the UI, plus ecommerce orders once ecommerce is enabled. The Reporting API exposes those reports for whatever BI tool you've standardised on. For anything involving traffic Matomo actually saw, the reporting is honest and complete.
What's missing is the other tab. There's no place in native Matomo that knows a campaign cost money. No ROAS field in any report. That one missing input is why every paid-media review needs a spreadsheet to finish.
## Why it stops where it stops
Two structural things, mostly.
The first is the data model. Matomo doesn't have a concept of ad cost. The Campaigns report shows 412 visits and 12 goal conversions from `spring_sale` and stays silent on the €380 you spent on Meta and Google to produce those clicks. You can try to push spend into a [Custom Report](https://plugins.matomo.org/CustomReports) through Custom Dimensions, but Custom Dimensions are visit- or action-scoped labels, not a campaign-date spend table. Attach €380 to every visit from `spring_sale` and a sum duplicates the cost across all 412 of them; store €380 as a label and Matomo just groups traffic by that value instead of dividing it into revenue. Either way you don't get a spend join you can trust. Nothing in the default reports returns CPC, CPA, or ROAS, because nothing in the schema can.
The second is what MCCA can see. Its models run on the acquisition touchpoints Matomo captured through standard tracking: channels, referrers, campaigns, and visits. Anything the JavaScript tracker didn't witness is invisible to it. Meta and YouTube impressions that didn't end in a click. View-through conversions on display and video. Cross-device journeys before login. Offline conversions. If you're running click-based paid search and direct response, and your campaign parameters, consent, and redirects are clean, MCCA is directionally reliable. If you're running upper-funnel display, video, or awareness, MCCA gives those unseen touchpoints little or no conversion credit, because the touchpoint never hit the tracker. The more budget you move into brand and upper funnel, the more the attribution undercounts what's actually working.
## How teams close the gap today
We've talked to a lot of Matomo-heavy teams about this, and the workarounds settle into a few shapes. None are clean.
Most start with a spreadsheet. Pull campaign data from Matomo, cost data from Meta and Google, paste it all into a sheet, compute ROAS in a formula column. Works fine for one client with three campaigns. At ten clients or thirty campaigns each, it becomes someone's actual job, and the numbers are stale by the time the deck ships.
A lot of agencies graduate to Looker Studio, Metabase, or whatever BI tool the team standardised on. Connect Meta Ads, Google Ads, and Matomo through the Reporting API or scheduled CSV exports, and put a blended ROAS dashboard in front of the client. This is the best honest answer short of a real pipeline, and a lot of teams stop there. What it doesn't fully solve is the attribution side. Most of these setups stop at the standard campaign and referrer export, so the cost join lands on last-non-direct-style metrics, not on an MCCA model. You can get MCCA-weighted ROAS this way, but only if you deliberately pull the MCCA attribution report through its own export or API and decide how spend should be allocated across the channels and campaigns it credits. Most teams don't, so blended ROAS is what ships.
A few teams build a real pipeline. A data person syncs Matomo, Meta, and Google to a warehouse, joins spend to conversions on date and campaign, and exposes the result as a dashboard. This is the most flexible answer, but it's only as complete as the platform data and attribution rules you import. View-through, offline conversions, and timezone alignment don't come for free. It's also the one that requires hiring a data person, which is a bigger commitment than most marketing teams can make.
There are connector plugins that push ad cost straight into Matomo custom reports. The ones we've looked at tend to solve spend ingestion or a single reporting slice, not spend, cross-channel attribution, and CLV under one schema. They handle the ingestion half without touching the half MCCA can't see.
## Our attempt to solve it
[Martez](/?utm_source=martez&utm_medium=blog&utm_campaign=tracking-ad-campaign-roi-in-matomo) is the data platform for Matomo a data person would build, without the hire. Connect Meta Ads and Google Ads once, and it pulls spend on each platform's native cadence, joins it to Matomo's conversion data on the campaign keys Matomo already uses, and exposes the result as reports: ROAS by campaign and channel, CLV, and multi-touch attribution that folds in view-through wherever the ad platform shares impressions. Matomo stays authoritative for traffic, events, and conversions. Martez fills in what the tracker was never going to see, and keeps the numbers honest as the ad platforms restate them.
It's in private beta. [Join the waitlist](/signup?utm_source=martez&utm_medium=blog&utm_campaign=tracking-ad-campaign-roi-in-matomo) if this is the part of your month that currently lives in a spreadsheet. We're building it because we ran into it ourselves.
---
# Why videos aren't loading in your Matomo heatmap (and how to fix it)
> Matomo heatmap screenshots show black rectangles where your hero video, autoplay product reel, or background loop should be. The clicks are fine. Here's why the video doesn't paint and how we deal with it.
Published: 2026-06-01
Categories: Matomo Heatmap Helper
Canonical: https://martez.io/blog/matomo-heatmap-videos-not-loading
---
You open a Matomo heatmap, and where your hero video should be there's a black rectangle with click markers floating on top of it. The clicks are fine. Matomo recorded them at the right coordinates. What's broken is the screenshot underneath them. The video didn't paint when Matomo rebuilt the page for the heatmap, so that whole section is unreadable and the click on your CTA looks like a click on nothing.
The fastest fix, if you can't touch the site's code, is a free Chrome extension we maintain called [Matomo Heatmap Helper](https://chromewebstore.google.com/detail/matomo-heatmap-helper/mndiinpjddfgnpemghkcefbpegcnbnnd). It pauses every video on the page, seeks to the first frame, and restores playback once the capture is done. If you can edit the markup, the durable fix is a `poster` attribute, and the rest of this post covers both paths.
A `
## How to fix it without the extension
There's a console snippet that papers over the problem right before each capture, and a handful of permanent fixes you can ship with the site. Pick whichever fits the constraints you're working under.
### Diagnose what's actually failing
Before you change anything, find out what state the video is in. Open the page in Chrome, hit F12, click the video in the Elements panel so it becomes `$0`, then switch to the Console and paste:
```js
// Paste into the console after selecting the video in the Elements panel.
console.log('Poster:', $0.poster || '(empty)');
console.log('Ready state:', $0.readyState); // 0 = nothing, 2+ = a frame is decoded
console.log('Current time:', $0.currentTime);
```
Empty poster plus `readyState: 0` is the failure mode. The element has no decoded frame, so there's nothing to paint when the snapshot is taken and it falls back to a black box. `readyState >= 2` (`HAVE_CURRENT_DATA`) means the browser has a frame ready, and the snippets below can lock onto it.
### Get Matomo to recapture the page
By default, Matomo's Heatmap & Session Recording captures its snapshot automatically when the page loads. Two things follow from that.
First, if Matomo already stored a bad snapshot, shipping a fix won't change the heatmap you're staring at. That snapshot is frozen. Delete the existing heatmap's screenshot/sample in Matomo so it captures a fresh one on the next qualifying visit.
Second, the console snippets below only help if Matomo captures the page *while* they're applied, and a page-load capture has already happened by the time you paste anything. To capture on demand, enable **Capture Heatmap Snapshot Manually** in the heatmap's settings (Matomo Heatmap & Session Recording 5.1.0 and up). That panel shows you the exact call to run. Apply your snippet first, then fire it from the console:
```js
// Matomo's settings panel shows this line with your heatmap's ID filled in.
_paq.push(['HeatmapSessionRecording::captureInitialDom', idHeatmap]);
```
One gotcha worth knowing before you waste twenty minutes: manual capture won't run if your own IP is excluded from tracking. Test from an address that isn't excluded, or drop the exclusion while you test.
### The quick fix: pause every video and seek to the first frame
This snippet pauses all media and waits until each video is parked on a decoded first frame, so the renderer has something stable to capture. The `await` matters: seeking is asynchronous, and if you trigger the capture before the `seeked` event fires, you snapshot the black box anyway.
```js
// Paste into the console, then trigger the manual capture once it resolves.
async function freezeForCapture() {
document.querySelectorAll('audio').forEach(a => a.pause());
const videos = [...document.querySelectorAll('video')];
await Promise.all(videos.map(v => new Promise(resolve => {
v.pause();
const seekToStart = () => {
v.currentTime = 0;
v.addEventListener('seeked', resolve, { once: true });
};
// readyState >= 2 (HAVE_CURRENT_DATA) means a frame is decoded and paintable.
if (v.readyState >= 2) seekToStart();
else v.addEventListener('loadeddata', seekToStart, { once: true });
setTimeout(resolve, 3000); // give up on a video that never decodes
})));
}
await freezeForCapture();
```
This is enough on its own for a lot of sites. The video stops, the first frame sits there until the capture is done, and the heatmap shows your CTA on top of the actual hero shot instead of on top of nothing.
It can't help a video that never decodes a frame, which is what `readyState: 0` was telling you above. A `preload="none"` video that nobody has played has no pixels to seek to. For those, the next snippet tries to build a poster from a frame once one is available, and failing that you ship a real poster image.
### Auto-generate posters from the first frame
If a video has a decoded frame, you can paint it onto a canvas, encode that as a data URI, and set it as the `poster`. The poster is a plain image, so it survives the snapshot cleanly:
```js
// Run after freezeForCapture() resolves. Needs a decoded frame to read.
document.querySelectorAll('video').forEach(v => {
if (v.poster || v.readyState < 2) return; // no frame yet (HAVE_CURRENT_DATA)
const c = document.createElement('canvas');
c.width = v.videoWidth || 1280;
c.height = v.videoHeight || 720;
try {
c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
v.poster = c.toDataURL('image/jpeg');
} catch (e) {
console.warn('Canvas tainted (cross-origin without CORS):', v.currentSrc);
}
});
```
The gate on `readyState >= 2` matters here. Metadata alone tells you the video's dimensions but not that any frame has been decoded, and `drawImage` needs actual pixels. The other catch is cross-origin: a video on a CDN that doesn't return CORS headers taints the canvas, and `toDataURL` throws. For that to work, the `` has to carry `crossorigin="anonymous"` *before* the resource loads, and the CDN has to send `Access-Control-Allow-Origin` for your origin. Adding `crossorigin` after the fact doesn't un-taint anything. If you can't get both, ship a poster image yourself.
### Always set a poster attribute (the canonical fix)
The cleanest answer for almost everyone. A `poster` is a static image, not a frame the browser has to decode, so it survives the snapshot without any of the timing problems video has. Add it to every `` tag and the heatmap stops being a guessing game:
```html
```
This is also better for real visitors. The poster shows while the video buffers, instead of the empty rectangle some browsers display by default. Better perceived load, fixed heatmap, one line of markup.
### Generate posters in your build pipeline with ffmpeg
If you have more than a handful of videos and you're touching them anyway, wire poster generation into the build step. ffmpeg is the obvious tool:
```bash
# Edit the input filename. Grabs a frame at the 1-second mark.
ffmpeg -i hero.mp4 -ss 00:00:01 -frames:v 1 hero-poster.jpg
```
A one-second mark usually beats frame zero, because the very first frame of an encoded video is sometimes a black or near-black keyframe before the content starts. Run it once per video, commit the JPG, set `poster="hero-poster.jpg"` in your template.
### Swap to a static image during capture with the matomoHeatmap hook
Useful when the video itself can't change but you can edit the markup or CSS around it. Matomo adds a `matomoHeatmap` class to the `` element while it captures a snapshot, so you can target that state in plain CSS, no JavaScript and no guessing at a tracker flag:
```css
/* Edit the selectors to match your markup. */
html.matomoHeatmap video.hero { display: none; }
html.matomoHeatmap picture.hero-fallback { display: block; }
```
The `` shows up only during capture; the `` stays visible to real visitors. A bit more wiring than just adding a poster, worth it when the video is owned by a vendor widget or a CMS field you can't touch directly.
### Switch preload="none" to preload="metadata" (a minor improvement)
Treat this as a footnote, not the fix. `preload="none"` tells the browser to fetch nothing until the user hits play, which means `readyState: 0` and no frame for the canvas trick to read. Bumping it to `preload="metadata"` lets the browser know the video's dimensions earlier:
```html
```
Just don't mistake it for a heatmap fix. `metadata` fetches things like duration and dimensions; it does not decode a frame (that's `loadeddata` / `HAVE_CURRENT_DATA`), `preload` is only a hint the browser can ignore, and with `autoplay` set the browser loads the video regardless. The `poster` is what makes the snapshot correct.
## Why Matomo can't render your videos
Matomo's Heatmap & Session Recording runs in the visitor's browser. When the page loads, its JavaScript serializes a snapshot of the DOM, the page structure as it stood at that moment, and stores it on your Matomo server. Later, when you open the heatmap, Matomo rebuilds the page from that stored snapshot, loads the resources (CSS, images, fonts) from your site, and overlays the click data on top.
There's no live browser session in that replay, no autoplay timer, no decoded video sitting in memory. A `` element is just an empty rectangle until something decodes a frame to paint into it. If the tag had no `poster` and no already-decoded frame at the moment the snapshot was taken, the stored structure has nothing to show, and the replay paints an empty box. Most renderers fill that with black. That's the box you're seeing in the heatmap.
## What's actually failing
A few patterns we keep running into:
- Hero videos with `autoplay muted loop` and no `poster`. Fine in a real browser, black rectangle in the heatmap, because the snapshot never caught a decoded frame.
- Autoplay product carousels built on `` elements (sometimes inside a `` for art direction). Same root cause as the hero video, multiplied across the page.
- `` on lazy-loaded sections. Saves bandwidth for real visitors, leaves no decoded frame when Matomo takes the snapshot.
- Background videos absolutely positioned behind text. The text shows up, the video doesn't, and the heatmap shows your hero copy floating over a black rectangle.
- `