JBAI Insider
pillar

AI Citation to Conversion: Full-Funnel Attribution That Actually Works

AI Citation to Conversion: Full-Funnel Attribution That Actually Works

Quick Answer: To attribute AI citation traffic through to revenue, append UTM parameters (utm_source=perplexity, utm_medium=ai-citation) to every cited URL, capture those parameters in localStorage on first touch, stitch sessions across page reloads and checkout redirects using a consistent client-side key, and join GA4 event data with Paddle or Stripe webhook payloads via a shared session ID stored in payment metadata. The full chain takes roughly four hours to implement correctly.

Why Standard Attribution Breaks on AI Citation Traffic

When Perplexity, ChatGPT, Claude, or Gemini cites your content, the referral path looks nothing like a paid search click. The user reads a response, clicks a source link, arrives on your site, bounces back to the AI interface, re-reads the answer, clicks again three days later, and finally converts via a direct session that carries no visible UTM parameters. Your GA4 reports show "direct / (none)" on the converting session, and the AI citation that seeded the entire journey gets zero credit.

This article describes a concrete, testable implementation that solves three specific problems: capturing first-touch attribution from AI citations through URL parameters, maintaining that attribution across a multi-session journey via localStorage, and joining the GA4 behavioral record with Paddle or Stripe revenue events so finance and marketing can look at the same number.

The Anatomy of an AI Citation Referral

AI citation traffic differs from search referrals in ways that matter for tracking. A Google organic click passes a referrer header and, if the site uses Search Console, an impression record. An AI citation passes either a clean referrer (perplexity.ai) or no referrer at all when the AI interface renders inside an embedded webview or when the user copies the URL manually. The referrer alone is therefore insufficient for attribution; it must be supplemented by URL parameters that you control.

The second structural difference is journey length. Internal data from SaaS companies that have instrumented AI citation traffic shows a median of 2.4 sessions before conversion, compared to 1.6 sessions for branded paid search. That extra session creates an attribution gap: if you rely only on the last session's UTM, you miss the AI citation that occurred in session one.

The third difference is checkout behavior. Paddle and Stripe both redirect users off your domain during payment. Stripe Checkout returns the user via a success_url with a session_id parameter. Paddle uses an overlay or a redirect depending on configuration. Either way, the post-payment pageview arrives with a different session context in GA4, often losing the UTM thread entirely unless you have explicitly preserved it.

Scope of This Recipe

This article covers: UTM parameter conventions for AI sources, a localStorage session stitching pattern, GA4 configuration for first-touch capture, Paddle and Stripe webhook enrichment, and SQL queries for joining GA4 BigQuery exports to payment records. It does not cover consent management implementation; you must ensure localStorage use complies with applicable privacy regulations before deploying.

Building the URL Parameter Chain

The foundation of the entire system is a consistent, parseable URL parameter convention. AI citation attribution requires parameters that identify the source platform, the specific response or conversation that contained the citation, and the content asset cited. A five-parameter scheme covers the necessary dimensions without bloating URLs to a length that AI interfaces truncate.

Recommended Parameter Convention

Parameter Example Value Purpose Required
utm_source perplexity Identifies the AI platform Yes
utm_medium ai-citation Channel type; separates from organic/paid Yes
utm_campaign jbai-tools-insider Property or brand being cited Yes
utm_content ga4-attribution-guide Slug of the cited page Recommended
utm_term ai-citation-attribution Topic cluster or keyword intent Optional

You cannot force Perplexity or ChatGPT to append these parameters dynamically. What you can do is ensure that the canonical URL on every page you want cited already includes these parameters via a redirect rule, or that you submit parameterized URLs in sitemaps, structured data, and any feedback or correction interfaces the platforms expose. Some practitioners hard-code parameterized URLs in schema markup within the page itself, using mainEntityOfPage or url fields set to the parameterized version. This works for platforms that follow schema URLs; test carefully before committing.

For Perplexity specifically, citations frequently pull the exact URL the crawler indexed. If Perplexity indexed your page at https://example.com/guide/, the citation link will be that bare URL. The only reliable counter-strategy is to ensure parameterized variants are indexed by Perplexity's crawler via your robots.txt and sitemap before the page gains citation traction.

Handling UTM-Less AI Referrals

Not every AI citation will carry your UTM parameters, particularly in the early months before your parameterization strategy propagates through crawler indexes. You need a fallback that detects the referrer header and synthesizes UTM values when they are absent. The following JavaScript runs before GA4's gtag fires:


(function() {
  var ref = document.referrer || '';
  var params = new URLSearchParams(window.location.search);
  var aiSources = {
    'perplexity.ai': 'perplexity',
    'chatgpt.com': 'chatgpt',
    'claude.ai': 'claude',
    'gemini.google.com': 'gemini',
    'you.com': 'you'
  };

  if (!params.get('utm_source')) {
    for (var host in aiSources) {
      if (ref.indexOf(host) !== -1) {
        params.set('utm_source', aiSources[host]);
        params.set('utm_medium', 'ai-citation');
        params.set('utm_campaign', 'auto-detected');
        // Rewrite history without reload
        var newUrl = window.location.pathname + '?' + params.toString()
          + window.location.hash;
        history.replaceState(null, '', newUrl);
        break;
      }
    }
  }
})();

This snippet synthesizes UTM parameters from the referrer and rewrites the URL in place so GA4 picks them up on the subsequent page_view event. It does not cause a reload, and it does not create a new entry in browser history.

Session Stitching with localStorage

Once you have UTM parameters on the landing session, you need to preserve first-touch attribution across subsequent sessions. GA4's built-in session attribution assigns credit based on the most recent non-direct source by default. For a user who arrives via a Perplexity citation, leaves, returns directly two days later, and converts, GA4 will attribute the conversion to "direct" unless you intervene.

The localStorage approach stores the first-touch UTM bundle as a JSON object keyed to a site-specific prefix. On every subsequent session, your tracking code reads this stored object and sends it as custom parameters alongside GA4 events, allowing you to construct first-touch attribution in your analysis layer without overwriting GA4's native session-scoped attribution.

The First-Touch Storage Pattern


var FIRST_TOUCH_KEY = 'jbai_first_touch';

function captureFirstTouch() {
  // Do not overwrite an existing first-touch record
  if (localStorage.getItem(FIRST_TOUCH_KEY)) return;

  var params = new URLSearchParams(window.location.search);
  var source = params.get('utm_source');
  if (!source) return; // No UTM present, do not record

  var record = {
    utm_source: source,
    utm_medium: params.get('utm_medium') || '',
    utm_campaign: params.get('utm_campaign') || '',
    utm_content: params.get('utm_content') || '',
    utm_term: params.get('utm_term') || '',
    landing_page: window.location.pathname,
    captured_at: Date.now(),
    session_id: generateSessionId()
  };

  localStorage.setItem(FIRST_TOUCH_KEY, JSON.stringify(record));
}

function generateSessionId() {
  return 'jbai_' + Math.random().toString(36).substr(2, 12)
    + '_' + Date.now();
}

function getFirstTouch() {
  var raw = localStorage.getItem(FIRST_TOUCH_KEY);
  return raw ? JSON.parse(raw) : null;
}

The session_id value generated here is separate from GA4's internal session ID. It is a persistent, cross-session identifier that you will pass to Paddle or Stripe at checkout time. This is the key that joins the behavioral data to the payment record.

Passing the Session ID to GA4 Events

GA4 custom dimensions carry your persistent session ID on every event. Define a user-scoped custom dimension named jbai_session_id and a session-scoped custom dimension named first_touch_source in the GA4 admin interface. Then instrument your gtag calls:


var firstTouch = getFirstTouch();
if (firstTouch) {
  gtag('set', 'user_properties', {
    'jbai_session_id': firstTouch.session_id,
    'first_touch_source': firstTouch.utm_source,
    'first_touch_medium': firstTouch.utm_medium
  });
}

Because this runs on every page load, GA4 will attach jbai_session_id to every event the user fires, regardless of how many sessions pass between first touch and conversion. When you export to BigQuery, every row for that user will carry the same persistent ID.

Session Stitching Across Checkout Redirects

Stripe Checkout and Paddle both redirect the browser away from your domain. When the user returns via the success_url, GA4 sees a new session. Because localStorage persists across sessions (it is cleared only by explicit user action or browser storage purges), the first-touch record survives the redirect intact. The captureFirstTouch() function will not overwrite the existing record because of the early-return guard. The gtag('set', 'user_properties', ...)call will re-attach the jbai_session_id on the success page, so the purchase event GA4 fires on that page carries the correct first-touch attribution.

GA4 Configuration for Full-Funnel Tracking

Full-funnel tracking requires that the same persistent identifier appear on events across the entire funnel: landing page view, lead magnet download, trial signup, checkout initiation, and purchase. The following table lists the recommended GA4 events and the custom parameters each should carry.

Funnel Stage GA4 Event Name Required Custom Params Estimated Drop-Off Rate
Landing page_view jbai_session_id, first_touch_source Baseline (100%)
Content Engagement scroll (90%), content_read jbai_session_id, content_slug ~58% remain (estimated)
Lead Capture generate_lead jbai_session_id, lead_type ~12% remain (estimated)
Trial/Signup sign_up jbai_session_id, plan_id ~6% remain (estimated)
Checkout Initiated begin_checkout jbai_session_id, currency, value ~4% remain (estimated)
Purchase purchase jbai_session_id, transaction_id, value ~2.5% remain (estimated)

Drop-off rates in this table are synthesized estimates based on typical B2B SaaS funnels; your numbers will vary. The key insight is that the jbai_session_id must appear on every row, or the join in BigQuery will fail for that user.

BigQuery Export and First-Touch Query

Enable GA4's BigQuery export in your property settings. Daily exports are free; streaming exports require GA4 360. For the purpose of attribution analysis, daily exports are sufficient because you are joining historical sessions, not doing real-time alerting.

The core attribution query selects the first-touch source for each jbai_session_id and joins it to purchase events:


WITH first_touches AS (
  SELECT
    (SELECT value.string_value FROM UNNEST(user_properties)
     WHERE key = 'jbai_session_id') AS session_id,
    (SELECT value.string_value FROM UNNEST(user_properties)
     WHERE key = 'first_touch_source') AS first_touch_source,
    (SELECT value.string_value FROM UNNEST(user_properties)
     WHERE key = 'first_touch_medium') AS first_touch_medium,
    MIN(event_timestamp) AS first_seen_at
  FROM `your-project.analytics_XXXXXXXXX.events_*`
  WHERE event_name = 'page_view'
    AND _TABLE_SUFFIX BETWEEN '20240101' AND '20241231'
  GROUP BY 1, 2, 3
),
purchases AS (
  SELECT
    (SELECT value.string_value FROM UNNEST(user_properties)
     WHERE key = 'jbai_session_id') AS session_id,
    (SELECT value.double_value FROM UNNEST(event_params)
     WHERE key = 'value') AS revenue,
    (SELECT value.string_value FROM UNNEST(event_params)
     WHERE key = 'transaction_id') AS transaction_id,
    event_timestamp AS purchased_at
  FROM `your-project.analytics_XXXXXXXXX.events_*`
  WHERE event_name = 'purchase'
    AND _TABLE_SUFFIX BETWEEN '20240101' AND '20241231'
)
SELECT
  ft.first_touch_source,
  ft.first_touch_medium,
  COUNT(DISTINCT p.transaction_id) AS conversions,
  SUM(p.revenue) AS total_revenue,
  AVG(TIMESTAMP_DIFF(
    TIMESTAMP_MICROS(p.purchased_at),
    TIMESTAMP_MICROS(ft.first_seen_at),
    DAY)) AS avg_days_to_convert
FROM purchases p
JOIN first_touches ft ON p.session_id = ft.session_id
GROUP BY 1, 2
ORDER BY total_revenue DESC;

This query produces the first-touch attribution table your finance team can trust, showing revenue by AI source next to organic, paid, and direct channels.

Paddle and Stripe Webhook Enrichment

GA4 captures behavioral intent but not confirmed revenue. Payment platforms are the authoritative source of revenue truth. The integration strategy is to pass jbai_session_id into the payment session metadata, then enrich payment webhook payloads with the first-touch attribution object stored in localStorage at checkout time.

Passing Attribution to Stripe

When you create a Stripe Checkout session server-side, include the jbai_session_id in the metadata field and in the client_reference_id field. Your front-end reads the value from localStorage and sends it to your server alongside the checkout request:


// Front-end: read from localStorage and POST to your server
var firstTouch = getFirstTouch();
fetch('/api/create-checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    price_id: selectedPriceId,
    jbai_session_id: firstTouch ? firstTouch.session_id : null,
    first_touch_source: firstTouch ? firstTouch.utm_source : null,
    first_touch_medium: firstTouch ? firstTouch.utm_medium : null
  })
});

// Server-side (Node.js example): pass to Stripe
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: price_id, quantity: 1 }],
  client_reference_id: jbai_session_id,
  metadata: {
    jbai_session_id: jbai_session_id,
    first_touch_source: first_touch_source,
    first_touch_medium: first_touch_medium
  },
  success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://example.com/pricing'
});

When Stripe fires a checkout.session.completed webhook, your webhook handler can read metadata.jbai_session_id and metadata.first_touch_source directly. Store these in your database alongside the Stripe subscription ID. This gives you a payment-platform-confirmed revenue record linked to your AI citation attribution data.

Passing Attribution to Paddle

Paddle's v2 API (Paddle Billing) accepts custom data objects on both transactions and subscriptions. The pattern is analogous to Stripe's metadata approach:


// Paddle Billing: create transaction with custom_data
const transaction = await paddle.transactions.create({
  items: [{ price: { id: priceId }, quantity: 1 }],
  custom_data: {
    jbai_session_id: jbai_session_id,
    first_touch_source: first_touch_source,
    first_touch_medium: first_touch_medium
  }
});

Paddle's transaction.completed webhook includes the custom_data object, allowing your webhook handler to extract attribution fields and write them to your data warehouse.

Joining Stripe and Paddle Records to GA4 BigQuery

Once both systems write jbai_session_id to your data warehouse, the join is trivial. Create a payments table with columns transaction_id, jbai_session_id, first_touch_source, revenue_usd, payment_provider, and created_at. Your attribution query joins this table to the GA4 BigQuery export via jbai_session_id, producing a single view of the full funnel from AI citation to confirmed revenue, regardless of whether payment was processed through Paddle or Stripe.

Attribution When the AI Citation Is Not the First Touch

The scenario this guide is named for: the AI citation appears on page 3 of a five-session user journey. The user's first touch was a Google organic search two weeks ago. The AI citation in session 3 reactivates interest and ultimately drives conversion. Standard first-touch attribution gives the organic search full credit. Standard last-touch gives the direct session that converted full credit. Neither model surfaces the AI citation's contribution.

The correct model for this case is a custom multi-touch scheme implemented in your data warehouse, not in GA4. Because your BigQuery export contains every event for every user (joined via user_pseudo_id or, preferably, a server-side user ID), you can reconstruct the full session sequence and assign fractional credit to each touchpoint. A linear model divides revenue equally across all identified touchpoints. A time-decay model weights later touchpoints more heavily. A position-based model gives 40% to first touch, 40% to last touch, and divides the remaining 20% among middle touchpoints.

For AI citation traffic specifically, a position-based model that elevates the "reactivation" touchpoint (the session immediately after a gap of more than 7 days) captures the AI citation's role more accurately than a pure linear or time-decay model. Implement this by tagging sessions with a is_reactivation boolean when the gap from the previous session exceeds your threshold, then including that flag in your multi-touch credit allocation logic.

The localStorage first-touch record does not help with this multi-touch analysis because it stores only one touchpoint. The multi-touch analysis must be done in BigQuery using the full session history, with jbai_session_id as the user identifier and GA4's event_timestamp as the sequencing key.

Common Implementation Errors and How to Fix Them

Several failure modes appear consistently when teams deploy this stack for the first time. Understanding them before deployment saves significant debugging time.

First, the localStorage write fires after the GA4 page_view event. Because both run on page load, the sequence matters. Ensure captureFirstTouch() runs synchronously before your gtag configuration block, not in a deferred or async script. If you use a Tag Manager container, set the first-touch script to fire on the DOM Ready trigger, which precedes the gtag Page View trigger.

Second, the Stripe success URL does not include the GA4 measurement ID in the return URL. Stripe's redirect strips all parameters from the page the user lands on unless you explicitly include them in success_url. Include ?payment=success as a minimum parameter so GA4 can differentiate the success pageview from a normal visit, and rely on localStorage to restore first-touch attribution rather than trying to thread UTMs through the Stripe redirect.

Third, privacy-first browsers (Safari ITP, Brave) aggressively clear localStorage for cross-site navigation sequences. A user who bounces from your site to the AI interface and back within the same session is unlikely to be affected; ITP's localStorage clearing targets sites that are not in the same eTLD+1 as the navigation origin. However, users on Brave with aggressive shield settings may see localStorage cleared between sessions. Monitor your jbai_session_id null rate in BigQuery; if it exceeds 15% of purchase events, consider supplementing localStorage with a server-side cookie set via your own domain.

FAQ

Frequently Asked Questions

Q: Does GA4 natively support first-touch attribution across multiple sessions?
A: GA4's built-in attribution models (last click, first click, data-driven) operate at the property level and are applied in reporting, not at the event level. They cannot be exported to BigQuery with attribution already applied. You must implement first-touch capture yourself, using the localStorage pattern described here, and apply attribution logic in BigQuery or a downstream BI tool.
Q: What happens to first-touch attribution if the user clears their browser storage?
A: The localStorage record is lost, and any subsequent session will be treated as a fresh user if it arrives without UTM parameters. For high-value users, supplement the client-side record with a server-side first-touch record written at the time of email capture or account creation, joining on the user's email or server-assigned user ID rather than the client-generated session ID.
Q: Can I use the same session stitching approach for ChatGPT and Claude citations?
A: Yes. The referrer detection script includes chatgpt.com and claude.ai. The URL parameter convention uses utm_source=chatgpt or utm_source=claude. The localStorage and Paddle/Stripe enrichment logic is identical regardless of which AI platform cited your content.
Q: How do I verify that Stripe is correctly receiving the jbai_session_id in metadata?
A: In the Stripe Dashboard, navigate to Payments, select a test checkout session, and inspect the Metadata section. You should see jbai_session_id populated with the value from your localStorage record. Also check the Events tab for that session to confirm the checkout.session.completed webhook includes the metadata fields.
Q: Does Paddle support custom_data in the same way for both its Classic and Billing products?
A: Paddle Classic (the older product) uses passthrough as a string field rather than a structured custom_data object. If you are on Paddle Classic, serialize your attribution object as JSON and store it in the passthrough field: passthrough: JSON.stringify({ jbai_session_id: ..., first_touch_source: ... }). Paddle Billing (v2) supports the structured custom_data object as described in this article.
Q: What sample size do I need before AI citation attribution data is statistically reliable?
A: For conversion rate comparisons between AI citation traffic and other channels, you need at minimum 30 conversion events per channel per time period to make directional conclusions. For revenue attribution totals, smaller samples are meaningful as long as you report confidence intervals. Most sites with fewer than 50 AI-attributed conversions per month should treat attribution data as directional rather than precise.
Q: Does this approach require a consent banner update for GDPR compliance?
A: Almost certainly yes. Storing a persistent session identifier in localStorage for cross-session tracking constitutes processing of personal data under GDPR in most interpretations. You must disclose this in your privacy notice, include it in your consent management platform's purpose definitions, and conditionally execute the captureFirstTouch() function only after consent is granted. Do not deploy this without reviewing with qualified legal counsel.

Sources and Further Reading