How Pirsch Works
Pirsch is a cookieless web analytics platform written in Go that tracks visitors without storing any persistent identifiers. Instead of cookies or localStorage tokens, Pirsch generates a daily fingerprint by hashing the visitor's IP address, User-Agent string, and a date-based salt on the server side. The hash rotates every 24 hours at midnight (UTC), making it impossible to track a visitor across days. The raw IP address is never stored.
The data flow works like this: a visitor loads a page, the pirsch.js script (under 1 KB gzipped) sends a POST request to api.pirsch.io/api/v1/hit containing the page URL, referrer, screen resolution, and UTM parameters. The Pirsch server receives the request, extracts the visitor's IP and User-Agent from the HTTP headers, generates the daily fingerprint hash, and increments counters in PostgreSQL. Because all identification happens server-side, there is nothing stored on the client -- no cookies, no localStorage, no fingerprinting JavaScript.
This architecture means Pirsch does not require cookie consent banners under GDPR, CCPA, or the ePrivacy Directive. The European Data Protection Board considers hashed, daily-rotating identifiers that cannot be linked back to individuals as non-personal data when the raw IP is discarded immediately after hashing.
Pirsch is available as a managed cloud service (hosted in Germany on Hetzner) or as an open-source self-hosted binary backed by PostgreSQL.
Installing Pirsch
Client-Side Script
Add the tracking script to your <head>. The data-code value is the identification code from your Pirsch dashboard under Settings > Integration.
Pageview-only tracking:
<script defer src="https://api.pirsch.io/pirsch.js"
id="pirschjs"
data-code="YOUR_IDENTIFICATION_CODE"></script>
Pageview + event tracking (extended script):
<script defer src="https://api.pirsch.io/pirsch-extended.js"
id="pirschextendedjs"
data-code="YOUR_IDENTIFICATION_CODE"></script>
The extended script adds the global pirsch() function for custom event tracking. It is approximately 1.5 KB gzipped.
Script Configuration Attributes
<script defer src="https://api.pirsch.io/pirsch-extended.js"
id="pirschextendedjs"
data-code="YOUR_CODE"
data-dev="localhost,staging.example.com"
data-disable-outbound-links="true"
data-hit-endpoint="https://your-proxy.example.com/api/v1/hit"
data-event-endpoint="https://your-proxy.example.com/api/v1/event"></script>
| Attribute | Purpose | Default |
|---|---|---|
data-code |
Identification code (required) | -- |
data-dev |
Comma-separated hostnames to exclude from tracking | -- |
data-disable-outbound-links |
Stop automatic outbound link click tracking | false |
data-hit-endpoint |
Override the pageview API endpoint (for proxying) | https://api.pirsch.io/api/v1/hit |
data-event-endpoint |
Override the event API endpoint (for proxying) | https://api.pirsch.io/api/v1/event |
Server-Side Tracking (Go SDK)
Server-side tracking bypasses ad blockers entirely because the hit is sent from your backend, not the visitor's browser. Install the Go SDK:
go get github.com/pirsch-analytics/pirsch-go-sdk/v2
package main
import (
"net/http"
pirsch "github.com/pirsch-analytics/pirsch-go-sdk/v2"
)
var client = pirsch.NewClient("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET", nil)
func handler(w http.ResponseWriter, r *http.Request) {
// Forward the hit to Pirsch -- pass the original request
// so Pirsch can extract IP and User-Agent from headers
if err := client.Hit(r); err != nil {
log.Printf("pirsch hit error: %v", err)
}
w.Write([]byte("OK"))
}
The SDK authenticates using OAuth2 client credentials. Create a client ID and secret in Pirsch dashboard under Settings > Integration > Access Clients.
Server-Side Tracking (Node.js)
npm install pirsch-sdk
const { Pirsch } = require('pirsch-sdk');
const client = new Pirsch({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET'
});
// Express middleware
app.use(async (req, res, next) => {
try {
await client.hit({
url: `https://example.com${req.path}`,
ip: req.ip,
user_agent: req.headers['user-agent'],
accept_language: req.headers['accept-language'],
referrer: req.headers['referer'] || ''
});
} catch (err) {
console.error('pirsch hit failed:', err.message);
}
next();
});
Server-Side Tracking (HTTP API)
If no SDK exists for your language, send hits directly via the REST API:
# Step 1: Get an access token
curl -X POST https://api.pirsch.io/api/v1/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}'
# Returns: {"access_token": "eyJhb..."}
# Step 2: Send a pageview hit
curl -X POST https://api.pirsch.io/api/v1/hit \
-H "Authorization: Bearer eyJhb..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"accept_language": "en-US,en;q=0.9",
"referrer": "https://google.com"
}'
The access token expires after 30 minutes. Cache it and refresh before expiry.
Events and Goals
Events require the extended script (pirsch-extended.js) on the client side, or the events API endpoint on the server side.
Client-Side Events
// Basic event
pirsch('Signup Button Click');
// Event with metadata (key-value pairs, values must be strings)
pirsch('Purchase Complete', {
meta: {
plan: 'Pro Annual',
value: '149.99',
currency: 'USD',
payment_method: 'stripe'
}
});
// Track duration (pass duration in seconds)
pirsch('Video Watch', {
duration: 245,
meta: {
video_id: 'onboarding-tour',
completed: 'true'
}
});
Metadata values are always strings. Pirsch stores them as-is and lets you filter by metadata key/value pairs in the dashboard.
Server-Side Events (Go)
err := client.Event(r, "Purchase Complete", 0, map[string]string{
"plan": "Pro Annual",
"value": "149.99",
"currency": "USD",
})
Server-Side Events (HTTP API)
curl -X POST https://api.pirsch.io/api/v1/event \
-H "Authorization: Bearer eyJhb..." \
-H "Content-Type: application/json" \
-d '{
"event_name": "Purchase Complete",
"url": "https://example.com/checkout/success",
"ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 ...",
"event_meta": {
"plan": "Pro Annual",
"value": "149.99"
}
}'
Goals
Goals in Pirsch are pattern-based conversion tracking rules defined in the dashboard. You can create goals based on:
- Page visits: Visitor reaches
/checkout/success - Custom events: The event
Purchase Completefires - Event metadata: The event
Signupfires withplanequal toEnterprise - Path patterns: Any URL matching
/blog/*is visited
Goals calculate conversion rates automatically by comparing the number of goal completions to total visitors in the selected date range. Define goals under Dashboard > Goals.
Dashboard Metrics
Pirsch collects the following metrics without cookies:
| Metric | Source | Notes |
|---|---|---|
| Unique visitors | Daily fingerprint hash (IP + UA + salt) | Resets at midnight UTC; same visitor on two days counts as two |
| Page views | One per pirsch.js POST or server-side /hit call |
SPA route changes need manual pirsch.hit() calls |
| Sessions | Inferred from sequential hits within 15-minute windows | No cookie means sessions reset if visitor closes and reopens the browser |
| Bounce rate | Single-pageview sessions / total sessions | |
| Session duration | Time between first and last hit in a session | |
| Entry pages | First page URL in each session | |
| Exit pages | Last page URL in each session | |
| Referrers | Referer HTTP header or ref/utm_source query parameter |
|
| UTM parameters | utm_source, utm_medium, utm_campaign, utm_content, utm_term |
Parsed from the page URL |
| Country / Region | GeoIP lookup from visitor IP (MaxMind) | IP discarded after geolocation |
| Browser / OS | Parsed from User-Agent string | |
| Screen resolution | Reported by pirsch.js via screen.width x screen.height |
Not available with server-side tracking |
| Device type | Inferred from User-Agent (desktop, mobile, tablet) |
API Reference
Pirsch exposes a REST API for reading analytics data programmatically. Authenticate with the same OAuth2 client credentials used for server-side tracking.
Get Visitors Over Time
curl "https://api.pirsch.io/api/v1/statistics/visitor?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
-H "Authorization: Bearer eyJhb..."
Returns daily visitor and pageview counts:
[
{"day": "2026-03-01", "visitors": 1243, "views": 3891, "sessions": 1587, "bounces": 412},
{"day": "2026-03-02", "visitors": 1102, "views": 3456, "sessions": 1401, "bounces": 389}
]
Get Top Pages
curl "https://api.pirsch.io/api/v1/statistics/page?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
-H "Authorization: Bearer eyJhb..."
Get Referrers
curl "https://api.pirsch.io/api/v1/statistics/referrer?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID" \
-H "Authorization: Bearer eyJhb..."
Get Event Data
curl "https://api.pirsch.io/api/v1/statistics/event?from=2026-03-01&to=2026-03-05&id=YOUR_DOMAIN_ID&event_name=Purchase%20Complete" \
-H "Authorization: Bearer eyJhb..."
Filtering
All statistics endpoints accept these query parameters:
| Parameter | Type | Description |
|---|---|---|
from |
YYYY-MM-DD |
Start date (required) |
to |
YYYY-MM-DD |
End date (required) |
id |
string | Domain ID from dashboard (required) |
path |
string | Filter by URL path |
country |
string | ISO 3166-1 alpha-2 country code |
referrer |
string | Filter by referrer domain |
os |
string | Filter by operating system |
browser |
string | Filter by browser name |
language |
string | Filter by visitor language |
event_name |
string | Filter by event name (events endpoint only) |
event_meta_key |
string | Filter by metadata key (events endpoint only) |
SPA (Single-Page Application) Support
Pirsch does not automatically detect client-side route changes. For React, Vue, Next.js, or any SPA, you need to manually trigger pageview hits on navigation.
// React with react-router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function usePirschPageview() {
const location = useLocation();
useEffect(() => {
if (typeof pirsch !== 'undefined' && pirsch.hit) {
pirsch.hit();
}
}, [location.pathname]);
}
// Next.js (pages router)
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export default function App({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
const handleRouteChange = () => {
if (typeof pirsch !== 'undefined' && pirsch.hit) {
pirsch.hit();
}
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => router.events.off('routeChangeComplete', handleRouteChange);
}, [router.events]);
return <Component {...pageProps} />;
}
Self-Hosting
Pirsch is open source and can be self-hosted with Docker:
docker run -d \
--name pirsch \
-p 8080:8080 \
-e PIRSCH_DB_HOST=postgres \
-e PIRSCH_DB_PORT=5432 \
-e PIRSCH_DB_USER=pirsch \
-e PIRSCH_DB_PASSWORD=secret \
-e PIRSCH_DB_NAME=pirsch \
-e PIRSCH_DB_SSL_MODE=disable \
-e PIRSCH_SECRET=your-random-secret-key \
pirsch/server:latest
Requirements: PostgreSQL 12+ with at least 1 GB RAM for the database. The Pirsch server binary uses approximately 50 MB of RAM. For sites under 1 million pageviews/month, a single 1-vCPU server handles both the application and database.
Proxying to Bypass Ad Blockers
Ad blockers that block api.pirsch.io will prevent client-side tracking. Proxy the Pirsch endpoints through your own domain to avoid this:
Nginx:
location /p/hit {
proxy_pass https://api.pirsch.io/api/v1/hit;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}
location /p/event {
proxy_pass https://api.pirsch.io/api/v1/event;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
}
location /p/pirsch.js {
proxy_pass https://api.pirsch.io/pirsch-extended.js;
}
Then update your script tag to use the proxied paths:
<script defer src="/p/pirsch.js"
id="pirschextendedjs"
data-code="YOUR_CODE"
data-hit-endpoint="/p/hit"
data-event-endpoint="/p/event"></script>
The X-Forwarded-For header is critical -- without it, Pirsch will see your server's IP for every visitor instead of the actual visitor IP.
Common Errors
| Error | Cause | Fix |
|---|---|---|
| No data in dashboard after installing script | data-code does not match the identification code in Pirsch settings, or ad blocker is blocking api.pirsch.io |
Verify the code matches Settings > Integration; test in an incognito window with extensions disabled |
| All visitors show the same country | Proxy or CDN stripping the real visitor IP; X-Forwarded-For not set |
Configure your reverse proxy to pass X-Forwarded-For with the client IP; for Cloudflare, Pirsch reads CF-Connecting-IP automatically |
| Server-side SDK returns 401 Unauthorized | Access token expired (tokens last 30 minutes) or client credentials are wrong | Refresh the token before expiry; verify client ID and secret in Settings > Integration > Access Clients |
| Events not appearing in dashboard | Using pirsch.js instead of pirsch-extended.js, or event metadata values are not strings |
Switch to the extended script; convert all metadata values to strings ('149.99' not 149.99) |
| Duplicate pageviews on SPA | pirsch.js fires on initial load and again on route change without deduplication |
Call pirsch.hit() only on routeChangeComplete, not on initial mount (the script already tracks the first load) |
| Session count much higher than unique visitors | Cookieless tracking cannot link sessions across browser restarts; each new browser session generates a new session | This is expected behavior; Pirsch sessions are more conservative than cookie-based platforms |
| Bounce rate unusually high | SPA not sending subsequent hits on route changes, so every visit appears as a single-page session | Implement manual pirsch.hit() calls on client-side navigation |
| Self-hosted instance returning 500 errors | PostgreSQL connection failed or migrations not applied | Check PIRSCH_DB_* environment variables; run pirsch migrate to apply database schema |
| Real-time counter always shows 0 | Using server-side tracking without the pirsch.js script; real-time requires the client-side script |
Add the client-side script alongside server-side tracking, or accept that server-side-only tracking does not support real-time |
| Data discrepancy vs. server access logs | Pirsch deduplicates by daily fingerprint; access logs count every request including bots | Pirsch filters known bots via User-Agent matching; the lower Pirsch number is typically more accurate for human visitors |