Docs
Everything datahog does, in one page: the snippet, the SDK, campaigns, Stripe attribution, MCP, REST, the wire format, and what we store.
Install
Paste one script tag into your site’s head. Autocapture starts recording pageviews, clicks, forms, rage clicks, dead clicks, and exceptions immediately — events appear in your dashboard within seconds. Create a site to get your own snippet with a real site id.
<script async src="undefined/dh.js" data-site="YOUR_SITE_ID"></script>
SDK API
Once loaded, window.dh exposes:
dh.track(name, args?)Fires a custom event. name must not start with $ (reserved for autocaptured events).
dh.track('upgrade_intent', { plan: 'pro', source: 'settings' })dh.identify(extUserId, args?)Attaches your own user id to the current visitor. args.email and args.name feed the person row and power Stripe email matching.
dh.identify('user_42', { email: 'maya@acme.com', name: 'Maya Chen' })dh.reset()Call on logout. Clears the ext user id and mints a fresh distinct id and session.
dh.optOut() / dh.optIn()Sets a localStorage flag checked before any capture runs — wire this up to a cookie or consent banner.
dh.distinctId()Returns the current anonymous distinct id — used to wire Stripe Checkout’s client_reference_id (see Stripe below).
Campaign links
Two ways to attribute traffic to a campaign, one join key:
- Param only — put
?ref=summer-launchon any URL. The SDK capturesreflike a UTM, and it joins traffic to the campaign asinitial_ref. - Short link —
GET /c/summer-launchrecords a server-side click (counts clicks even from email clients and QR codes) and 302s to the campaign’s destination with?ref=summer-launchappended.
Stripe revenue attribution
Point a Stripe webhook at your site’s endpoint (find yours, and the signing secret field, on the site’s Settings page):
undefined/api/stripe/webhook/YOUR_SITE_ID
In your Stripe dashboard, send this endpoint checkout.session.completed and invoice.paid, then paste the signing secret into the same settings page. To attribute a sale to a visitor, wire either of these on checkout:
// option 1 — client_reference_id
stripe.checkout.sessions.create({
client_reference_id: 'dh_' + dh.distinctId(),
// ...
})// option 2 — identify with an email, matched against Stripe's customer email
dh.identify(userId, { email: 'maya@acme.com' })No match still records the sale, just unattributed — site totals stay honest either way.
MCP connect
Create an API key in Settings, then connect your agent. Claude Code:
claude mcp add datahog undefined/api/mcp --header "Authorization: Bearer dhk_..."
Cursor (or any stdio MCP client):
npx -y mcp-remote undefined/api/mcp --header "Authorization: Bearer dhk_..."
20 tools, all mirroring the dashboard:
get_accountuser, plan, usage summarylist_siteslist your sitescreate_sitecreate a site — returns the install snippetget_snippetfetch the install snippet for a sitequery_analyticsoverview / timeseries / group_by / eventsrun_funnelsteps[] through the funnel pipeget_retentionweekly retention gridget_journeysentry source, landing page, converted vs bouncedget_live_visitorsvisitors active in the last 5 minuteslist_eventsraw recent events, for naming funnelsannotate_deploypin a release marker on every chartlist_campaignslist campaigns for a sitecreate_campaigncreate a short link + spend trackerupdate_campaignupdate name, destination, spend, metadatalist_dashboardslist dashboards for a sitecreate_dashboardcreate a dashboardadd_dashboard_cardadd a card — query is the analytics params belowupdate_dashboard_cardedit a cardremove_dashboard_cardremove a cardget_usagecurrent month events vs quotaREST
Every REST route takes the same bearer key as MCP: Authorization: Bearer dhk_.... The core read is GET /api/analytics, one builder behind every dashboard card, REST call, and MCP tool:
| Param | Type | Notes |
|---|---|---|
| site_id | string | required |
| kind | 'overview' | 'timeseries' | 'group_by' | 'funnel' | 'retention' | 'journeys' | 'live' | 'events' | required |
| date_from / date_to | ISO date string | default: last 7 days |
| granularity | 'auto' | 'hour' | 'day' | 'month' | default: 'auto' |
| compare | boolean | also fetch the previous period, as [current, previous] |
| dimension | 'channel' | 'source' | 'initial_channel' | 'initial_source' | 'ref' | 'initial_ref' | 'page' | 'entry_page' | 'country' | 'region' | 'city' | 'device' | 'browser' | 'os' | 'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_term' | 'utm_content' | 'event' | 'error_message' | group_by only |
| metric | 'visitors' | 'pageviews' | 'sessions' | 'revenue' | 'goal_count' | optional |
| goal_event | string | conversion event for overview/group_by/journeys |
| steps | [{ event: string }], 2 to 6 entries | funnel only |
| window_days | number | funnel conversion window, default 7 |
| filters | [{ field, operator: 'in' | 'not_in' | 'contains', values: string[] }] | optional |
| limit | number | optional |
Wire format of POST /i
The ingest endpoint is SDK-agnostic and documented here for anyone writing a server-side or non-JS SDK. Batches post as a plain-text body (no Content-Type, which keeps it a CORS-simple request):
{
"site_id": "abc123",
"sent_at": "2026-07-08T12:00:00.000Z",
"events": [ /* one object per event, shape below */ ]
}Each event:
{
"uuid": "0190f5b2-...", // UUIDv7 -- retry-safe, dedupes on the server
"event": "$pageview", // or a custom name from track()
"timestamp": "2026-07-08T12:00:00.000Z",
"distinct_id": "0190f4a1-...",
"ext_user_id": "user_42", // present only after identify()
"session_id": "0190f3c0-...",
"url": "https://example.com/pricing",
"path": "/pricing",
"title": "Pricing",
"referrer": "https://google.com/",
"screen_width": 1920,
"screen_height": 1080,
"viewport_width": 1280,
"viewport_height": 800,
"language": "en-US",
"client_timezone": "America/New_York",
"is_webdriver": false,
"first_touch": {
"referrer_domain": "google.com",
"landing_page": "/pricing",
"ref": "",
"utm_source": "",
"utm_medium": "",
"utm_campaign": "",
"utm_term": "",
"utm_content": ""
},
"args": {} // your custom track() properties
}Autocaptured event types add their own fields on top of this shape (for example $click adds selector, el_text, and href). One retry after 3s on a network error or 5xx, then the batch is dropped — the uuid dedup makes retries safe.
Privacy
- Autocapture never records form field values — only that a form was submitted, by name/id/action.
- Click capture stores a CSS selector and up to 100 characters of element text, not full DOM contents.
- Raw IP address and user agent are stored for geo/device enrichment (a per-site mode that drops them is planned, not yet shipped).
- Exceptions capture the error type, message, and stack trace — no surrounding application state.
- Any visitor can be excluded from tracking with
dh.optOut(), checked before every capture.