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.
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.
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>
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 settingsCastle 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,
matchedLocationIdis set; the dashboard breaks Local sessions down by branch.
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.
bashcurl -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}'
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 · simplifiedexport 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:
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.
Slack alerts
One webhook per client. Four scope modes:
off— never pinglocal— only visits matched to a configured locationall— every session, first detection onlyb2b— local OR ISP-bucket isbusiness
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 webhookPOST /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.
/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:
input.type—email,telautocompleteattribute —given-name,family-name,email,tel,organization- 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
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.
What it does
- Re-runs the matcher against each visit using the client’s current target list.
- Sets
matchedLocationIdon previously-untagged rows. - Flips
isLocalto 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.
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
localStorageon 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
VisitDailyStatfor fast long-range analytics. - Slack delivery log in
Alert, used to dedupe pings so a returning session doesn't re-fire.
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
- 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.
- 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”.
- 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.
- 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.