SurfacedBySurfacedBy Docs

Conversion Tracking

Tie revenue and signups back to the AI platform that sent the visitor.

Ask an AI:Open in ChatGPTOpen in Claude

Conversion tracking ties revenue and signups back to the AI platform that sent the visitor. When someone arrives from ChatGPT, Claude, Perplexity, or another AI platform and later completes a purchase, a renewal, or a custom event, that conversion is attributed to the AI source.

The Tracking page in the dashboard shows the result. A Conversions card sits on that page with the headline numbers, the recent activity table, and the top AI source by attributed revenue. Each row in the recent activity table is clickable: clicking opens the Conversion journey drawer, which lists every AI visit we recorded for that visitor in the 30 days before the sale, ordered chronologically, with the credited touch marked.

How it works

A sb_attr first-party cookie records the AI source on the first AI-referred visit and persists for 30 days. When that visitor later converts, the conversion event is sent to the SurfacedBy ingest endpoint with the cookie forwarded so the backend can resolve attribution to the original AI source.

The cookie payload is a URL-encoded JSON object: {c: client_id, h: ai_host, t: first_seen, g?: gclid, f?: fbclid, m?: msclkid, tt?: ttclid}. The four optional fields are ad-platform click IDs captured from the URL on the original landing (see "Ad-click capture" below).

The attribution model is last-touch among AI sources: if a visitor arrives from ChatGPT, then later returns from Perplexity, then converts, the conversion is attributed to Perplexity. Direct visits and non-AI referrers do not displace the most-recent AI host on the cookie.

The headline numbers (KPIs, by-source, by-event-type, AOV) stay anchored to last-touch-among-AI on the aggregate cards. The Conversion journey drawer is a drill-down explanation, never a different headline; we do not split numerical credit across touches.

Install paths

There are four supported install paths. All four mint the same sb_attr cookie shape, so a customer who installs more than one path (for example, the WordPress plugin + the Cloudflare Worker in front of WordPress) ends up with a single coherent attribution chain.

WordPress plugin. Auto-detects WooCommerce and MemberPress. If either is active, purchases, renewals, signups, and refunds are recorded automatically. No checkout edits, no shortcodes, no admin opt-in. The plugin admin gains a Conversions tab the first time an adapter activates. Adapter coverage is filterable: register an adapter for Easy Digital Downloads, Restrict Content Pro, or any custom revenue path by hooking the surfacedby_aiv_conversion_adapters filter.

JavaScript snippet. For sites not on WordPress, or on WordPress with a custom checkout. Exposes window.sbAi.track(). Call it from the thank-you page, post-signup callback, or any other conversion point.

window.sbAi.track('purchase', {
  event_id: 'order_12345',     // required, idempotent key
  value: 49.00,                // optional, defaults to 0
  currency: 'USD',             // optional, defaults to site currency
  metadata: { sku: 'PRO-1Y' }  // optional, capped at 4 KB
});

The same wire format works for Shopify Hydrogen, headless storefronts, and Cloudflare Workers. The event_id is the idempotent key. Re-firing the same event id is a no-op end to end, so a refreshed thank-you page or a replayed webhook will not double-count.

Cloudflare Worker (edge). A small Worker that sits in front of a customer's origin. Mints the sb_attr cookie at the edge so static sites, cached sites, and headless storefronts get attribution without the WP plugin or the JS snippet. Set SURFACEDBY_SITE_ID, SURFACEDBY_COLLECTOR_SECRET, and optionally SURFACEDBY_COOKIE_DOMAIN (omit for host-only cookie; set to example.com if the cookie needs to span www.example.com and checkout.example.com).

NGINX njs (origin middleware). Same cookie shape, same attribution model, runs in the nginx js_set / js_header_filter phases. Set $surfacedby_site_id, $surfacedby_ingest_base, $surfacedby_secret_path, and optionally $surfacedby_cookie_domain. Ships with a bundled seed registry so AI detection works the instant the worker boots on a fresh deployment.

Edge + WordPress: the X-SB-Client-Id handshake

When the Cloudflare Worker or NGINX njs sits in front of a WordPress origin running the SurfacedBy plugin, both layers process the same request. Without coordination, the edge would mint UUID A and write a cookie, but the plugin (running server-side without that cookie in hand on the first request) would mint UUID B and write its own cookie. The visit row from the edge would carry client_id=A, the conversion later would carry client_id=B, and the lookup would fail.

The fix is the X-SB-Client-Id request header: edge collectors add it to the upstream request they forward to origin, the WordPress plugin honors it when the cookie is absent, and both layers converge on the same client_id on the first request. No customer configuration required - both ends know what to do.

Supported event types

The first argument to track() (and the event types recorded by the WordPress adapters) is one of:

  • purchase for completed sales.
  • renewal for subscription rebills.
  • signup for new account creations and free-trial starts. Reported with value: 0 by default.
  • refund for refunded sales. Reported as a separate negative-value row keyed by the refund id so partial refunds compose without overwriting the original purchase.
  • lead for qualified form submissions or other pre-sale conversions.
  • custom for anything else you want to attribute.

Ad-click capture (gclid, fbclid, msclkid, ttclid)

When a visitor lands with a Google Ads gclid, Meta fbclid, Microsoft Ads msclkid, or TikTok ttclid parameter, the collector captures it into the sb_attr cookie alongside the AI attribution. At conversion time, the parser stores them on the conversion row at metadata.sb_ad_click_ids = {gclid, fbclid, msclkid, ttclid}. Only IDs the URL actually carried appear in the map; values are capped at 256 characters.

We capture; we do not forward yet. Customers running paid ads can read sb_attr from their own checkout server, extract the click IDs, and POST them to Google's Conversion API / Meta CAPI / Microsoft Ads / TikTok Events API themselves. The cookie persists for 30 days, so an ad click on Monday is still resolvable at a Friday conversion even if the URL no longer carries the param.

Native forwarding (one webhook in, four ad platforms out) is on the conditional roadmap; see docs/todos/08-conditional/OP-21-ad-platform-conversion-api-forwarders.md.

What the dashboard shows

The Conversions card on the Tracking page reports:

  • 7-day total revenue from AI-attributed conversions.
  • 7-day count, broken down by event type.
  • The top AI source by attributed revenue.
  • A recent activity table (last 10 events) with timestamp, AI source, event type, value, and path. Rows are clickable: click to open the journey drawer.

If no conversions have landed yet, the card's empty state explains that conversions will appear after a customer who arrived from an AI platform completes a tracked event, and links to the Integrations page to install the tracker.

The headline numbers exclude rows fired by the Send test conversion button (see below). Test rows appear in the recent activity table with a "TEST" chip so the customer sees the test landed.

Multi-touch attribution models

Every conversion that lands with a resolved client_id produces fractional credit rows for four built-in attribution models. The conversion journey drawer's model picker switches between them in place; selection persists per domain and survives navigation.

ModelRuleWhen to use
Last touch100% credit to the most recent AI touch before the conversion.Matches the headline on the Conversions card; safest default.
LinearEqual 1/N share to every touch in the 30 day lookback.Treat every assist as equal.
Time-decay (7d)weight = 0.5 ^ (age_days / 7), then normalised.Credit recent touches more without dropping the tail to zero.
Position-based (40/20/40)40% first touch, 40% last touch, 20% split linearly across the middle.Honor both discovery and decision.

Credit fractions are computed at ingest time and persisted to domain_conversion_attributions, keyed by (conversion_id, visit_event_id, model). The aggregate KPIs and per-source breakdown on the parent card stay anchored to last-touch-among-AI regardless of the picker choice; the journey is a drill-down explanation, never a different headline.

Existing conversions with client_id IS NOT NULL inside the 30-day window can be filled in by running python -m scripts.backfill_conversion_attributions --all from the backend container; the write path is idempotent on ON CONFLICT DO NOTHING so the script is safe to re-run.

Send test conversion

After installing the webhook path, a Send test conversion button on the Webhook install drawer fires a synthetic conversion through the real ingest path with the dashboard browser's sb_attr cookie forwarded server-side. The result drawer shows whether the cookie parsed, whether attribution resolved, and the synthetic conversion id.

  • The test row is written to domain_conversions with is_test=true so every aggregate query excludes it from customer-facing totals.
  • The recent activity table renders the row with a "TEST" chip so the customer has explicit feedback that the test landed.
  • No HMAC secret leaves the server. The endpoint signs the synthetic event server-side and routes it through the same code path a real webhook would hit; no signature material reaches the browser.

This unblocks customers who want to verify their integration end to end without making a real purchase.

Privacy and data

What is stored:

  • The AI source host (for example, chatgpt.com).
  • The timestamp of the original visit and the conversion.
  • The event id, type, value, and currency.
  • Ad-platform click IDs captured from the landing URL (gclid, fbclid, msclkid, ttclid), if present.
  • Optional metadata you pass to track() (capped at 4 KB).

What is not stored:

  • The visitor's IP address.
  • Any personally identifiable information about the customer, unless you explicitly include it in the optional metadata field. The recommendation is not to.

The attribution cookie holds only the first AI host, the visit timestamp, the client_id, and the captured ad-click IDs. It does not carry an advertising id or any cross-site identifier.

Setup walkthrough

  1. Open Dashboard -> Integrations on the domain you want to track.
  2. Pick the install path that matches your stack:
    • WordPress plugin (download link + auto-install steps).
    • Generic JavaScript snippet.
    • Cloudflare Worker (paste the worker.js, set env vars).
    • NGINX njs (drop the files in /etc/nginx/njs/, include snippet.conf).
  3. For WordPress: activate the plugin, then visit SurfacedBy -> Settings in your WP admin and confirm "Track conversions" is on (it is on by default). If you run WooCommerce or MemberPress, no other configuration is required.
  4. For the JavaScript path: drop the tracker snippet into your site's head, then call window.sbAi.track() at each conversion point.
  5. For the edge paths (Cloudflare, NGINX): set the Site ID + secret env vars from the install drawer.
  6. Verify the wire: click "Send test conversion" on the Webhook install drawer. The result drawer confirms the chain end to end without requiring a real purchase.
  7. Optional: place a real order from a session that arrived from an AI platform, then check the Conversions card on the Tracking page. New events land within seconds; click the row to see the visitor journey.

A site-owner toggle in the WordPress plugin settings turns the whole conversion path off cleanly without disabling the rest of the plugin.

Safari ITP note for the edge paths

Safari's Intelligent Tracking Prevention can shorten first-party cookies set by an edge proxy to seven days when the path is classified as a tracker. If you expect significant Safari traffic, pair the edge collector with the JS snippet on the same site - the snippet's localStorage mirror keeps the attribution cookie alive across the Safari eviction so visits remain linked to later conversions. The JS snippet alone (without the edge layer) is not affected; Safari's heuristic applies to HTTP Set-Cookie headers set by detected trackers, not to document.cookie writes.

On this page