Penny Metrics
Documentation

Documentation

Need to read analytics data or manage sites programmatically? See the API reference — the same REST API that powers our mobile app.

Quick start
  1. 1
    Add your site

    Sign in, open the Dashboard, and click "Add site". Enter the domain you want to track (for example, mydomain.com).

  2. 2
    Install 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>
  3. 3
    Watch 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!

Use the bare domain
The data-hostname should be your registered domain without a scheme or path. A "www." prefix is matched automatically, so mydomain.com also collects traffic from www.mydomain.com.
Script options

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.
Custom events

Track conversions like sign-ups, purchases, or downloads. There are two ways to send an event.

1. With an HTML attribute

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>
2. With JavaScript

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",
});
The optional second argument overrides the page path — useful for merging dynamic routes such as /users/123 into /users/[id]. Property values are trimmed and stored as strings.
Webhooks

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.

Setup
  1. Open a site in your dashboard.

  2. Go to Actions → Webhooks.

  3. Add an endpoint URL, optional signing secret, and choose whether to include pageviews.

  4. Use Send test event to verify delivery before going live.

You can also manage webhooks programmatically — see the webhook API endpoints.

What gets sent

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.

Payload example

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"
}
Verifying signatures

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}.

Single-page apps

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();
Ad blockers

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.

Image beacon delivery

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.

First-party proxy

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.jshttps://pennymetrics.dev/stats.js

  • /pm/i.gifhttps://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>
Forward the visitor IP
Country is resolved from the visitor IP via a geo lookup service. When you proxy server-side, forward the incoming request headers to the ingest URL (strip Host, Cookie, and other hop-by-hop headers) so User-Agent, CF-Connecting-IP, and CF-IPCountry pass through from your CDN. Also set X-PM-Client-IP to the visitor address — Cloudflare on pennymetrics.dev may rewrite standard forwarding headers on the server-to-server hop.

Next.js config rewrites do not forward these headers — use the proxy example below instead.

nginx

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;
}
Apache

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"
Cloudflare Worker

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 });
  },
};
Next.js

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"],
};
Express

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()));
});
Nuxt

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.

Remix

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" },
  });
}
Astro

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();
});
Vite

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",
      },
    },
  },
});
Laravel

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');
});
Symfony

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;
    }
}
Not a guarantee
First-party delivery recovers most filter-list traffic, but visitors who deliberately block all analytics may still be invisible. That is expected — we do not try to circumvent intentional privacy choices.
Privacy

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.

Payload reference

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.

Image beacon (default)

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 '=')"
POST (custom integrations)

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
string
Either "pageview" or "event".
name
string
Event name. Required when type is "event".
hostname
string
The domain the hit belongs to. Must match a registered site.
path
string
The page path, e.g. /pricing.
query
string
The query string. UTM parameters are parsed from it.
referrer
string
The full referring URL. Self-referrals are dropped.
props
object
Optional key/value pairs of scalar values (events only).
Image beacons use GET with a base64url-encoded JSON payload in the d query parameter. POST bodies use text/plain so the browser never issues a CORS preflight. Country is resolved server-side from the visitor IP; browser, OS, and device are parsed from the user agent.
AI agents

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.

Quick reference

Agents can read https://pennymetrics.dev/llms.txt for a compact integration guide with proxy snippets.

Cursor skill

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/.

Without installing a skill

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
Troubleshooting
I don't see any data

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>.

Testing on localhost

Set data-hostname to your real registered domain while testing locally, otherwise hits are attributed to "localhost" and dropped as an unknown site.

Ad blockers hide my visits

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.

My own visits are counted

Browse with the tracker disabled during development, or use a separate browser profile without the snippet installed.