# askbowtie tracking reference

Machine-friendly reference for the askbowtie tracker. Everything here is current and verified against the shipped tracker (v7.3). Human-readable version: https://askbowtie.com/docs/

## Install

One tag, in `<head>`, on every page:

```html
<script src="https://askbowtie.com/bowtie.js" data-token="your-claim-token" async></script>
```

The `data-token` shown is a placeholder. Yours is unique to your site and is how askbowtie
confirms the site is yours, so copy the real line from your dashboard at
https://askbowtie.com/app/ . Without it the tracker still sends events, but the site stays
unverified and MCP returns nothing for it.

`async`, not `defer`: the tracker captures errors that happen during page parse. Never version the URL. The domain must be registered at askbowtie.com or the tracker disables itself.

## Tracked automatically (zero config)

| Signal | Event types |
|--------|-------------|
| Page views and sessions | `page_load`, `session_start`, `page_hidden` |
| Clicks | `click`, `external_link_click`, `rage_click` |
| Forms | `form_submit` |
| Errors | `console_error`, `resource_error`, `network_error`, `csp_violation` |
| Performance | `web_vitals` (LCP, CLS, INP, TTFB) |
| Engagement | `page_hidden` carries engaged time and scroll depth: the furthest scroll % reached (0-100), surfaced per page in analytics |

UTM parameters and ad click IDs (gclid, msclkid, fbclid) are captured on landing and survive internal navigation. No cookies. No PII: input values are never read, query-string PII is redacted.

## Public API

The API lives at `window.bowtie` after the script loads. The script is async, so guard calls that could run before load with optional chaining: `window.bowtie?.track(...)`. In user-action handlers (clicks, submits) the tracker is always ready.

### Custom events

```js
bowtie.track(eventType, data?)

bowtie.track('signup_started', { source: 'homepage_cta' });
bowtie.track('plan_selected', { plan: 'agency', seats: 5 });
```

`eventType` is a string you choose. `data` is a flat object of context, queryable later through MCP.

### Conversions and revenue

```js
bowtie.converted(goalName, details?)

bowtie.converted('purchase', { value: 99.00, currency: 'USD', transaction_id: 'ord_1842' });
bowtie.converted('lead_captured', { source: 'contact_form' });
bowtie.converted('trial_started');
```

| Field | Type | Notes |
|-------|------|-------|
| `value` | number | Revenue or goal value. Optional. |
| `currency` | string | ISO code, default `USD` |
| `transaction_id` | string | Dedupe key: the same conversion never counts twice |
| `pixels` | boolean or string | Also fire to connected ad platforms. `true` = primary Google Ads label, `'secondary'` = secondary label, default off |
| `email`, `phone` | string | Enhanced conversions, hashed by gtag before leaving the page. Only used when `pixels` fires |

Any other keys you pass are stored with the event.

### Application errors

```js
bowtie.error(code, details?)

bowtie.error('payment_declined', { reason: 'insufficient_funds', amount: 99.00 });
bowtie.error('validation_failed', { field: 'email' });
```

For errors your own code catches. Uncaught JS errors, failed resources, and failed fetches are captured automatically.

### Guardrails

```js
bowtie.guardrail(code, details?)

bowtie.guardrail('rate_limited', { endpoint: '/api/checkout', limit: '10/min' });
bowtie.guardrail('geo_blocked', { country: 'XX' });
```

Intentional blocks that are not failures: rate limits, quota, access denied, feature flags. Kept separate from errors so error rates stay honest.

### Utilities

```js
bowtie.getSessionId()   // current session id: REQUIRED for server-side conversions, see Rule 2
bowtie.flush()          // send the queue now (before a hard navigation)
bowtie.debug(true)      // tag this tab's events debug:true; persists across pages
bowtie.debug(false)     // off
```

### Legacy

`window.itbroke` is an alias of `window.bowtie`. Sites migrated from itbroke.dev keep working unchanged.

## Page roles (`data-askbowtie-tag`)

Declare what a page IS, in the page itself:

```html
<body data-askbowtie-tag="editorial">
<!-- or -->
<meta name="askbowtie:tag" content="editorial">
```

This is a **tag, not an event**: no API call, nothing to fire. bowtie reads the marker on every crawl (served HTML, so even zero-traffic pages classify) and on every `page_load` from the tracker. Analytics can then be grouped and filtered by role instead of slug: "how does editorial convert vs commercial" is one question, not a scan of every page.

| Rule | Detail |
|------|--------|
| Value | A free lowercase slug you choose: `editorial`, `commercial`, `conversion`, `lander`, … Keep roles coarse (3-6 that mean something). |
| Read on | Every crawl and every `page_load`. Ship a new page with the marker and it classifies itself, no reminders. |
| Precedence | manual > declared > suggested. A tag set explicitly (via the `tag_pages` MCP tool) wins over the page's marker. Auto-detection only **suggests** roles for untagged pages and never overwrites anything. |
| Where it surfaces | `get_page_types` (role rollup + untyped bucket + suggestions), `get_top_pages {type}`, and the Type column and filter on the app's analytics page. |

Put the marker in your layout/template once and every page using that template carries the role forever. For legacy sections you won't re-template, an agent can bulk-assign with `tag_pages {tag, match: '/blog/'}` over MCP.

## Sections (`data-askbowtie-section`)

Name the regions of a page, and analytics can split that page's viewers by which section they clicked: share of viewers per section, plus how each group converted afterwards.

```html
<section data-askbowtie-section="hero">
<nav data-askbowtie-section="nav:header">
<a data-askbowtie-section="cta:start-quiz" href="/quiz">
```

Like page roles, this is a **marker, not an event**: no API call, nothing to fire. The tracker already records every click; with markers present, each click resolves to the nearest ancestor carrying `data-askbowtie-section` at click time.

**Name for the component, not just the type.** A specific sublabel makes the report name the exact element, so you see precisely which piece works: `cta:hero-start-quiz`, `nav:main`, `card:pricing-pro`, `form:newsletter`, `link:footer-support`. Bare `cta` lumps every button on the page into one row. When your markup has component names already, reuse them, so the engagement report reads like your layout.

| Rule | Detail |
|------|--------|
| Value | `section` or `section:sublabel`, each a lowercase slug: `hero`, `content`, `nav:header`, `cta:buy`, ... |
| Placement | On the section container (the `<section>`, `<nav>`, `<header>`, wrapper div). Clicks anywhere inside inherit it. A specific element (one CTA link) can carry its own marker, which wins because it is the nearer ancestor. |
| Read on | Every click, at click time. Put it in the template once and the section is covered forever, new pages included. |
| Retroactive rules | Clicks recorded before markers existed (or on sites you can't re-template) can be classified by a rule mapping an existing click signature (class/href/selector substring) to a section, via the `manage_sections` MCP tool. Rules apply at query time, so one rule names months of historical clicks instantly, and clearing it reverts cleanly. A marker always wins over a rule. |
| Suggestions | The engagement report lists the top unnamed click signatures on a page as proposals. Nothing is auto-applied: you (or your agent) confirm one by adding the rule. |
| Where it surfaces | `get_engagement {domain, page, period}` over MCP, and the Engagement block in the app's analytics page detail. |

One property to know: a session can engage several sections, so section shares overlap and do not sum to 100%.

## Server-side events (no browser)

For events your backend is the source of truth for: webhook-confirmed conversions, ad-block-proof lead capture, server-validated purchases. These POST straight to the ingest endpoint — no tracker, no browser.

**There are TWO rules here, and only the first one is obvious.** Get the second wrong and your events still arrive, still count in your totals, and still tell you nothing about *where* they came from.

### Rule 1: every event needs `"source": "server"`

Mandatory, not optional. It does two things:

1. **Opts out of the browser bot filter.** Ingest silently drops batches whose `User-Agent` looks non-browser (empty, `curl`, `node-fetch`, `go-http`, `python`, anything with `bot`…) which is *every* backend and Cloudflare Worker. `source:"server"` on every event tells ingest this is a trusted first-party server batch, so the UA heuristic is skipped. Without it your events are dropped behind a `200` and you never know.
2. **Tags the origin as server.** Events missing it are stored as `source:"js"` and become indistinguishable from client events. They silently merge into your browser counts and can double-count against a client fire for the same action.

### Rule 2: `session_id` must be the visitor's REAL bowtie session id

This is the one that quietly ruins conversion reporting, because nothing fails when you get it wrong. Any string is accepted. Your conversion is counted. But if the id never appeared on a real visit, askbowtie cannot connect that conversion to the session that caused it, so it can never tell you which **source, landing page, device, or country** converted. Your totals stay right; every conversion breakdown goes empty.

We have measured exactly how binary this is on live traffic. One site's backend sends the real session id on some conversions and a made-up one on the rest:

| what the backend sent in `session_id` | conversions | attributed to a visit |
|---|---|---|
| the real tracker session id | 214 | **214 (100%)** |
| a synthetic `srv_…` id | 77 | **0 (0%)** |

There is no partial credit and no flakiness. Pass the real id and it works every time. Invent one and it never works.

### Getting to ~100%: capture at the touch, persist, echo

The mistake is trying to obtain the session id **at conversion time**. By then there is no browser to ask, so you invent one. Do it in this order instead:

1. **Capture at the web touch.** When the visitor submits the form, starts checkout, or clicks the thing, read `bowtie.getSessionId()` in the browser and send it to your backend with the payload.
2. **Persist it on the record.** Store it on the lead, order, or subscription row. With Stripe, `client_reference_id` on the Checkout Session is purpose-built for this; `metadata` works too.
3. **Echo it when the conversion fires.** The webhook, CRM stage change, or nightly job reads the id back off the record and puts it in `session_id`.

Capture once, carry it forward. Do not re-read `getSessionId()` later even if a browser is present: sessions roll over after 30 minutes of inactivity, so a fresh read returns a *different* id than the one that earned the conversion.

### When there is genuinely no web touch

> **Or skip the handoff entirely: [server-side sessions](/docs/server-side/).** Everything above assumes the browser owns the identity and hands it to your backend. It does not have to. If your backend posts the **page view** as well as the conversion, under one id you own, the join is exact with no browser involvement at all. That is the recommended setup for gateways, server-rendered checkouts and webhook-driven flows.


Phone orders, offline imports, and backend-only signups have no session to point at. Send them anyway with a synthetic id. **The conversion is real and must be counted**, and since 2026-08-02 a synthetic id no longer inflates your session count. Just know these will show up as unattributed, and that is honest rather than hidden.

What matters is that this stays a small, explainable floor. If most of your conversions land in it, that is a wiring problem, not an offline-business problem.

```bash
curl -X POST https://askbowtie.com/api/ingest \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "yoursite.com",
    "session_id": "k3f9qz2ab7x",
    "events": [
      {
        "event": "lead_captured",
        "category": "conversion",
        "source": "server",
        "page": "/domain-broker/thank-you",
        "value": 1200,
        "currency": "USD",
        "transaction_id": "lead_1842",
        "ts": 1781157492
      }
    ]
  }'
```

| Field | Required | Notes |
|-------|----------|-------|
| `domain` | yes | Must be registered, top-level (`www.` is stripped) |
| `session_id` | **yes, and the VALUE matters** | The visitor's real bowtie session id, captured at the web touch via `bowtie.getSessionId()` and carried through your backend (see Rule 2). Any string is accepted, but a made-up one gets you 0% attribution with no error. Only use a synthetic id when there was genuinely no web session at all |
| `events[].source` | **yes** | Must be exactly `"server"` on **every** event in the batch. Mixed client/server batches don't get the bypass — send server events in their own POST |
| `events[].event` | yes | The event/goal name (`lead_captured`, `purchase`…) |
| `events[].category` | yes | `conversion` for a conversion goal; `event` for a custom event; `error` / `guardrail` as applicable |
| `events[].transaction_id` | recommended | Dedup key — the same conversion never counts twice, even on retry |
| `value`, `currency`, `page`, `ts` | optional | `value` carries deal size; `ts` is epoch seconds (defaults to receipt time) |

**Verify it landed — check `stored`, not `events_received`.** `events_received` only proves the POST was *accepted*; it says nothing about how each event was stored. The response also returns `stored`, a per-source count of what was actually persisted:

```json
{ "success": true, "events_received": 1, "incidents_detected": 0, "stored": { "server": 1 } }
```

For a server batch, assert **`stored.server` equals the number of events you sent**. If you instead see `"stored": { "js": 1 }`, your events were accepted but tagged as client events (a `source` value other than exactly `"server"`, or it was dropped from the payload) — they'll merge into your browser counts. `stored` is the signal that closes the loop; `events_received` can't.

Anything that isn't a clean `stored` means the batch was filtered or partially stored:
- `"events_received": 0, "dropped": "bot_ua"` (no `stored`) → a non-`source:"server"` event slipped into the batch (or all of them). Make sure **every** event has `"source":"server"`.
- `"events_received": 0, "status": "provisioning"` → the domain was just registered and its store isn't ready yet; retry shortly.
- `"dropped": "suspicious_speed"` → too many non-system events in one batch spanning <500ms; pace real submissions.
- `stored` totals less than `events_received` → some events were deduped (e.g. a repeat `page_load` within 2s, or a `transaction_id` already seen). Expected; not an error.

Batches are capped at 50 events; over that returns `429`.

**Then verify ATTRIBUTION, which `stored` cannot tell you.** A perfect `stored.server` only proves your events were persisted as server events. It says nothing about whether the `session_id` you sent matched a real visit. Check that separately in `get_traffic`, which reports it directly:

```json
"attribution": {
  "conversions": 3800,
  "attributed": 44,
  "unattributed": 3756,
  "unattributed_rate": 98.8,
  "reason": "Server-posted conversions whose session id never matched a tracked visit…"
}
```

Read `unattributed_rate` as the scoreboard for Rule 2:

- **under ~5%.** Wired correctly. The remainder is your genuine offline floor.
- **20-60%.** You are capturing the id on some paths and inventing it on others. Find the path that skips step 1.
- **over 90%.** No path is passing a real session id. Every conversion still counts, but no conversion breakdown will ever populate.

This is the check that closes the loop on Rule 2, exactly as `stored.server` closes it on Rule 1.

### Don't block your response on it

`sendBeacon` is a **browser** API — it doesn't exist in a Cloudflare Worker or Node backend, so there's no server-side beacon. The equivalent is **fire-and-forget**: send the ingest POST in the background so your user-facing response (the thank-you page, the redirect, the API reply) returns immediately and never waits on — or times out against — askbowtie.

Cloudflare Worker — `ctx.waitUntil` keeps the request alive for the background POST without blocking the response:

```js
ctx.waitUntil(
  fetch("https://askbowtie.com/api/ingest", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      domain: "yoursite.com",
      session_id: sessionId,
      events: [{ event: "lead_captured", category: "conversion", source: "server",
                 value: 1200, currency: "USD", transaction_id: "lead_1842" }],
    }),
  }).catch(() => {})
);
return thankYouResponse; // sent to the user right away
```

Node / Express: don't `await` the ingest `fetch` before `res.send()` — fire it, `.catch()` it, respond immediately.

**The trade-off:** fire-and-forget means you don't read the response — including `stored` — so a silent drop or mis-tag (the failure mode `source:"server"` exists to prevent) becomes invisible again. Two safe ways to keep accuracy:

- Put `transaction_id` on every conversion (dedup is automatic) and **retry in the background** on a network/5xx failure — a retry can't double-count.
- Or **reconcile** periodically: compare bowtie against your own database of record.

If you'd rather verify inline, do a normal awaited POST with a short timeout (250–500 ms) and one retry — the endpoint is fast and this won't meaningfully delay a server response.

## Querying the data

All of it is queryable by an AI agent through MCP:

```
claude mcp add --transport http askbowtie https://askbowtie.com/mcp \
  --header "Authorization: Bearer YOUR_TOKEN"
```

Core tools: `list_domains`, `get_summary`, `get_traffic`, `get_conversions`, `get_incidents`, `get_alerts`, `get_performance`, `get_page` — plus 29 more (search, ads reads + `manage_ads` Google Ads management, paid performance, landing-page attribution, crawl + lander audits, flows, page scores, page roles/tags, referrers, notes, domain registration, and the `playbook` guide): 37 in total. Call `playbook` or `tools/list` for the full surface. Custom events, conversions with revenue, errors, and guardrails all come back with their data fields intact.

The Google-backed tools (`get_search`, `get_ads`, `get_ads_report`, `get_paid_performance`, `manage_ads`, `manage_conversion_upload`) need a one-time connector link first: Settings > Connectors in the app, one OAuth consent per product. Search Console links read-only. Google Ads has no read-only scope, so its link grants read and write; `manage_ads` is limited to the domain's owners and admins, every change previews before it applies, budget changes and campaign creation sit behind a separate switch on the connector card, and created campaigns start paused.

## Event payload (reference)

Each event the tracker sends:

```json
{
  "event_type": "conversion",
  "page": "/pricing",
  "ts": 1781157492.3,
  "goal": "purchase",
  "value": 99.00,
  "currency": "USD",
  "transaction_id": "ord_1842"
}
```

Events batch into `POST https://askbowtie.com/api/ingest` as `{ domain, session_id, events: [...] }`. In the browser you never call this directly — the tracker does. To send events from your **backend**, see [Server-side events](#server-side-events-no-browser): those must set `"source": "server"` on every event or they're dropped/mis-tagged.
