DEV Community

Cover image for Why Hosted Checkout Breaks Your Analytics (and How I Fixed Attribution with Webhooks)
Belle AI
Belle AI

Posted on

Why Hosted Checkout Breaks Your Analytics (and How I Fixed Attribution with Webhooks)

When I started selling subscriptions for Belle AI, I added the usual GA4 purchase event on the thank-you page.

It reported almost nothing.

The reason is simple: with a hosted checkout, users pay on the provider's domain and often never return to your site. The browser-side event never fires. Revenue shows up in the payment dashboard but not in analytics, and you can't tell which channel earned it.

Disclosure: I'm the developer of Belle AI.

Here's the setup I ended up with. It has three parts.

Part 1: Capture first-touch attribution on landing

When someone arrives with UTM parameters or from an external site, store it once in localStorage. Don't overwrite it on later visits, otherwise a direct visit next week erases the source that actually brought them.

const ATTR_KEY = "attribution";

export function captureAttribution(): void {
  const params = new URLSearchParams(window.location.search);
  const current = {
    utm_source: params.get("utm_source") ?? undefined,
    utm_medium: params.get("utm_medium") ?? undefined,
    utm_campaign: params.get("utm_campaign") ?? undefined,
    ref: params.get("ref") ?? undefined,
    referrer_host: externalReferrerHost(), // e.g. www.google.com
  };

  const hasSignal = Object.values(current).some(Boolean);
  if (!hasSignal) return;

  const stored = readStored();
  if (stored && (stored.utm_source || stored.referrer_host || stored.ref)) {
    return; // keep the first touch
  }

  localStorage.setItem(
    ATTR_KEY,
    JSON.stringify({ ...current, landing: location.pathname, ts: Date.now() }),
  );
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • Organic traffic (search, Pinterest) has no UTMs, so I also keep referrer_host and the landing path.
  • Wrap storage access in try/catch. Private mode can make localStorage throw.

Part 2: Send attribution with the order

At checkout, I pass the stored attribution plus the GA identifiers into the order's metadata. The payment provider echoes this metadata back in its webhook.

// Read the GA4 client_id from the _ga cookie
export function getGaClientId() {
  const m = document.cookie.match(/(?:^|;\s*)_ga=GA\d+\.\d+\.(\d+\.\d+)/);
  return m?.[1];
}

// Read the current GA4 session id from the _ga_<container> cookie
export function getGaSessionId(measurementId: string) {
  const name = `_ga_${measurementId.replace(/^G-/, "")}`;
  const m = document.cookie.match(
    new RegExp(`(?:^|;\\s*)${name}=GS\\d\\.\\d\\.(?:s)?(\\d+)`),
  );
  return m?.[1];
}
Enter fullscreen mode Exit fullscreen mode

The session_id is what makes this work. Without it, server-side purchases often land in (not set) instead of the real traffic channel.

Part 3: Fire the purchase from the webhook

When the payment provider confirms payment, the webhook handler sends a purchase event through the GA4 Measurement Protocol, reusing the client_id and session_id captured earlier:

async function sendGaPurchase(userId: string, data: any, metadata: Record<string, string>) {
  const apiSecret = process.env.GA_API_SECRET;
  if (!apiSecret) return; // silently skip if not configured

  const value = Number(data.chargedAmount);
  if (!(value > 0)) return;

  await fetch(
    `https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${apiSecret}`,
    {
      method: "POST",
      body: JSON.stringify({
        client_id: metadata.ga_cid || `server.${userId}`,
        user_id: userId,
        events: [{
          name: "purchase",
          params: {
            transaction_id: data.paymentId,
            value,
            currency: data.currency || "USD",
            campaign_source: metadata.utm_source,
            campaign_medium: metadata.utm_medium,
            campaign_name: metadata.utm_campaign,
            referrer_host: metadata.referrer_host,
            landing_page: metadata.landing,
            ...(metadata.ga_sid
              ? { session_id: Number(metadata.ga_sid), engagement_time_msec: 1 }
              : {}),
          },
        }],
      }),
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

One note: custom params like referrer_host and landing_page only show up in GA4 reports after you register them as event-scoped custom dimensions.

The webhook itself needs to be boring and safe

Since money is involved, a few rules I follow:

  1. Verify the signature first. Return 401 on failure.
  2. Make it idempotent. Providers redeliver events. I build an event key (eventType:eventId) and let the backend de-duplicate in the database. An in-memory Set breaks with multiple instances or restarts.
  3. Return 500 on transient failures so the provider retries. A paid order that never grants access is the worst bug you can have.
  4. Return 200 for permanent failures (like "user not found"), and alert a human. Retrying won't fix it.
  5. Only report to analytics on the first delivery. If the backend says "already processed", skip the GA call so you don't double-count revenue.
if (code >= 500 || code === 403) {
  return new Response("Backend processing failed", { status: 500 }); // retry
}
if (code === 404) {
  await alertHuman("user not found", orderId);
  return NextResponse.json({ received: true, warning: "user not found" }); // don't retry
}
if (result.message !== "Event already processed") {
  await sendGaPurchase(userId, data, metadata);
}
return new Response("OK");
Enter fullscreen mode Exit fullscreen mode

Result

Revenue now shows up in GA4 under the actual channel: a Pinterest pin, a search landing page, a directory listing, or a referral code. That changed which channels I invest in.

If you want to see the product this runs on, it's here.

Question for the comments: how do you attribute revenue when checkout happens off-site? Anything better than first-touch in localStorage?

Top comments (0)