Key takeaways
- Three ways to stamp
conversation_idon the purchase event: a GA4 user property,sessionStorage, or a cookie. - Attribution has four levels – from a zero-effort session segment to a hard A/B test.
- The full production dataLayer bridge and a React hook are here, ready to paste.
- Correlation isn’t causation – only the A/B test (level 4) proves the chat’s revenue impact.
Attribution: does the chat sell?
This is the question the whole setup is for. There are four levels – from the simplest to the most solid.
Level 1: session segment (zero extra work)
You have it straight after exploration B in the main guide. Compare the conversion of chat vs non-chat sessions.
- Plus: works immediately, no code changes.
- Minus: strong selection bias.
Don’t say “chat lifts conversion by X%” – say “sessions with a conversation convert X times more often”.
Level 2: conversation_id on the purchase event
You want to know which conversation ended in which order. This requires passing conversation_id to the purchase event, which you usually don’t control directly. Three ways:
Method A – GA4 User Property (simplest). In the bridge, at conversation start, set a user property. In GTM, under GA4 Event tag → User Properties, add:
| User property | Value |
|---|---|
last_conversation_id | {{DLV - conversationId}} |
Register a User-scoped custom dimension for last_conversation_id in GA4. From then on every event from that user – including purchase – carries the dimension.
- Plus: one change in GTM.
- Minus: keeps only the last conversation; not cross-device.
Method B – a GTM variable reading sessionStorage. In the bridge (in the push function or the session_update listener) save the current ID:
try { sessionStorage.setItem("askspot_current_conversation", id); } catch (e) {}
In GTM create a Custom JavaScript variable:
function () {
try {
return sessionStorage.getItem("askspot_current_conversation") || "(not set)";
} catch (e) {
return "(not set)";
}
}
and add it as a conversation_id parameter to your existing purchase tag.
- Plus: works independently of user properties, survives navigation.
- Minus:
sessionStoragedoesn’t cross domains; on a checkout in an isolated sandbox it may be unavailable.
Method C – a cookie on the parent domain (most robust). Instead of sessionStorage, save the ID in a cookie on the parent domain: document.cookie = "askspot_cid=" + encodeURIComponent(id) + "; path=/; domain=.yourdomain.com; max-age=1800; SameSite=Lax";
A cookie with domain=.yourdomain.com is visible on all subdomains – including a checkout that lives on a subdomain of the same parent domain. In GTM you read it with a First-Party Cookie variable.
- Plus: works cross-subdomain.
- Minus: it’s an analytics cookie – it must be subject to
analytics_storageconsent; write it only after consent and cover it in your privacy policy.
Level 3: conversions on the AskSpot side
Independently of your GA4, AskSpot can receive purchase events and tie them to conversations on our side. Then the AskSpot panel shows revenue attributed to conversations, with no GTM config. Two variants:
- Automatic variant. Our script listens for purchase events in your environment (e.g.
checkout_completedin Shopify Customer Events orpurchasein the dataLayer), reads theconversationIdfrom the widget’s memory, and sends the conversion – value + conversation ID – straight to our API. You don’t build anything. - Manual variant. You send conversions to our API yourself, from your backend. It’s the most solid option, because the data goes server-side (resistant to adblockers and to a lack of analytics-cookie consent, provided you have a legal basis). Ask us for the conversions API documentation.
Recommendation: do both. GA4 for behavioural analysis and comparison with the rest of your traffic; the AskSpot API as an independent, server-side source of truth for chat revenue. A gap between them is normal and itself informative (it shows how much data is lost on the frontend).
Level 4: A/B test (the only hard proof)
Everything above measures correlation. If you need a number you can show the board as “chat delivers X”, you need an experiment:
- Split traffic: 90% with the widget, 10% without (control group),
- assign stably per user (cookie), not per session,
- a minimum of 2–4 weeks and a sufficient sample,
- compare revenue per user (not session conversion) between the groups.
You can run the split in GTM (a random variable + cookie) or ask us for help on the integration-config side. Note: turning the chat off for 10% of customers has a support cost – it’s a business decision, not just an analytics one.
Appendix A – the full production script
The extended version: conversation depth, proactive notifications, agent mode, double-binding protection, and an optional cross-subdomain attribution cookie.
<script>
(function () {
"use strict";
// ── CONFIG ────────────────────────────────────────────────────
var PREFIX = "askspot_";
var COOKIE_NAME = "askspot_cid";
var COOKIE_DOMAIN = ""; // e.g. ".yourdomain.com" - empty = off
var COOKIE_TTL = 1800; // seconds
var MAX_RETRIES = 60; // 60 × 500 ms = 30 s
// ──────────────────────────────────────────────────────────────
window.dataLayer = window.dataLayer || [];
if (window.askSpotDataLayerEventsBound) return; // already bound
var messageCounters = {}; // conversationId -> user message counter
function safeGet(key) {
try { return sessionStorage.getItem(key); } catch (e) { return null; }
}
function safeSet(key, val) {
try { sessionStorage.setItem(key, val); } catch (e) {}
}
function setConversationCookie(id) {
if (!COOKIE_DOMAIN || !id) return;
try {
document.cookie = COOKIE_NAME + "=" + encodeURIComponent(id)
+ "; path=/; domain=" + COOKIE_DOMAIN
+ "; max-age=" + COOKIE_TTL + "; SameSite=Lax";
} catch (e) {}
}
// dedup conversation start - survives navigation between pages
function firstInConversation(id) {
if (!id) return false;
var key = "askspot_started_seen";
var list;
try { list = JSON.parse(safeGet(key)) || []; } catch (e) { list = []; }
if (list.indexOf(id) !== -1) return false;
list.push(id);
safeSet(key, JSON.stringify(list));
return true;
}
function nextMessageIndex(id) {
if (!id) return 1;
var key = "askspot_msgcount_" + id;
var n = parseInt(safeGet(key) || "0", 10) + 1;
safeSet(key, String(n));
messageCounters[id] = n;
return n;
}
function unwrap(event) {
if (!event) return {};
var d = event.data !== undefined ? event.data : event;
if (typeof d === "string") {
try { return JSON.parse(d); } catch (e) { return { raw: d }; }
}
return d && typeof d === "object" ? d : {};
}
function init() {
var ids = window.AskWidget ? Object.keys(window.AskWidget) : [];
if (!ids.length) return false;
var widget = window.AskWidget[ids[0]];
if (!widget || typeof widget.addEventListener !== "function") return false;
function cid() {
return (widget.sessionInfo && widget.sessionInfo.conversationId) || "";
}
function push(name, extra) {
var payload = {
event: PREFIX + name,
conversationId: cid(),
pageLocation: location.href
};
if (extra) {
for (var k in extra) {
if (Object.prototype.hasOwnProperty.call(extra, k)) payload[k] = extra[k];
}
}
window.dataLayer.push(payload);
}
// ── widget state ──────────────────────────────────────────
widget.addEventListener("chat_opened", function () { push("widget_open"); });
widget.addEventListener("minimize_chat", function () { push("widget_minimize"); });
widget.addEventListener("chat_closed", function () { push("widget_close"); });
// ── user message ──────────────────────────────────────────
widget.addEventListener("new_user_action", function (event) {
var data = unwrap(event);
var id = cid();
if (firstInConversation(id)) {
setConversationCookie(id);
push("conversation_started", { actionName: data.actionName || "" });
}
push("user_message", {
actionName: data.actionName || "",
messageIndex: nextMessageIndex(id)
});
});
// ── AI reply ──────────────────────────────────────────────
widget.addEventListener("new_chat_message", function (event) {
var data = unwrap(event);
push("ai_message", {
awaitingUserResponse: data.awaitingUserResponse === true,
messageIndex: messageCounters[cid()] || 0
});
});
// ── session: refresh cookie when conversationId appears/changes
widget.addEventListener("session_update", function (event) {
var data = unwrap(event);
if (data.conversationId) setConversationCookie(data.conversationId);
});
// ── proactive notifications (denominator for nudge CTR) ────
widget.addEventListener("showNotification", function (event) {
var data = unwrap(event);
push("notification_shown", {
notificationContent: typeof data.content === "string"
? data.content.slice(0, 100)
: ""
});
});
// ── agent mode ────────────────────────────────────────────
widget.addEventListener("agent_mode_enabled", function () { push("agent_mode", { agentModeState: "enabled" }); });
widget.addEventListener("agent_mode_disabled", function () { push("agent_mode", { agentModeState: "disabled" }); });
window.askSpotDataLayerEventsBound = true;
return true;
}
if (!init()) {
var n = 0;
var t = setInterval(function () {
if (init() || ++n >= MAX_RETRIES) clearInterval(t);
}, 500);
}
})();
</script>
Events produced by this script:
| dataLayer event | Parameters |
|---|---|
askspot_widget_open | conversationId, pageLocation |
askspot_widget_minimize | conversationId, pageLocation |
askspot_widget_close | conversationId, pageLocation |
askspot_conversation_started | + actionName |
askspot_user_message | + actionName, messageIndex |
askspot_ai_message | + awaitingUserResponse, messageIndex |
askspot_notification_shown | + notificationContent (trimmed to 100 chars) |
askspot_agent_mode | + agentModeState |
A note on volume. askspot_user_message and askspot_ai_message fire on every conversation turn. At high traffic that’s a significant event volume in GA4 (cardinality limits, BigQuery cost). If you don’t need conversation-depth analysis, comment out those two listeners – the rest works independently.
Appendix B – React version (hook)
For React apps where you load the widget directly from code (the frontend method – see the main guide). The hook handles script loading, binding retry, and cleanup on unmount.
import { useEffect } from "react";
const ASKSPOT_SCRIPT_ID = "askspot-script";
const ASKSPOT_SCRIPT_SRC =
"https://chat.askspot.io/api/v1/integration/{YOUR_INTEGRATION_ID}/embed-script";
type AskSpotEventPayload = Record<string, unknown> | undefined;
type AskSpotWidget = {
sessionInfo?: { conversationId?: string };
addEventListener: (
eventName: string,
callback: (event?: AskSpotEventPayload) => void
) => void;
};
declare global {
interface Window {
AskWidget?: Record<string, AskSpotWidget>;
dataLayer?: Record<string, unknown>[];
askSpotDataLayerEventsBound?: boolean;
}
}
const seenConversationIds = new Set<string>();
function markConversationStarted(conversationId: string) {
if (seenConversationIds.has(conversationId)) return false;
seenConversationIds.add(conversationId);
return true;
}
function pushAskSpotEvent(
eventName: string,
widget: AskSpotWidget,
payload?: AskSpotEventPayload
) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: eventName,
conversationId: widget.sessionInfo?.conversationId,
askspotPayload: payload,
});
}
function bindAskSpotDataLayerEvents() {
if (window.askSpotDataLayerEventsBound) return true;
const widget = Object.values(window.AskWidget || {})[0];
if (!widget) return false;
widget.addEventListener("new_user_action", (event) => {
const conversationId = widget.sessionInfo?.conversationId;
pushAskSpotEvent("askspot_new_user_action", widget, event);
if (!conversationId || !markConversationStarted(conversationId)) return;
pushAskSpotEvent("askspot_conversation_started", widget, event);
});
widget.addEventListener("new_chat_message", (event) => {
pushAskSpotEvent("askspot_new_chat_message", widget, event);
});
window.askSpotDataLayerEventsBound = true;
return true;
}
function scheduleAskSpotDataLayerBinding() {
let attempts = 0;
let intervalId = 0;
const tryBind = () => {
if (bindAskSpotDataLayerEvents() || ++attempts >= 60) {
window.clearInterval(intervalId);
}
};
tryBind();
intervalId = window.setInterval(tryBind, 500);
return () => window.clearInterval(intervalId);
}
export function useAskSpotScript(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
if (document.getElementById(ASKSPOT_SCRIPT_ID)) {
return scheduleAskSpotDataLayerBinding();
}
const script = document.createElement("script");
script.id = ASKSPOT_SCRIPT_ID;
script.async = true;
script.crossOrigin = "anonymous";
script.src = ASKSPOT_SCRIPT_SRC;
let cleanupBinding: (() => void) | undefined;
const handleScriptLoad = () => {
cleanupBinding = scheduleAskSpotDataLayerBinding();
};
script.addEventListener("load", handleScriptLoad);
document.head.appendChild(script);
return () => {
cleanupBinding?.();
script.removeEventListener("load", handleScriptLoad);
};
}, [enabled]);
}
Usage with consent handling:
const analyticsConsent = useConsent("statistics"); // your CMP logic
useAskSpotScript(analyticsConsent);
Differences vs Appendix A:
| Appendix A (vanilla/GTM) | Appendix B (React) | |
|---|---|---|
| Start dedup | sessionStorage – survives a reload | in-memory Set – lost on reload |
| Message counter | yes | no |
| Attribution cookie | yes (optional) | no |
| Script loading | assumes the widget is already there | loads it itself |
| Event names | askspot_user_message, askspot_ai_message | askspot_new_user_action, askspot_new_chat_message |
Unify the names. Appendix B uses names that mirror the widget events 1:1 (askspot_new_user_action), Appendix A uses descriptive names (askspot_user_message). Pick one convention before you start. The ^askspot_w+ regex in GTM catches both, but you’ll see two unrelated event sets in GA4 if you change your mind mid-way.
In an SPA the in-memory Set is enough (no full reloads). With SSR / MPA move the dedup to sessionStorage – otherwise you’ll inflate conversation_started.
Appendix C – KPI glossary
Metrics worth reporting, and how to compute each in GA4.
| KPI | Definition | How to compute |
|---|---|---|
| Chat reach | % of sessions where the bubble was opened | sessions with askspot_widget_open / all sessions |
| Engagement rate | % of opens that ended in a first message | askspot_conversation_started / askspot_widget_open |
| Conversation penetration | % of sessions with a conversation | sessions with askspot_conversation_started / all sessions |
| Average conversation depth | how many turns a typical conversation has | average of message_index per conversation_id |
| Proactive-nudge CTR | notification effectiveness | askspot_widget_open (after notification_shown) / askspot_notification_shown |
| Conversion with chat | CR of sessions with a conversation | purchases in the “chat users” segment / sessions in that segment |
| Uplift (correlational) | how many times better chat sessions convert | CR with chat / CR without chat – don’t call it the chat’s impact |
| Chat-assisted revenue | revenue in sessions with a conversation | purchase revenue in the “chat users” segment |
| Attributed revenue | revenue tied to specific conversations | purchase with a non-empty conversation_id |
| Causal uplift | the chat’s real impact | from an A/B test only |
Recommended starter set (5 metrics): chat reach, conversation penetration, average depth, conversion with vs without chat, chat-assisted revenue. The rest once the first five are stable.








