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-launch on any URL. The SDK captures ref like a UTM, and it joins traffic to the campaign as initial_ref.
  • Short linkGET /c/summer-launch records a server-side click (counts clicks even from email clients and QR codes) and 302s to the campaign’s destination with ?ref=summer-launch appended.

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 summary
list_siteslist your sites
create_sitecreate a site — returns the install snippet
get_snippetfetch the install snippet for a site
query_analyticsoverview / timeseries / group_by / events
run_funnelsteps[] through the funnel pipe
get_retentionweekly retention grid
get_journeysentry source, landing page, converted vs bounced
get_live_visitorsvisitors active in the last 5 minutes
list_eventsraw recent events, for naming funnels
annotate_deploypin a release marker on every chart
list_campaignslist campaigns for a site
create_campaigncreate a short link + spend tracker
update_campaignupdate name, destination, spend, metadata
list_dashboardslist dashboards for a site
create_dashboardcreate a dashboard
add_dashboard_cardadd a card — query is the analytics params below
update_dashboard_cardedit a card
remove_dashboard_cardremove a card
get_usagecurrent month events vs quota

REST

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:

ParamTypeNotes
site_idstringrequired
kind'overview' | 'timeseries' | 'group_by' | 'funnel' | 'retention' | 'journeys' | 'live' | 'events'required
date_from / date_toISO date stringdefault: last 7 days
granularity'auto' | 'hour' | 'day' | 'month'default: 'auto'
comparebooleanalso 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_eventstringconversion event for overview/group_by/journeys
steps[{ event: string }], 2 to 6 entriesfunnel only
window_daysnumberfunnel conversion window, default 7
filters[{ field, operator: 'in' | 'not_in' | 'contains', values: string[] }]optional
limitnumberoptional

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.