Skip to content

Ad blockers and CSP

Why a script gets blocked, and the one change that fixes both causes.

There are only two causes

If the snippet is on your page and no data arrives, it is almost always one of two things. An ad blocker refused to load a script from a domain on its blocklist, or your own Content Security Policy refused to load a script from a domain you have not listed.

Both are judging the same thing: the domain the script came from. That is why one change fixes both.

Loading the script from termind.tech is a cross domain request, which an ad blocker or a Content Security Policy can refuse. Loading the same script through a path on your own domain is a same domain request, which neither can tell apart from your own files.LOADED FROM OUR DOMAINYour pageyoursite.comdifferent domainBlockedby an ad blocker or a CSPLOADED THROUGH YOUR OWN DOMAINYour pageyoursite.comsame domain/pulse.jsa path on your siterewriteTermindserver to serverThe rewrite happens on your server, so the browser never makes a cross domain request.
The same script, blocked and allowed

Serve it from your own domain

Add two rewrites on your own server and point the snippet at your own path. The browser then makes an ordinary same domain request, which an ad blocker cannot distinguish from your own files and a Content Security Policy already allows under self.

Between twenty and forty per cent of visitors on a technical audience run a blocker, so this is usually the difference between roughly right numbers and numbers you can act on. Termind sends its data back to whichever origin it was loaded from, so nothing else in your setup changes.

Next.js — next.config.js
module.exports = {
  async rewrites() {
    return [
      { source: "/pulse.js", destination: "https://www.termind.tech/t.js" },
      { source: "/v1/:path*", destination: "https://www.termind.tech/v1/:path*" },
    ];
  },
};
Vercel without Next.js — vercel.json
{
  "rewrites": [
    { "source": "/pulse.js", "destination": "https://www.termind.tech/t.js" },
    { "source": "/v1/:path*", "destination": "https://www.termind.tech/v1/:path*" }
  ]
}

On a single page app, put these two rewrites above your catch-all rule. A catch-all that sends every path to index.html will swallow them, and the script will quietly serve your HTML instead of JavaScript.

Netlify — _redirects
/pulse.js  https://www.termind.tech/t.js      200
/v1/*      https://www.termind.tech/v1/:splat  200
Nuxt — nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    "/pulse.js": { proxy: "https://www.termind.tech/t.js" },
    "/v1/**": { proxy: "https://www.termind.tech/v1/**" },
  },
});
Astro or SvelteKit on a Node adapter — vite.config
export default {
  server: {
    proxy: {
      "/pulse.js": { target: "https://www.termind.tech/t.js", changeOrigin: true, rewrite: () => "/t.js" },
      "/v1": { target: "https://www.termind.tech", changeOrigin: true },
    },
  },
};
Nginx, for anything self hosted
location = /pulse.js {
  proxy_pass https://www.termind.tech/t.js;
  proxy_set_header Host www.termind.tech;
}

location /v1/ {
  proxy_pass https://www.termind.tech/v1/;
  proxy_set_header Host www.termind.tech;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Then use your own path in the snippet
<script defer src="/pulse.js" data-key="YOUR_KEY"></script>

Do not name the file analytics.js, stats.js, tracking.js or termind.js. Blocklists match those words in the path, which puts you back where you started. Any unremarkable name works: pulse.js, p.js, script.js.

The same two rewrites, on other stacks

Every one of these does the same job: serve our script from a path on your domain, and forward everything under /v1 to us.

Express
const { createProxyMiddleware } = require("http-proxy-middleware");

app.use("/pulse.js", createProxyMiddleware({
  target: "https://www.termind.tech", changeOrigin: true, pathRewrite: { "^/pulse.js": "/t.js" },
}));

app.use("/v1", createProxyMiddleware({
  target: "https://www.termind.tech", changeOrigin: true,
}));
Apache, with mod_proxy enabled
SSLProxyEngine On
ProxyPreserveHost Off

ProxyPass        /pulse.js https://www.termind.tech/t.js
ProxyPassReverse /pulse.js https://www.termind.tech/t.js

ProxyPass        /v1/ https://www.termind.tech/v1/
ProxyPassReverse /v1/ https://www.termind.tech/v1/
Caddy
handle /pulse.js {
  rewrite * /t.js
  reverse_proxy https://www.termind.tech {
    header_up Host www.termind.tech
  }
}

handle /v1/* {
  reverse_proxy https://www.termind.tech {
    header_up Host www.termind.tech
  }
}
Cloudflare Worker
export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/pulse.js") {
      return fetch("https://www.termind.tech/t.js", request);
    }
    if (url.pathname.startsWith("/v1/")) {
      return fetch("https://www.termind.tech" + url.pathname + url.search, request);
    }
    return fetch(request);
  },
};
Laravel — routes/web.php
Route::get('/pulse.js', fn () =>
    response(Http::get('https://www.termind.tech/t.js')->body())
        ->header('Content-Type', 'application/javascript'));

Route::any('/v1/{path}', fn (Request $request, $path) =>
    Http::withBody($request->getContent(), 'application/json')
        ->post("https://www.termind.tech/v1/{$path}"))->where('path', '.*');
Flask
import requests
from flask import Response, request

@app.route("/pulse.js")
def pulse():
    upstream = requests.get("https://www.termind.tech/t.js")
    return Response(upstream.content, mimetype="application/javascript")

@app.route("/v1/<path:path>", methods=["GET", "POST"])
def collect(path):
    upstream = requests.post(
        f"https://www.termind.tech/v1/{path}",
        data=request.get_data(),
        headers={"Content-Type": "application/json",
                 "User-Agent": request.headers.get("User-Agent", "")},
    )
    return Response(upstream.content, upstream.status_code)
FastAPI
import httpx
from fastapi import Request, Response

@app.get("/pulse.js")
async def pulse():
    async with httpx.AsyncClient() as client:
        upstream = await client.get("https://www.termind.tech/t.js")
    return Response(upstream.content, media_type="application/javascript")

@app.post("/v1/{path:path}")
async def collect(path: str, request: Request):
    async with httpx.AsyncClient() as client:
        upstream = await client.post(
            f"https://www.termind.tech/v1/{path}",
            content=await request.body(),
            headers={"Content-Type": "application/json",
                     "User-Agent": request.headers.get("user-agent", "")},
        )
    return Response(upstream.content, upstream.status_code)

Forward the visitor's User-Agent header when you proxy from a server language. It is what fills in the browser and system panels, and a proxy that drops it leaves those blank.

If you have no server to add rewrites to

A purely static site with no rewrite support gets the same result through DNS instead. You point a subdomain of your own site at us, we issue the certificate, and the script serves from your hostname. The Managed tracking domain page has the steps.

Rewrites are the better option when you have the choice. They need no DNS change and no certificate, and they cannot break later because of a DNS edit made for some other reason.

Or just allow our domain in your CSP

If you would rather not proxy anything, add Termind to your Content Security Policy directly. This fixes the CSP problem but not the ad blocker one, so you will still lose the visitors who run a blocker.

Content-Security-Policy
script-src  'self' ... https://www.termind.tech
connect-src 'self' ... https://www.termind.tech

Do not skip connect-src. With only script-src the tag loads and the console stays clean, but every event is blocked on the way back. The dashboard stays empty and nothing tells you why.

Your CSP might live in your framework middleware, your hosting config such as vercel.json, netlify.toml or _headers, your web server config, or a meta tag in your HTML head. Search your project for the words Content-Security-Policy to find which.

Not sure whether you have one at all? Open your site, press F12, and read the Console. A CSP problem always uses the word violates and names the directive that refused.

NextManaged tracking domainOne CNAME, and the script serves from your own domain. No server needed.