Track Zendesk Messaging & Web Widget
Zendesk ships two different chat products on the same brand: Messaging (the modern async successor) and the Web Widget (Classic). They expose different event APIs. This recipe covers both so you can drop in whichever matches your install.
Valid as of April 2026, Zendesk Web Widget (Classic) and Zendesk Messaging (zE API).
Problem
Section titled “Problem”Zendesk’s reporting tells you about ticket volume but not about on-site behaviour leading up to a chat. Which product pages drive support tickets? Which campaigns produce high-cost conversations? A dataLayer bridge surfaces those questions in GA4 alongside everything else.
Solution
Section titled “Solution”Both products expose a global zE (or window.zE) function. Attach listeners once Zendesk signals ready, and push a dedicated dataLayer event for each lifecycle change.
Implementation
Section titled “Implementation”Messaging uses the zE('messenger:on', event, handler) signature. Attach in a Custom HTML tag.
(function() { if (typeof window.zE !== 'function') return;
function push(payload) { window.dataLayer = window.dataLayer || []; window.dataLayer.push(payload); }
// Widget opened window.zE('messenger:on', 'open', function() { push({ event: 'zendesk_widget_open', zendesk_product: 'messaging', zendesk_action: 'open', page_path: window.location.pathname }); });
// Widget closed window.zE('messenger:on', 'close', function() { push({ event: 'zendesk_widget_close', zendesk_product: 'messaging', zendesk_action: 'close' }); });
// Unread messages count changed window.zE('messenger:on', 'unreadMessages', function(count) { push({ event: 'zendesk_unread_message', zendesk_product: 'messaging', zendesk_action: 'unread_change', zendesk_unread_count: count }); });
// Conversation started window.zE('messenger:on', 'conversationStarted', function() { push({ event: 'zendesk_chat_started', zendesk_product: 'messaging', zendesk_action: 'conversation_started', page_path: window.location.pathname }); });})();Classic uses the zE('webWidget:on', event, handler) signature. Chat and ticket submit fire through different events.
(function() { if (typeof window.zE !== 'function') return;
function push(payload) { window.dataLayer = window.dataLayer || []; window.dataLayer.push(payload); }
window.zE('webWidget:on', 'open', function() { push({ event: 'zendesk_widget_open', zendesk_product: 'classic', zendesk_action: 'open', page_path: window.location.pathname }); });
window.zE('webWidget:on', 'close', function() { push({ event: 'zendesk_widget_close', zendesk_product: 'classic', zendesk_action: 'close' }); });
// Live chat started window.zE('webWidget:on', 'chat:start', function() { push({ event: 'zendesk_chat_started', zendesk_product: 'classic', zendesk_action: 'chat_started' }); });
// Live chat ended window.zE('webWidget:on', 'chat:end', function() { push({ event: 'zendesk_chat_ended', zendesk_product: 'classic', zendesk_action: 'chat_ended' }); });
// Contact form / ticket submitted window.zE('webWidget:on', 'userEvent', function(data) { if (data && data.action === 'Contact Form Submitted') { push({ event: 'zendesk_ticket_submitted', zendesk_product: 'classic', zendesk_action: 'ticket_submitted', zendesk_category: data.category || 'unknown' }); } });})();GTM setup
Section titled “GTM setup”-
Add the listener script as a Custom HTML tag
- Trigger: Window Loaded (or your consent-granted trigger)
- If you run both widgets on different properties, use separate tags gated by a Lookup Table variable on hostname
-
Create a regex Custom Event trigger
- Trigger type: Custom Event
- Event name:
^zendesk_(widget_open|widget_close|chat_started|chat_ended|ticket_submitted|unread_message)$ - Use regex matching: true
-
Create Data Layer Variables
DLV - zendesk_action→zendesk_actionDLV - zendesk_product→zendesk_productDLV - zendesk_unread_count→zendesk_unread_countDLV - zendesk_category→zendesk_category
-
Create a GA4 Event Tag
- Event name:
{{Event}} - Parameters:
zendesk_action→{{DLV - zendesk_action}}zendesk_product→{{DLV - zendesk_product}}page_path→{{Page Path}}
- Trigger: the regex Custom Event trigger
- Event name:
-
Mark
zendesk_ticket_submittedandzendesk_chat_startedas key events in GA4.
GA4 - zendesk_events
- Type
- Google Analytics: GA4 Event
- Trigger
- Custom Event - zendesk_* (regex)
- Variables
-
DLV - zendesk_actionDLV - zendesk_productDLV - zendesk_unread_count
Test it
Section titled “Test it”- Open GTM Preview on a page with the widget installed
- Click the launcher — confirm
zendesk_widget_openwith the correctzendesk_product - Start a chat or conversation — confirm
zendesk_chat_started - (Classic only) Submit a ticket via the contact form — confirm
zendesk_ticket_submitted - Close the widget — confirm
zendesk_widget_close - In GA4 DebugView, verify each event arrives with parameters
Common gotchas
Section titled “Common gotchas”Wrong API signature for your product. Messaging and Classic don’t share event names. zE('webWidget:on', 'chat:start', ...) silently does nothing on Messaging installs. Use the tab that matches your widget — the note at the top shows how to detect it.
SDK not ready at Window Loaded. Zendesk loads asynchronously. zE is usually available by Window Loaded, but async-heavy pages can beat it. Wrap the listener attach in a small retry: if (!window.zE) return setTimeout(attach, 500);
Multiple events for a single user action. Opening the widget often fires both open and (milliseconds later) a conversationStarted if the visitor had an active conversation. That’s expected — treat them as distinct signals or dedupe with a conversation-scoped flag.
userEvent is chatty. In Classic, userEvent fires for many internal UI transitions, not just form submits. Filter by data.action === 'Contact Form Submitted' to catch real ticket submissions, or log all data.action values once to see what your install emits.
Consent gating differs by region. If Zendesk loads only after consent in some regions, the widget and your listener must both wait for the consent-granted event. A global Window Loaded trigger will run too early for those visitors.