Documentation
Need to read analytics data or manage sites programmatically? See the API reference — the same REST API that powers our mobile app.
-
1Add your site
Sign in, open the Dashboard, and click "Add site". Enter the domain you want to track (for example, mydomain.com).
-
2Install the snippet
Copy the snippet from your site's menu and paste it inside the <head> of every page you want to track. It is the same snippet shown here, with your domain filled in:
html
<script defer src="https://pennymetrics.dev/stats.js" data-hostname="mydomain.com" ></script> -
3Watch the data arrive
Deploy your change and visit your site. Within a few seconds the first pageview appears on your analytics dashboard. That's it!
Configure the tracker with data attributes on the script tag.
|
Attribute
|
Required
|
Description
|
|---|---|---|
data-hostname
|
No | The domain to attribute traffic to. Defaults to the current window hostname. Set this when serving the same code from multiple domains or from localhost. |
data-endpoint
|
No | The ingest URL for event delivery. Defaults to api/i.gif on the script host. Set this to a first-party path when proxying through your own domain (see Ad blockers). |
data-debug
|
No | Logs every payload to the browser console instead of sending silently. Handy while verifying your install locally. |
Track conversions like sign-ups, purchases, or downloads. There are two ways to send an event.
Add data-pm-event to any element. The event is sent automatically when the element is clicked.
html
<button data-pm-event="Signup click">Sign up</button>
Attach extra properties with data-pm-event-props. Separate pairs with a semicolon and keys from values with an equals sign:
html
<button
data-pm-event="Signup click"
data-pm-event-props="plan=pro;period=monthly"
>
Sign up
</button>
Call window.pennymetrics.event() for anything that isn't a simple click. The signature is event(name, path?, props?):
javascript
window.pennymetrics.event("Signup click", "/dashboard/[userId]/settings", {
plan: "pro",
logged_in: "true",
});
Forward custom events (conversions) to external tools in real time — Zapier, Make, Slack, your CRM, or any HTTP endpoint. Each site can have multiple webhook endpoints.
Open a site in your dashboard.
Go to Actions → Webhooks.
Add an endpoint URL, optional signing secret, and choose whether to include pageviews.
Use Send test event to verify delivery before going live.
You can also manage webhooks programmatically — see the webhook API endpoints.
Custom events are always forwarded. Pageviews are off by default but can be enabled per webhook. Disabled webhooks are skipped until turned back on.
Deliveries are JSON POST requests queued in the background. Your endpoint should respond with a 2xx status code.
json
{
"id": 42,
"type": "event",
"name": "Signup click",
"site": {
"id": "9b3f1c2a-…",
"domain": "example.com"
},
"path": "/pricing",
"props": { "plan": "pro" },
"utm_source": "newsletter",
"country": "DE",
"timestamp": "2026-06-26T14:00:00+00:00"
}
When a signing secret is configured, each request includes a Penny-Metrics-Signature header. Verify it with HMAC-SHA256 over "{timestamp}.{raw_json_body}" using your secret. The header format is t={unix_timestamp},v1={hex_digest}.
The tracker hooks into the History API, so client-side navigations (pushState / replaceState / back & forward) are counted as pageviews automatically. React Router, Vue Router, Livewire wire:navigate, and similar libraries work out of the box.
If you navigate in some other way and need to record a pageview manually, call:
javascript
window.pennymetrics.pageview();
Some visitors use ad blockers or privacy extensions that block third-party analytics scripts and network requests. The tracker is designed to be as reliable as possible without cookies or fingerprinting.
By default, stats.js sends events through a 1×1 image request (an "image beacon") instead of fetch or sendBeacon. Blockers are far less likely to interfere with image loads than with XHR-style calls.
The ingest URL is https://pennymetrics.dev/api/i.gif with the payload base64url-encoded in the d query parameter.
The most effective way to avoid script and request blocking is to serve the tracker from your own domain. Proxy these paths on your site to pennymetrics.dev:
/pm/stats.js→https://pennymetrics.dev/stats.js/pm/i.gif→https://pennymetrics.dev/api/i.gif
Then install the snippet with first-party paths:
html
<script
defer
src="/pm/stats.js"
data-hostname="mydomain.com"
data-endpoint="/pm/i.gif"
></script>
Next.js config rewrites do not forward these headers — use the proxy example below instead.
A reverse proxy forwards the client request headers automatically. Set Host to pennymetrics.dev and add X-PM-Client-IP so visitor geolocation survives the hop to pennymetrics.dev.
nginx
location /pm/stats.js {
proxy_pass https://pennymetrics.dev/stats.js;
proxy_set_header Host pennymetrics.dev;
proxy_set_header X-PM-Client-IP $http_cf_connecting_ip;
}
location /pm/i.gif {
proxy_pass https://pennymetrics.dev/api/i.gif;
proxy_set_header Host pennymetrics.dev;
proxy_set_header X-PM-Client-IP $http_cf_connecting_ip;
}
Enable mod_proxy and mod_proxy_http. If the upstream URL uses HTTPS, also enable SSLProxyEngine On. mod_proxy forwards client headers by default — add X-PM-Client-IP from your CDN:
apache
SSLProxyEngine On
ProxyPass /pm/stats.js https://pennymetrics.dev/stats.js
ProxyPassReverse /pm/stats.js https://pennymetrics.dev/stats.js
ProxyPass /pm/i.gif https://pennymetrics.dev/api/i.gif
ProxyPassReverse /pm/i.gif https://pennymetrics.dev/api/i.gif
RequestHeader set X-PM-Client-IP "%{CF-Connecting-IP}i"
javascript
const INGEST = "https://pennymetrics.dev/api/i.gif";
const SCRIPT = "https://pennymetrics.dev/stats.js";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(request) {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!STRIP_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("CF-Connecting-IP") ??
request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ??
request.headers.get("X-Real-IP");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/pm/i.gif") {
const target = new URL(INGEST);
target.search = url.search;
return fetch(target.toString(), { method: "GET", headers: upstreamHeaders(request) });
}
if (url.pathname === "/pm/stats.js") {
return fetch(SCRIPT, { headers: upstreamHeaders(request) });
}
return new Response("Not found", { status: 404 });
},
};
Use proxy.ts (Next.js 16+) or middleware.ts so the visitor's headers are forwarded. Config rewrites proxy from your deployment region and every visitor will appear to come from that location.
typescript
// proxy.ts (Next.js 16+) or src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
const SCRIPT_URL = "https://pennymetrics.dev/stats.js";
const PIXEL_URL = "https://pennymetrics.dev/api/i.gif";
function upstreamHeaders(request: NextRequest): HeadersInit {
const strip = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!strip.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl;
if (pathname === "/pm/stats.js") {
const upstream = await fetch(SCRIPT_URL, { headers: upstreamHeaders(request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") ?? "application/javascript",
},
});
}
if (pathname === "/pm/i.gif") {
const url = new URL(PIXEL_URL);
url.search = search;
const upstream = await fetch(url, { headers: upstreamHeaders(request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "image/gif" },
});
}
return NextResponse.next();
}
export const config = {
matcher: ["/pm/stats.js", "/pm/i.gif"],
};
Forward the two paths in your Node server — no extra packages required:
javascript
const express = require("express");
const app = express();
const SCRIPT_URL = "https://pennymetrics.dev/stats.js";
const PIXEL_URL = "https://pennymetrics.dev/api/i.gif";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(req) {
const headers = {};
for (const [key, value] of Object.entries(req.headers)) {
if (value && !STRIP_HEADERS.has(key.toLowerCase())) {
headers[key] = Array.isArray(value) ? value.join(", ") : value;
}
}
const clientIp =
req.headers["cf-connecting-ip"] ??
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ??
req.headers["x-real-ip"] ??
req.socket.remoteAddress;
if (clientIp) {
headers["X-PM-Client-IP"] = clientIp;
}
return headers;
}
app.get("/pm/stats.js", async (req, res) => {
const upstream = await fetch(SCRIPT_URL, { headers: upstreamHeaders(req) });
res.status(upstream.status);
res.setHeader("Content-Type", upstream.headers.get("content-type") ?? "application/javascript");
res.send(Buffer.from(await upstream.arrayBuffer()));
});
app.get("/pm/i.gif", async (req, res) => {
const url = new URL(PIXEL_URL);
url.search = new URL(req.url, "http://localhost").search;
const upstream = await fetch(url, { headers: upstreamHeaders(req) });
res.status(upstream.status);
res.setHeader("Content-Type", upstream.headers.get("content-type") ?? "image/gif");
res.send(Buffer.from(await upstream.arrayBuffer()));
});
Built-in routeRules proxy does not forward visitor headers. Use a Nitro server route instead:
javascript
// server/routes/pm/i.gif.ts
const PIXEL_URL = "https://pennymetrics.dev/api/i.gif";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(request: Request): HeadersInit {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!STRIP_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export default defineEventHandler(async (event) => {
const url = new URL(PIXEL_URL);
url.search = new URL(event.node.req.url ?? "", "http://localhost").search;
const upstream = await fetch(url, { headers: upstreamHeaders(event.node.req as unknown as Request) });
setResponseStatus(event, upstream.status);
setHeader(event, "Content-Type", upstream.headers.get("content-type") ?? "image/gif");
return upstream.arrayBuffer();
});
Add a matching server/routes/pm/stats.js.ts route for the script.
Add resource routes that forward to pennymetrics.dev. Brackets escape literal dots in filenames — without them, pm.stats.js.ts would map to /pm/stats/js:
javascript
// app/routes/pm[.]stats[.]js.ts
import type { LoaderFunctionArgs } from "@remix-run/node";
const SCRIPT_URL = "https://pennymetrics.dev/stats.js";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(request: Request): HeadersInit {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!STRIP_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export async function loader({ request }: LoaderFunctionArgs) {
const upstream = await fetch(SCRIPT_URL, { headers: upstreamHeaders(request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/javascript" },
});
}
javascript
// app/routes/pm[.]i[.]gif.ts
import type { LoaderFunctionArgs } from "@remix-run/node";
const PIXEL_URL = "https://pennymetrics.dev/api/i.gif";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(request: Request): HeadersInit {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!STRIP_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(PIXEL_URL);
url.search = new URL(request.url).search;
const upstream = await fetch(url, { headers: upstreamHeaders(request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "image/gif" },
});
}
Use middleware to proxy in SSR mode (Node, Vercel, Netlify, and similar adapters):
javascript
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
const SCRIPT_URL = "https://pennymetrics.dev/stats.js";
const PIXEL_URL = "https://pennymetrics.dev/api/i.gif";
const STRIP_HEADERS = new Set([
"host", "connection", "content-length", "content-encoding",
"transfer-encoding", "keep-alive", "proxy-authorization", "te", "trailers", "upgrade", "cookie",
]);
function upstreamHeaders(request: Request): HeadersInit {
const headers = new Headers();
for (const [key, value] of request.headers.entries()) {
if (!STRIP_HEADERS.has(key.toLowerCase())) {
headers.set(key, value);
}
}
const clientIp =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip");
if (clientIp) {
headers.set("X-PM-Client-IP", clientIp);
}
return headers;
}
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname, search } = new URL(context.request.url);
if (pathname === "/pm/stats.js") {
const upstream = await fetch(SCRIPT_URL, { headers: upstreamHeaders(context.request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/javascript" },
});
}
if (pathname === "/pm/i.gif") {
const url = new URL(PIXEL_URL);
url.search = search;
const upstream = await fetch(url, { headers: upstreamHeaders(context.request) });
return new Response(await upstream.arrayBuffer(), {
status: upstream.status,
headers: { "Content-Type": upstream.headers.get("content-type") ?? "image/gif" },
});
}
return next();
});
Proxy through the dev server while working locally. For production, use one of the options above.
javascript
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/pm/stats.js": {
target: "https://pennymetrics.dev",
changeOrigin: true,
rewrite: () => "/stats.js",
},
"/pm/i.gif": {
target: "https://pennymetrics.dev",
changeOrigin: true,
rewrite: () => "/api/i.gif",
},
},
},
});
Add routes that forward to Penny Metrics — useful when your marketing site already runs Laravel:
php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
function pennyMetricsUpstreamHeaders(Request $request): array
{
$strip = [
'host', 'connection', 'content-length', 'content-encoding',
'transfer-encoding', 'keep-alive', 'cookie',
];
$headers = [];
foreach ($request->headers->all() as $key => $values) {
if (! in_array(strtolower($key), $strip, true)) {
$headers[$key] = $values[0] ?? '';
}
}
$clientIp = $request->header('CF-Connecting-IP')
?: trim(explode(',', (string) $request->header('X-Forwarded-For'))[0] ?: '')
?: $request->ip();
if ($clientIp) {
$headers['X-PM-Client-IP'] = $clientIp;
}
return $headers;
}
Route::get('/pm/stats.js', function (Request $request) {
$response = Http::withHeaders(pennyMetricsUpstreamHeaders($request))
->get('https://pennymetrics.dev/stats.js');
return response($response->body(), $response->status())
->header('Content-Type', $response->header('Content-Type') ?? 'application/javascript');
});
Route::get('/pm/i.gif', function (Request $request) {
$response = Http::withHeaders(pennyMetricsUpstreamHeaders($request))
->get('https://pennymetrics.dev/api/i.gif', $request->query());
return response($response->body(), $response->status())
->header('Content-Type', $response->header('Content-Type') ?? 'image/gif');
});
Add a controller that forwards to pennymetrics.dev — useful when your marketing site already runs Symfony:
php
// src/Controller/PennyMetricsProxyController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class PennyMetricsProxyController extends AbstractController
{
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
#[Route('/pm/stats.js', name: 'pennymetrics_proxy_script', methods: ['GET'])]
public function script(Request $request): Response
{
$response = $this->httpClient->request('GET', 'https://pennymetrics.dev/stats.js', [
'headers' => $this->upstreamHeaders($request),
]);
return new Response(
$response->getContent(),
$response->getStatusCode(),
['Content-Type' => $response->getHeaders(false)['content-type'][0] ?? 'application/javascript'],
);
}
#[Route('/pm/i.gif', name: 'pennymetrics_proxy_pixel', methods: ['GET'])]
public function pixel(Request $request): Response
{
$response = $this->httpClient->request('GET', 'https://pennymetrics.dev/api/i.gif', [
'query' => $request->query->all(),
'headers' => $this->upstreamHeaders($request),
]);
return new Response(
$response->getContent(),
$response->getStatusCode(),
['Content-Type' => $response->getHeaders(false)['content-type'][0] ?? 'image/gif'],
);
}
/**
* @return array<string, string>
*/
private function upstreamHeaders(Request $request): array
{
$strip = [
'host', 'connection', 'content-length', 'content-encoding',
'transfer-encoding', 'keep-alive', 'cookie',
];
$headers = [];
foreach ($request->headers->all() as $key => $values) {
if (! in_array(strtolower($key), $strip, true)) {
$headers[$key] = $values[0] ?? '';
}
}
$clientIp = $request->headers->get('CF-Connecting-IP') ?? $request->getClientIp();
if ($clientIp) {
$headers['X-PM-Client-IP'] = $clientIp;
}
return $headers;
}
}
The tracker is designed to be compliant by default:
No cookies or other persistent identifiers are stored on visitors' devices.
Visitors are counted using a hash of a daily-rotating salt, the site, the IP address, and the user agent. Raw IP addresses are never stored.
Because the salt rotates every day, visitors cannot be tracked across days, and the hash cannot be reversed.
Known bots and crawlers are detected and discarded.
For GDPR guidance, see our GDPR page.
stats.js delivers events via a GET image beacon. Custom integrations may also POST JSON directly. You normally never need this, but it is documented here for server-side or manual testing.
bash
curl "https://pennymetrics.dev/api/i.gif?d=$(printf '%s' '{
"type": "pageview",
"hostname": "mydomain.com",
"path": "/pricing",
"query": "?utm_source=newsletter",
"referrer": "https://news.ycombinator.com/"
}' | base64 | tr '+/' '-_' | tr -d '=')"
bash
curl -X POST https://pennymetrics.dev/api/collect \
-H "Content-Type: text/plain" \
-d '{
"type": "pageview",
"hostname": "mydomain.com",
"path": "/pricing",
"query": "?utm_source=newsletter",
"referrer": "https://news.ycombinator.com/"
}'
|
Field
|
Type
|
Description
|
|---|---|---|
type
|
Either "pageview" or "event". | |
name
|
Event name. Required when type is "event". | |
hostname
|
The domain the hit belongs to. Must match a registered site. | |
path
|
The page path, e.g. /pricing. | |
query
|
The query string. UTM parameters are parsed from it. | |
referrer
|
The full referring URL. Self-referrals are dropped. | |
props
|
Optional key/value pairs of scalar values (events only). |
Point your coding agent at Penny Metrics so it installs the tracker correctly — with a first-party proxy, the right script attributes, and framework-specific routing.
Agents can read https://pennymetrics.dev/llms.txt for a compact integration guide with proxy snippets.
Install the integration skill once per project. Your agent will apply it when you ask to add Penny Metrics or website analytics.
bash
mkdir -p .cursor/skills/pennymetrics-integration
curl -fsSL https://pennymetrics.dev/skills/pennymetrics-integration/SKILL.md \
-o .cursor/skills/pennymetrics-integration/SKILL.md
Claude Code users: use .claude/skills/pennymetrics-integration/ instead of .cursor/skills/.
Paste this into your agent chat:
text
Read https://pennymetrics.dev/llms.txt and integrate Penny Metrics into this project.
Use a first-party proxy at /pm/stats.js and /pm/i.gif.
My registered domain is: example.com
Add the data-debug attribute to the script and reload the page. You should see the payload logged in the console. Confirm the data-hostname exactly matches the domain you registered, and that the snippet is in the <head>.
Set data-hostname to your real registered domain while testing locally, otherwise hits are attributed to "localhost" and dropped as an unknown site.
That is expected during development if you block analytics yourself. For production, proxy the script and ingest endpoint through your own domain — see Ad blockers.
Browse with the tracker disabled during development, or use a separate browser profile without the snippet installed.