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() }),
);
}
Two details matter here:
- Organic traffic (search, Pinterest) has no UTMs, so I also keep
referrer_hostand thelandingpath. - Wrap storage access in
try/catch. Private mode can makelocalStoragethrow.
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];
}
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 }
: {}),
},
}],
}),
},
);
}
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:
- Verify the signature first. Return 401 on failure.
-
Make it idempotent. Providers redeliver events. I build an event key (
eventType:eventId) and let the backend de-duplicate in the database. An in-memorySetbreaks with multiple instances or restarts. - Return 500 on transient failures so the provider retries. A paid order that never grants access is the worst bug you can have.
- Return 200 for permanent failures (like "user not found"), and alert a human. Retrying won't fix it.
- 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");
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)