Documentation

Set up LocalPulse in a coffee break.

Install the snippet, set a target city, get pinged. The rest of the docs are for when you want to tune Slack scope, configure digests, or manage multi-location targets.

2 min · Quickstart v2.4 · current View on GitHub
Getting started

Overview

LocalPulse is a hosted analytics product built for agencies and local businesses. You install one snippet per client site, configure one or more target cities, and the dashboard surfaces every visit that matters with a Slack ping the moment a local prospect lands.

New here? The fastest path to a working install is Install the snippet Configure target locations → wait 60 seconds. The dashboard will start filling in.

How the pieces fit

The host site loads snippet.js on every page. Each pageview hits the track endpoint, which resolves the IP through ipinfo, classifies it against the client’s configured locations and ISP buckets, and writes a Visit or extends an existing session row. Slack and email come off the same pipeline.

host site  →  /api/track  →  geo + match  →  Visit row
                                          ↘ Slack webhook (if scope matches)
                                          ↘ digest scheduler (period close)

Install the snippet

From /dashboard/<client>/snippet, copy the generated tag and paste it before </body> on the client’s site. Each client gets a unique data-client key — never reuse one across sites.

<!-- paste before </body> -->
<script
  src="https://lp.app/s/9f2a.js"
  data-client="castle-rock-fitness"
  defer
></script>
The snippet uses navigator.sendBeacon first, with a fetch + keepalive fallback. Tab-closes on the way to a contact form still report the visit.

Verification

A background job fetches each site every 6 hours with a Chrome user-agent and looks for the snippet. The install status pill on the client list reads active as soon as real visits start landing — so a Cloudflare block doesn’t falsely show “broken”.

Configure target locations

A target is a city or region the client wants to win in. Most clients have one. Multi- location chains can have many — each visit gets tagged with whichever location it matched.

dashboard · client settings
Castle Rock Fitness └─ Target locations ├─ HQ — Castle Rock (city: Castle Rock, region: CO) ├─ Phoenix branch (city: Phoenix, region: AZ) └─ Denver gym (city: Denver, region: CO)

Match logic

  • City match wins over region.
  • If no city matches across any configured location, region match is the fallback.
  • If neither matches, the visit is non-local — but the row is still recorded.
  • On match, matchedLocationId is set; the dashboard breaks Local sessions down by branch.
Adding a new location after launch does not retroactively tag past visits. Run scripts/backfill-matched-location.ts to re-tag history. See Backfilling locations.

Your first visit

Once the snippet is on the page and at least one target is configured, the live feed will surface the next pageview. Median time-to-first-visit after install is about 8 seconds.

Curl-curl yourself a fake visit to verify the pipe end-to-end:
bash
curl -X POST https://lp.app/api/track \ -H 'Origin: https://castle-rock-fitness.com' \ -H 'Content-Type: application/json' \ -d '{"client":"castle-rock-fitness","path":"/","ts":1716480000000}'
Concepts

Sessions & returning visitors

A Visit row represents a session. The session window is 30 minutes — any pageview from the same visitorId within that window extends the existing row rather than creating a new one. Re-visits beyond the window create a new Visit and flip the “Returning” badge on the new row with the session count.

Why this matters

  • Slack stays sane. Only the first detection of a session pings. Subsequent pageviews don’t.
  • Counts are honest. A visitor refreshing four times in a minute is one session, not four.
  • Returning is signal. A row marked “Returning · 4×” is usually closer to converting than a fresh visit.

Local match logic

For each visit, the matcher walks the client’s ClientLocation rows in order and returns the first city hit. Falling back, it returns the first region hit. If neither, the visit is non-local.

lib/locations.ts · simplified
export function matchLocation(visit, locations) { const city = visit.geo.city?.toLowerCase(); const region = visit.geo.region?.toLowerCase(); // City wins const byCity = locations.find(l => l.targetCity && l.targetCity.toLowerCase() === city ); if (byCity) return { id: byCity.id, via: "city" }; // Region fallback const byRegion = locations.find(l => l.targetRegion && l.targetRegion.toLowerCase() === region ); if (byRegion) return { id: byRegion.id, via: "region" }; return null; }

ISP / organisation buckets

The classifier reads the org / ASN from ipinfo and tags the visit with one of five buckets:

business
Named companies, business-class ISP lines (e.g. Lumen Business, Comcast Business).
isp
Consumer ISPs — Comcast residential, Verizon FiOS, AT&T, etc.
hosting
Cloud providers, datacenters, hosting orgs — AWS, Azure, Hetzner.
education
University and school networks.
government
Gov / military ASNs.

VPN & Private Relay

~20 named VPN providers and iCloud Private Relay are detected by ASN and tagged on the Visit. The feed shows a shield badge with an explainer tooltip. Geo from these visits is unreliable — the dashboard still records the row, but local classification is treated as a maybe.

Integrations

Slack alerts

One webhook per client. Four scope modes:

  • off — never ping
  • local — only visits matched to a configured location
  • all — every session, first detection only
  • b2b — local OR ISP-bucket is business

Payload

The block-kit payload contains:

  • Header: matched location name + page title
  • Section: location, company (if known), device, dwell, source, keyword
  • Section: page journey (path + dwell, in order)
  • Action: “View in LocalPulse” deep link straight to the visit detail
bash · test the webhook
POST /api/clients/<id>/slack/test # fires a sample alert to the configured webhook # response: { ok: true, latencyMs: 142 }

Email digests

Configurable per client: cadence (daily / weekly / monthly), hour-of-day, day-of-week for weekly, day-of-month for monthly. Everything honors the client’s IANA timezone.

Digests are idempotent. Running /api/cron/digest twice in the same period is a no-op; missed periods get caught up on the next tick.

Identity capture

The snippet listens for submit events on the host site. For each input inside the form, the scorer assigns identity in priority order:

  1. input.typeemail, tel
  2. autocomplete attribute — given-name, family-name, email, tel, organization
  3. Hint string built from name + id + placeholder + aria-label + associated label text

Hint matching uses small word-boundary regexes per identity slot.

Refused fields

The following are skipped unconditionally — no override, not even for the agency:

  • password, new-password, current-password
  • Card number, CVV, expiry
  • SSN, government ID
  • Tokens, OTPs, MFA codes
Integrations

Backfilling locations

When you add a new target location to an existing client, past visits don’t move on their own — they were classified against the old target list. The dashboard surfaces a one-click Re-tag history action that walks the client’s visits and retags any that now match the new location.

The re-tag job runs in the background and emails you when it’s done. For a typical client (~50k visits) it finishes in well under a minute. Larger clients are batched across an hour to keep the database happy.

What it does

  • Re-runs the matcher against each visit using the client’s current target list.
  • Sets matchedLocationId on previously-untagged rows.
  • Flips isLocal to true on rows that now match a configured location.
  • Never un-tags a visit — it can only ever add or upgrade matches.

The action is idempotent: re-running it after you’ve added another location only writes the rows that changed.

Reference

Track API

The track endpoint is the only public surface. Everything else is dashboard-only.

POST /api/track
{ "client": "castle-rock-fitness", // snippet data-client value "visitorId": "v_a12c…", // localStorage on host "path": "/services/water-heater-repair", "referrer": "https://www.google.com/", "utm": { "source":"google", "medium":"cpc", "campaign":"plumbing-spring", "keyword":"plumber denver near me" }, "ts": 1716480000000, "tz": "America/Denver", "screen": { "w": 390, "h": 844, "dpr": 3 }, "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 …)" }

Response is 204 No Content. The endpoint is meant for fire-and-forget; the dashboard reads asynchronously.

Data model

The Postgres schema is intentionally small. The tables you'll see referenced everywhere else in the docs:

  • User — login accounts on the dashboard.
  • Client — one row per managed site, with per-client config (Slack scope, identity scope, digest schedule, timezone).
  • ClientLocation — target city / region rows per client. A client with two branches has two rows.
  • Visit — one row per session (extended within a 30-minute window).
  • PageView — per-page rows inside a Visit, with dwell.
  • VisitDailyStat — pre-aggregated daily rollups used for long-range analytics.
  • Alert — Slack delivery log, used as the idempotency record.
  • Identity — captured names / emails / phones, keyed on (clientId, visitorId).

Security & data

The short version: the snippet refuses to read sensitive fields, no third-party pixels are involved, and the raw visit data is retained for a bounded window.

What we don’t store

  • Sensitive form fields. The snippet skips inputs whose name / id / label matches password, CVV, card number, SSN / tax ID, tokens, secrets, auth keys, or CSRF — refused both client-side (in the snippet) and again server-side on the identify endpoint. They never reach the database.
  • Third-party pixels or cross-site IDs. The snippet talks only to our own track endpoint. There is no analytics-vendor pixel, no ad pixel, no shared cookie.
  • Visitor cookies on your client sites. Visitor ID lives in localStorage on the host domain only; nothing crosses domains.

What we do store

  • Visit rows with the resolved IP, geo, ISP / company, device, referrer, UTM parameters, page journey, and (when identified) name / email / phone.
  • Daily rollups in VisitDailyStat for fast long-range analytics.
  • Slack delivery log in Alert, used to dedupe pings so a returning session doesn't re-fire.
Raw Visit rows are kept for 365 days, then deleted by the retention job once the daily rollup has captured them. Aggregate rollups outlive the raw rows for long-range analytics.

Changelog

May 22, 2026
Multi-location targets, VPN-awareness, per-client timezones
  • Multi-location targeting — one ClientLocation row per branch, matchedLocationId stored on every Visit; the bundled scripts/backfill-matched-location.ts re-tags historical visits against the new targets.
  • Per-location filter on the dashboard scopes both the analytics charts and the visit feed to a single branch.
  • Per-client IANA timezone drives reporting windows, the hour-of-week heatmap, and the digest scheduler — agencies stop reading every chart in their own zone.
  • VPN / iCloud Private Relay hint on visits, with a shield badge and an explainer tooltip; new app-level error boundary so a transient render error doesn’t take the whole page.
  • Database overhaul: SQL-side aggregation for every chart, counts capped at 10,000 (UI shows “10,000+”), geo-IP lookups cached, daily VisitDailyStat rollups for the long ranges, and configurable raw-visit retention.
May 21, 2026
Dashboard rebuild on Tailwind v4 + UntitledUI v2
  • Per-client analytics rebuilt on Tailwind v4 and UntitledUI v2 — cleaner type, denser cards, faster paint.
  • New Top Campaigns card on per-client analytics, with utm_source / utm_medium / utm_campaign provenance.
  • Visit-feed filters restyled as folder-style tabs; new “Businesses” chip hides residential ISPs, hosting providers, and gov/edu.
  • iCloud Private Relay and Cloudflare WARP traffic now correctly bucketed as residential rather than datacenter, so a personal Mac doesn’t read as a server.
  • Status pill auto-overrides to “Snippet active” whenever real visits are landing — a Cloudflare block on the install verifier no longer reads as “broken”.
May 21, 2026
Install verification & Cloudflare-friendly tracking
  • Background install verification runs every six hours with a cached status pill on the client list — green when the snippet is reachable, amber when it’s missing, gray when it’s blocked.
  • Install checker uses a Chrome user-agent so naive bot blocks don’t false-flag a working install.
  • Track endpoint now reads the real visitor IP from CF-Connecting-IP, True-Client-IP, X-Real-IP, and X-Forwarded-For (in that order) so Cloudflare in front doesn’t mask the visitor.
  • Dynamic CORS — Access-Control-Allow-Origin echoes the request origin so credentialed sendBeacon stops bouncing.
  • Delete-client action from settings, with a confirmation step and a 30-day data-purge window.
May 20, 2026
Initial public build
  • Dashboard, snippet, ingest endpoint, Slack alerts, identity capture, and email digests — the first end-to-end build.
  • LocalPulse branding (logo, favicon, marketing site) replaces the internal SearchActions skin, ready for generic distribution.