Key takeaways
- The setup has four layers: AskSpot provides layer 0 (widget) and 1 (events); you build layers 2–4 (dataLayer bridge, GTM, GA4).
- By default, load the widget through GTM; for large stores a hybrid is better (widget from the front, bridge from GTM).
conversationIdis the key that ties everything together – it is created on the first message, not when the bubble opens.- A ready-made dataLayer bridge (Custom HTML tag in GTM) gets measurement running in minutes; the full production script is in the reference companion.
- GA4 will always show fewer conversations than the AskSpot panel (consent, adblockers) – that is normal, not a bug.
Why do this – what you actually measure
The AskSpot chat widget has its own analytics panel – conversation counts, question categories, resolutions. But that is the chat’s own view. Google Analytics gives you the shopper-session view: where they came from, what they browsed before the chat, whether they bought after it, how much they spent. Connect the two and you answer questions neither can answer alone:
| Business question | What you need |
|---|---|
| What % of traffic touches the chat at all? | askspot_widget_open / all sessions |
| Do people who chat convert better? | segment “sessions with askspot_conversation_started” vs the rest |
| How much revenue runs through the chat path? | purchase in the chat segment |
| Which pages need the chat most? | page_location on askspot_conversation_started |
| Does the chat save abandoned carts? | path askspot_* → begin_checkout → purchase |
| Which specific conversations ended in a purchase? | conversationId passed to purchase |
| How deep are the conversations? | new_user_action counter per conversation |
Architecture: four layers
The whole setup is four layers, one on top of the other:
| Layer | What it is | Who builds it |
|---|---|---|
| 0 – widget loading | The AskSpot embed script (frontend / GTM / Custom Pixel) creates window.AskWidget[widgetId]. | AskSpot |
| 1 – widget event API | widget.addEventListener("new_user_action", cb) and widget.sessionInfo.conversationId. | AskSpot |
| 2 – the bridge to the dataLayer | Your script maps widget events to your names: window.dataLayer.push({ event: "askspot_...", ... }). | You |
| 3 – Google Tag Manager | A Custom Event trigger + Data Layer Variables + a GA4 tag. | You |
| 4 – Google Analytics 4 | Events + custom dimensions + key events + explorations. | You |
The key principle: AskSpot provides only layers 0 and 1. Layers 2–4 are on your side – and that is deliberate. We do not impose event names, a payload format, or a convention. Match them to what you already have in GA4.
If you would rather we build layer 2 (the bridge) inside our custom script, that is possible – let’s arrange it individually. By default we assume you do it yourself, so you keep control of the naming.
Layer 0 – how you load the widget
The embed script looks like this (you get the integration ID from us – it is unique per widget):
<script
crossorigin="anonymous"
async
src="https://chat.askspot.io/api/v1/integration/{YOUR_INTEGRATION_ID}/embed-script"
></script>
There are three sensible places to put it. The choice has real analytics consequences.
Comparing the methods
| A. Directly in the frontend | B. Through GTM (Custom HTML) | C. Shopify Custom Pixel | |
|---|---|---|---|
| Adblocker resistance | ✓ highest | ⚠ lowest – adblockers block gtm.js entirely | ⚠ depends on the pixel |
| Consent handling | ✗ you do it yourself | ✓ built in (Consent Mode / Cookiebot in GTM) | ✓ native Shopify consent API |
| Reach across the path | where your code runs | ✓ everywhere the container runs | only where the pixel runs |
| Works on Shopify checkout | ✗ no | ⚠ only with Checkout Extensibility / Shopify Plus | ✓ yes (but in a sandbox) |
| Widget renders | ✓ yes | ✓ yes | ✗ no (sandbox = no access to the page DOM) |
| Time to ship a change | front deploy | ✓ container publish | pixel publish |
| Duplication risk | – | ⚠ if you also leave the frontend one | ⚠ if you also leave the frontend one |
Recommendation
Default: GTM (option B). Why:
- Consent is already solved – the GTM container usually knows Cookiebot / your CMP and respects Consent Mode. You don’t duplicate consent logic in three places.
- GTM works across the whole path, so you have one source of truth.
- Changes without a front deploy.
The conscious cost: adblockers that block googletagmanager.com will block the chat too. On a typical B2C store that is a few to low-double-digit percent of traffic. If the chat is a critical part of customer service, not just an “add-on”, consider a hybrid.
Hybrid (recommended for large stores): load the widget directly in the frontend (resistance), and the dataLayer bridge from GTM (consent + flexibility). The bridge only listens anyway – if GTM is blocked, the chat still works, you lose only the measurement.
Don’t duplicate
If you’re moving from the frontend to GTM – remove the script from the frontend. Two embed scripts loaded in parallel means:
- two entries in
window.AskWidget→ the bridge may bind to the wrong one, - double listeners → events counted twice in GA4,
- potentially two widgets in the DOM.
If you must have both temporarily, guard with document.getElementById("askspot-script") before adding a second one (see the reference companion) and be sure to use the window.askSpotDataLayerEventsBound flag so the bridge binds only once.
Hiding the widget on specific pages
On the cart, checkout and thank-you page the widget usually gets in the way. Don’t solve it by “not loading the script” (you’ll lose measurement) – ask AskSpot for a display rule on our side. Then the script runs, analytics fires, and the bubble doesn’t show.
If you want to hide the widget on specific URLs, send us a list of patterns – we set it up in the integration config. No changes to your code.
Layer 1 – the widget event API
Once the script loads, the widget exposes a global API:
window.AskWidget = {
"<widgetId>": {
sessionInfo: {
chatId: "...",
conversationId: "...", // null before the first message
sessionExpireTime: 1234567890,
isMinimized: false
},
addEventListener: (eventName, callback) => void,
openChat: () => void,
closeChat: () => void,
minimizeChat: () => void,
changeLabel: (label) => void
}
}
How to bind
const widgetId = Object.keys(window.AskWidget)[0];
const widget = window.AskWidget[widgetId];
widget.addEventListener("new_chat_message", (event) => {
const conversationId = widget.sessionInfo?.conversationId;
console.log("AI replied in conversation", conversationId, event);
});
Four things to keep in mind
- Event names are strings, not constants. To
addEventListeneryou pass a string value ("showNotification"), not the key name from an internal enum (add_notification). Stick to the exact names in the “Widget event catalog” table. - The widget loads asynchronously. Your script (especially from GTM) will almost certainly start before
window.AskWidgetexists. You need a retry with an interval – not a singlesetTimeout. In the examples we usesetInterval(500ms)capped at 60 tries (30 seconds). That’s safe, because events only fire after user interaction anyway. - The payload is sometimes a string. On some paths (iframe forwarding) the payload arrives as a JSON string, not an object. Unpack it defensively before you use it (pattern below).
- Read
conversationIdfromwidget.sessionInfoat event time – don’t cache it when you attach the listener, because it doesn’t exist yet.
const data = typeof event?.data === "string"
? JSON.parse(event.data)
: event?.data;
Widget event catalog
Public events (use these)
| Event | When it fires | Payload | Analytics use |
|---|---|---|---|
chat_loaded | Chat bundle finished loading after opening | – | Performance diagnostics |
chat_opened | Chat was opened | – | Interest – top of the chat funnel |
chat_closed | Chat closed, conversation/UI state cleaned up | – | End of interaction |
minimize_chat | Chat minimized (not closed) | – | “Set aside” vs “done” |
new_user_action | User sent a message / took an action | { actionName } | Conversation start & depth |
new_chat_message | AI replied (new assistant response) | { awaitingUserResponse } | Turn count, responsiveness |
session_update | Session change | { chatId, conversationId, sessionExpireTime, isMinimized } | Source of conversationId |
agent_mode_enabled | Agent (copilot) mode activated | { source } / { widgetId } | Advanced-feature use |
agent_mode_disabled | Agent mode turned off | { source, reason? } | as above |
showNotification | Proactive notification / nudge shown | { content, actions, durationMs } | Proactive exposure – CTR denominator |
hideNotification | Notification hidden | as above | – |
custom_open | Hook: if you have a listener, a bubble click fires this instead of the default open | – | Custom open logic |
Six usually suffice: chat_opened, new_user_action, new_chat_message, minimize_chat, chat_closed, session_update.
Internal events (do NOT bind to these)
These exist on the widget’s internal event bus but are not part of the public contract. They may disappear or change meaning without warning:
open_chat, close_chat, add_button, remove_button, add_bar, remove_bar, iframe_style, update_label, minimize_notification, end_conversation, floating_button_animation_finished, setup, resize, run_agent, stop_agent, agent_steps_state
To control the widget, use the public methods: openChat(), closeChat(), minimizeChat(), changeLabel(...) – don’t emit internal commands.
Business events from your conversation config
Beyond the above, the widget can emit its own events defined in your integration config (conversationContext.eventAction) – e.g. “user clicked a recommended product”, “redirect to a category”. These are the most interesting events from a sales perspective, but they are specific to your configuration.
Ask your AskSpot contact which eventActions are active on your widget. It’s worth asking before you finalise your measurement scope.
conversationId – the key that ties everything together
This is the most important identifier in the whole setup. It is also the key you use to join your data with the conversation analytics in the AskSpot panel.
Lifecycle
| User step | Event | conversationId |
|---|---|---|
| Lands on the page | widget loads | null |
| Clicks the bubble | chat_opened | null ⚠ |
| Sends the FIRST message | new_user_action → session_update | "abc-123" ✓ created here |
| Conversation continues (more messages, navigation) | new_user_action / new_chat_message | "abc-123" (stable) |
| Ends it and starts a new one | new_user_action | "def-456" (NEW) |
Practical consequences
chat_openedandchat_closedbefore the first message have noconversationId. That’s not a bug – the conversation doesn’t exist yet. Don’t patch it by substitutingchatId; mixing identifier spaces is worse than an empty value. Stitch pre-first-message events by your own session identifier (GA4client_id/session_id).conversationIdrotates. One user in one session can have several. If you count “unique chat users”, count byclient_id, notconversationId. If you count “conversations” – byconversationId.conversationIdsurvives page navigation (it lives in the widget’s session memory), but your bridge’ssessionStoragemay not – which is why theconversation_starteddedup must keep the list of seen IDs insessionStorage, not in a JS variable. Without that, every page reload mid-conversation reports another “conversation start” and inflates the metric.
Store it as a User Property (optional)
If you want conversationId available on every later event in the session (including purchase, which you don’t control), set it as a GA4 user property at conversation start. Details in the attribution companion.
Layer 2 – the dataLayer bridge (ready-made script)
This is your code. The script below is an example – match the askspot_* names and the parameter set to your own naming convention in GA4.
Minimal version (start in 5 minutes)
Paste it as a Custom HTML tag in GTM, fired on All Pages (or Initialization – All Pages).
<script>
(function () {
window.dataLayer = window.dataLayer || [];
function init() {
var ids = window.AskWidget ? Object.keys(window.AskWidget) : [];
if (!ids.length) return false;
var widget = window.AskWidget[ids[0]];
function push(name) {
window.dataLayer.push({
event: name,
conversationId: widget.sessionInfo && widget.sessionInfo.conversationId
});
}
// dedup: the same conversation spans many page views - count the start once
function firstInConversation(id) {
if (!id) return false;
var key = "askspot_started_seen";
var list;
try { list = JSON.parse(sessionStorage.getItem(key)) || []; } catch (e) { list = []; }
if (list.indexOf(id) !== -1) return false;
list.push(id);
try { sessionStorage.setItem(key, JSON.stringify(list)); } catch (e) {}
return true;
}
widget.addEventListener("chat_opened", function () { push("askspot_widget_open"); });
widget.addEventListener("minimize_chat", function () { push("askspot_widget_minimize"); });
widget.addEventListener("chat_closed", function () { push("askspot_widget_close"); });
widget.addEventListener("new_user_action", function () {
var id = widget.sessionInfo && widget.sessionInfo.conversationId;
if (firstInConversation(id)) push("askspot_conversation_started");
});
return true;
}
// AskWidget loads async - retry until available (max 30 s)
if (!init()) {
var n = 0, t = setInterval(function () {
if (init() || ++n > 60) clearInterval(t);
}, 500);
}
})();
</script>
Result: askspot_widget_open, askspot_conversation_started, askspot_widget_minimize, askspot_widget_close reach the dataLayer – each with a conversationId.
What the full version adds
The minimal version doesn’t measure conversation depth or proactive nudges. The full production script (in the reference companion) adds:
askspot_user_messagewith amessageIndexcounter – lets you measure average conversation length,askspot_ai_messagewithawaitingUserResponse,askspot_notification_shown– the denominator for proactive-nudge CTR,askspot_agent_mode– agent-mode usage,- double-binding protection (
window.askSpotDataLayerEventsBound), pageLocationon every event (useful when GA4 attributes an event to a different page than the one it actually fired on).
Naming convention – do it once, properly
Before you paste the script, decide the names. Changing them a month later is a hole in your historical data. Recommended pattern: askspot_<object>_<action>, snake_case, always the askspot_ prefix (so one regex in GTM catches everything).
| Widget event | Suggested dataLayer name |
|---|---|
chat_opened | askspot_widget_open |
minimize_chat | askspot_widget_minimize |
chat_closed | askspot_widget_close |
first new_user_action in a conversation | askspot_conversation_started |
every new_user_action | askspot_user_message |
new_chat_message | askspot_ai_message |
showNotification | askspot_notification_shown |
agent_mode_enabled | askspot_agent_mode |
Mind the GA4 limit: free GA4 caps unique event names at 500 per property. Eight names is nothing, but don’t generate names dynamically (e.g. askspot_msg_1, askspot_msg_2) – use parameters.
Layer 3 – Google Tag Manager setup
Variables (Data Layer Variables)
Variables → New → Data Layer Variable. Create one for each parameter you want to use:
| GTM variable name | Data Layer name |
|---|---|
| DLV – conversationId | conversationId |
| DLV – messageIndex | messageIndex |
| DLV – actionName | actionName |
| DLV – awaitingUserResponse | awaitingUserResponse |
| DLV – pageLocation | pageLocation |
Set the Default Value to (not set) where a parameter is sometimes empty (e.g. conversationId on askspot_widget_open). Otherwise GA4 gets undefined and the parameter drops out of the report instead of showing “not set”.
Trigger – one regex for everything
Triggers → New → Custom Event:
- Event name:
^askspot_w+ - ✓ tick Use regex matching
- Fire on: All Custom Events
Name it CE – AskSpot (all). One trigger handles every current and future event with the askspot_ prefix. If you want to fire selected events separately (e.g. only askspot_conversation_started as a conversion), add extra triggers with an exact name match – but keep the catch-all tag too.
GA4 Event tag
Tags → New → Google Analytics: GA4 event.
| Field | Value |
|---|---|
| Configuration tag / Measurement ID | your existing GA4 Configuration tag |
| Event name | {{Event}} |
| Trigger | CE – AskSpot (all) |
Event parameters:
| Parameter name | Value |
|---|---|
conversation_id | {{DLV - conversationId}} |
message_index | {{DLV - messageIndex}} |
action_name | {{DLV - actionName}} |
page_location_custom | {{DLV - pageLocation}} |
Why {{Event}} as the name? Because one tag handles all AskSpot events. The alternative – a separate tag per event – is clearer in an audit, but multiplies the work. At 8 events the catch-all tag wins.
Consent settings on the tag
On the Advanced → Consent Settings tab, set for the bridge tag (Custom HTML) and the GA4 tag: Require additional consent for tag to fire: analytics_storage. GTM then holds the tag until consent is granted, with no logic in your code. Details in the “Consent” section.
Load order
If you load both the widget and the bridge from GTM, set:
- Tag AskSpot – embed script (Custom HTML) → trigger Initialization – All Pages
- Tag AskSpot – dataLayer bridge (Custom HTML) → trigger All Pages; under Advanced → Tag Sequencing tick “Fire a tag before this tag fires”: AskSpot – embed script
In practice the retry in the bridge handles it anyway, but explicit ordering shortens the time to the first event.
Layer 4 – Google Analytics 4
Events alone aren’t enough in GA4. Without the steps below, the parameters won’t appear in reports.
Register custom dimensions
Admin → Custom definitions → Create custom dimension:
| Display name | Scope | Event parameter |
|---|---|---|
| AskSpot Conversation ID | Event | conversation_id |
| AskSpot Message Index | Event | message_index |
| AskSpot Action Name | Event | action_name |
Dimensions work only from the moment you create them. GA4 doesn’t backfill. Register them the same day you publish the GTM container – otherwise you lose the first days of data. Limit: 50 Event-scoped dimensions in standard GA4. Check how many you have free before adding three.
Mark key events
Admin → Key events → New key event: askspot_conversation_started – this is your main chat micro-conversion. Do not mark askspot_user_message or askspot_ai_message as key events – they fire many times per session and distort the conversion rate.
Audiences – the foundation for attribution
Admin → Audiences → New audience → Create a custom audience:
- Audience “Chat users”: condition – event
askspot_conversation_startedoccurred at least once; membership 30 days. - Audience “Opened, didn’t write”: condition –
askspot_widget_open≥ 1 ANDaskspot_conversation_started= 0. Useful for judging whether the bubble promises something the chat doesn’t deliver.
Audiences start collecting from the moment you create them – same as dimensions. Create them right away.
Reports and explorations
A. Chat funnel (Funnel exploration)
| Step | Condition |
|---|---|
| 1 | session_start |
| 2 | askspot_widget_open |
| 3 | askspot_conversation_started |
| 4 | add_to_cart |
| 5 | begin_checkout |
| 6 | purchase |
Set an open funnel (not closed) – a shopper may add to cart before chatting – and split by Device category and Session default channel group.
What it tells you: where people drop off. A big drop between step 2 and 3 = the bubble draws people in but the first message is too hard (consider starter prompts). A big drop between 3 and 4 = conversations don’t lead to the product.
B. Conversion: chat vs no chat
Explore → Free-form.
- Rows: Session default channel group
- Values: Sessions, Purchases, Revenue, Session conversion rate
- Comparison segments: A = sessions containing
askspot_conversation_started; B = sessions not containing it
This is not proof of causation. People who chat are, by definition, more engaged – they’d buy more often without the chat too. This table shows correlation, and must be presented that way. Hard proof needs an A/B test (see the attribution companion).
C. Where conversations start
Explore → Free-form.
- Rows: Landing page or Page location
- Filter: event name
askspot_conversation_started - Values: Event count
The pages with the most conversation starts are where the content doesn’t answer the questions – a direct input for content and UX.
D. Conversation depth
- Rows: AskSpot Message Index
- Values: Event count
The distribution shows which message people drop off at. If 70% of conversations end on the first message, either the chat answers perfectly first time or it answers so badly the user gives up – cross it with the AskSpot panel (resolution) to settle which.
E. Path after a conversation (Path exploration)
Explore → Path exploration. Starting point = event askspot_conversation_started; steps +1, +2, +3 = event names. Shows what users actually do right after a conversation. If page_view on a category dominates, the chat is driving traffic; if session_end does, it isn’t.
Export to BigQuery (advanced)
GA4 samples and aggregates. If you want to compute the chat’s real revenue impact per conversation, connect BigQuery Export (free in standard GA4) and join on conversation_id with the conversation export from the AskSpot panel. It’s the only way to analyse without cardinality limits.
Attribution: does the chat sell? → once the reports are in place, the four levels of sales attribution – plus the full production scripts and a KPI glossary – are in the advanced companion.
Consent, Consent Mode, Cookiebot
The principle
The AskSpot chat has two natures and two consent regimes:
| Function | Nature | Consent |
|---|---|---|
| The chat widget itself (customer service) | usually functionally necessary | usually needs no analytics consent |
| Sending events to GA4 / the dataLayer | analytical | ✓ requires analytics_storage |
The askspot_cid attribution cookie | analytical | ✓ requires analytics_storage |
This is a practical classification, not legal advice. Settle the final classification of the chat in your CMP with your data-protection officer. If you need it, we have ready wording for the terms and privacy policy covering the chat integration – ask your contact.
Recommendation: leave it to GTM
The cleanest solution: don’t write consent logic in your code. Instead:
- Make sure your CMP (Cookiebot, Consentmanager, OneTrust…) runs in Google Consent Mode v2 and is deployed in GTM.
- On the AskSpot tags (bridge + GA4) set Advanced → Consent Settings → Require additional consent:
analytics_storage. - Done. GTM holds the tags until consent and fires them once it’s granted (thanks to Consent Initialization).
Benefits: one place to manage, consistency with the rest of your measurement, no drift risk when the CMP changes.
If you must check consent in code
Sometimes it’s necessary – e.g. in a Shopify Custom Pixel, where GTM can’t reach. A Cookiebot pattern in the Shopify sandbox:
const getCookiebotConsent = async () => {
try {
const cookieString = await browser.cookie.get('CookieConsent');
if (!cookieString) return null;
return {
marketing: cookieString.includes('marketing:true'),
statistics: cookieString.includes('statistics:true'),
preferences: cookieString.includes('preferences:true')
};
} catch (e) {
return null;
}
};
async function loadIfAnalyticsConsentGranted() {
const consent = await getCookiebotConsent();
if (consent?.statistics) loadAskSpot();
}
Three traps with this approach:
- String parsing is brittle.
includes('statistics:true')stops working if Cookiebot changes the cookie format. Treat it as temporary. - No reaction to a consent change. The code above checks consent once. If the user accepts cookies after the code runs, nothing happens. Add a listener for your CMP’s consent-change event and retry.
- By default, no consent = no chat. If you classified the chat as functionally necessary, blocking it under
statisticsis excessive – then block only the bridge, not the widget itself.
Consent Mode v2 – what happens without consent
With Consent Mode v2 and a consent refusal, GA4 sends cookieless pings – without identifiers, but counted in conversion modelling. AskSpot events will not be sent if the tag requires analytics_storage. The result: your conversation numbers in GA4 will be lower than in the AskSpot panel. That’s expected and correct.
On Shopify? The storefront works exactly as above; the checkout runs in an isolated Custom Pixel sandbox with its own rules. See AskSpot analytics on Shopify.
Debugging and the QA checklist
Check layer 1 (widget) – browser console
// 1. Is the widget there at all?
Object.keys(window.AskWidget || {});
// expected: ["<some-id>"] - if [] the script didn't load
// 2. Attach a logger to everything
(function () {
var w = window.AskWidget[Object.keys(window.AskWidget)[0]];
["chat_loaded","chat_opened","chat_closed","minimize_chat",
"new_user_action","new_chat_message","session_update",
"agent_mode_enabled","agent_mode_disabled","showNotification"]
.forEach(function (name) {
w.addEventListener(name, function (e) {
console.log("[AskSpot]", name, e, "cid:", w.sessionInfo && w.sessionInfo.conversationId);
});
});
console.log("logger attached");
})();
// 3. Check session state
window.AskWidget[Object.keys(window.AskWidget)[0]].sessionInfo;
Write a message to the chat and watch the console. You should see new_user_action → session_update (with conversationId) → new_chat_message.
Check layer 2 (dataLayer)
// live preview of every push with the askspot_ prefix
(function () {
var orig = window.dataLayer.push;
window.dataLayer.push = function () {
var a = arguments[0];
if (a && typeof a.event === "string" && a.event.indexOf("askspot_") === 0) {
console.log("[dataLayer]", a);
}
return orig.apply(window.dataLayer, arguments);
};
console.log("dataLayer tap active");
})();
// history (what already fired)
window.dataLayer.filter(function (x) {
return x.event && String(x.event).indexOf("askspot_") === 0;
});
Check layers 3 and 4
- GTM Preview (Tag Assistant): turn on preview, walk the path, check that
askspot_*events appear in the left panel and the GA4 tag shows Fired. Click the tag → Values → check thatconversation_idisn’tundefined. - GA4 DebugView: Admin → DebugView. Needs an active GTM Preview or the GA Debugger extension. Expand an event and check the parameters.
- GA4 Realtime reports: Reports → Realtime → Event count by event name. Delay up to ~1 min.
- Standard reports: data appears after 24–48 h. Don’t panic before then.
Pre-publish checklist
Layer 0 – loading
- The AskSpot script loads exactly once (Network → filter
embed-script, one entry). Object.keys(window.AskWidget).length === 1.- After moving from the frontend to GTM: the old script is removed from the theme / code.
- The widget doesn’t show on the cart / checkout / thank-you page (rule on the AskSpot side).
Layers 1–2 – events and bridge
askspot_widget_openfires on bubble open.askspot_conversation_startedfires on the first message.askspot_conversation_starteddoes not fire again after a page reload mid-conversation ← the most common bug.conversationIdis non-empty onaskspot_conversation_started.- The bridge doesn’t attach listeners twice (
window.askSpotDataLayerEventsBound === true). - Events fire on all page types (home, category, product, cart).
Layers 3–4 – GTM and GA4
- All DLV variables have a Default Value (
(not set)). - The
^askspot_w+trigger regex catches all events. - The GA4 tag has
analytics_storageset in Consent Settings; on a consent refusal the tags do not fire (test in incognito). - Custom dimensions and the key event are created before publishing the container.
- After 48 h the GA4 numbers are the same order of magnitude as the AskSpot panel (a 10–40% shortfall is normal).
Shopify – additionally: the Custom Pixel deployed on a test shop first; verified whether the sandbox blocks access to conversation memory (whether conversationId is available on the checkout); if it blocks – a fallback to the askspot_cid cookie on the parent domain enabled; a test purchase links to a conversation (confirmed on the AskSpot side – message your contact with the date and time of the test).
Verification on the AskSpot side. We store conversations and conversions on our side. After your test we can confirm whether purchases link correctly to conversations – even if something isn’t visible on your end. Write to your contact with the date and approximate time of the test.
The most common implementation mistakes
- A duplicated script after moving to GTM. A script left in the frontend + a new one in GTM = doubled events. Symptom: twice as many conversations in GA4 as in the panel. Always remove the old one.
conversation_startedcounted on every page view. No dedup byconversationIdinsessionStorage. Symptom: “conversation starts” ≈ “user messages”. Fix withfirstInConversation.- Caching
conversationIdwhen attaching the listener.const cid = widget.sessionInfo.conversationIdoutside the callback returnsnullforever. Read it inside the callback. - Custom dimensions created after the fact. GA4 doesn’t backfill. Two weeks of data without
conversation_idis two weeks lost. - No Default Value on the DLV variables.
undefinedmeans the parameter doesn’t reach GA4 at all. - One try instead of a retry.
setTimeout(init, 1000)works on a fast connection and fails on 3G. AlwayssetIntervalwith a cap. - Checking consent only once. The user accepts cookies after 3 s, the code checked at 1 s → no measurement for the whole “late acceptors” group. Listen for the consent change.
- Treating GA4 as the source of truth for billing. It will always be lower. The AskSpot panel is what you invoice from.
- Renaming events mid-flight.
askspot_chat_start→askspot_conversation_startedafter a month = two unrelated data sets. Settle the convention before you start. - Drawing causal conclusions from segments. “Chat sessions convert 3× better” doesn’t mean “chat triples conversion”. Without an A/B test, say correlation.
Full scripts & KPI glossary → the complete production dataLayer bridge, a React hook and a KPI glossary are in the reference companion.
What this guide doesn’t settle
A few things depend on your specific install and need checking with us or a test:
- Which business events (
conversationContext.eventAction) are active on your widget – ask your AskSpot contact. - Whether the Shopify Custom Pixel sandbox allows access to conversation memory – it needs a test on your install.
- The AskSpot conversions API documentation (the server-side variant from Level 3) – available on request.
- Rules for hiding the widget on specific URLs – we configure this on our side, send a list of patterns.
- How to classify the chat in your CMP (functionally necessary vs analytical) – your DPO’s decision; we have ready wording for the privacy policy.
Want to see exactly what you’re measuring? See the AI Chat Agent page – and if you also run the inbox, the AI Inbox Support Agent.
Why does GA4 show fewer conversations than the AskSpot panel?
Because some traffic declines analytics consent, and adblockers block GTM/GA4. Under Consent Mode v2, AskSpot events don’t fire without analytics_storage consent. A 10–40% shortfall is normal and correct – the AskSpot panel, not GA4, is what you invoice from.
When is the conversationId created?
On the user’s first message (new_user_action), not when the bubble opens. chat_opened before the first message has conversationId = null – that’s not a bug, the conversation doesn’t exist yet.
Does the chat really lift conversion?
The “chat vs non-chat sessions” segment shows correlation, not causation – people who chat are, by definition, more engaged. Hard proof of “chat delivers X” comes only from an A/B test with a stable control group.
Do I have to write code, or does AskSpot do it?
AskSpot provides layers 0 and 1 (widget + events). You build layers 2–4 (bridge, GTM, GA4) so you control the naming. If you’d rather we build the bridge inside our custom script, that’s possible – let’s arrange it individually.








