SDK Events
Who can use this feature?
- Available to all users on any plan.
- New to the SDK? Start with the SDK overview. For commands, see the SDK Reference.
The SDK emits 22 events. Subscribe with on(event, callback), unsubscribe with off(event, callback).
window.$chatty.on('chat:opened', function () { /* ... */ })An event name the SDK does not know is rejected: the callback is never registered and a [ChattySDK] warning goes to the console. Nothing is thrown.
How to read the payload column
Each callback receives one argument. Where the payload column says None, the callback is called with no argument at all, so do not destructure it.
Errors thrown inside your callback are caught and logged. One broken handler does not stop the other handlers or the widget.
Lifecycle
| Event | Payload | Fires when | How often |
|---|---|---|---|
sdk:ready | None | The command layer is live and queued commands have been replayed. | Once per page load. Subscribing later still runs your callback immediately. |
chat:opened | None | The chat window opens, by the visitor or through the SDK. | Every open. |
chat:closed | None | The chat window closes. | Every close. |
sdk:ready means commands are accepted. It does not mean the chat window has rendered. See When your commands run.
Messages
| Event | Payload | Fires when | How often |
|---|---|---|---|
message:sent | { text, type } | The visitor's message is accepted. | Every message the visitor sends. |
message:received | { text, isAdmin, isAutomated } | A reply arrives. | Every reply. |
ai:reply | Same as message:received | A reply arrives with isAutomated true. | Every AI reply. |
human:reply | Same as message:received | A reply arrives with isAutomated false. | Every reply that is not from the AI. |
message:updated | The updated message object | An existing message is edited. | Every edit. |
message:removed | { id } | A message is removed. | Every removal. |
Payload fields:
text— the message body as plain text.type— always'text'. The SDK has no other message type.isAutomated—truefor an AI reply,falsefor a human agent. This is the field to branch on.isAdmin— alwaystrueonmessage:received, because the event only covers replies coming into the widget. It does not tell you whether a human or the AI wrote the reply.id— the removed message ID.
Conversation
| Event | Payload | Fires when | How often |
|---|---|---|---|
conversation:started | { conversationId } | The conversation record is created, which happens when the visitor's first message is submitted. | Once per conversation. Fires again after do session:reset creates the next one. |
conversation:resolved | { conversationId } | The conversation status changes to resolved. | On each change into resolved. |
handoff | { conversationId, memberId } | The conversation moves from the AI to a human agent and an agent has been assigned. | On each handoff. |
conversation:removed | { conversationId } | The conversation record no longer exists. | Once. |
memberId is the ID of the agent who picked the conversation up. handoff needs both conditions: the conversation must leave AI mode and have an assigned agent. A conversation that leaves AI mode without an assignment emits nothing.
Pre-chat and lead capture
| Event | Payload | Fires when | How often |
|---|---|---|---|
prechat:shown | None | The pre-chat form appears on screen. | Each time it appears. |
prechat:submitted | { conversationId } | The visitor submits the pre-chat form. | Once per submission. |
email:captured | { email } | The submitted pre-chat form carried an email. | Alongside prechat:submitted, never on its own. |
survey:submitted | { rating, surveyComment } | The visitor submits a satisfaction survey. | Each submission. |
Two limits worth designing around:
- Skipping does not count as submitting.
do prechat:skip, and the visitor choosing to chat anonymously, emit neitherprechat:submittednoremail:captured. conversationIdcan benullonprechat:submittedwhen the conversation record is not ready yet. Guard before you send it anywhere.email:capturedonly covers the pre-chat form. An email the visitor types into the chat, or one you pass withset user:email, does not emit it.
Presence and typing
| Event | Payload | Fires when | How often |
|---|---|---|---|
agent:available | { online: true } | Support is inside chat hours. | On each change into available, including the first reading after load. |
agent:unavailable | { online: false } | Support is outside chat hours. | On each change into unavailable, including the first reading after load. |
agent:typing | { active } | A human agent starts or stops typing. | On each change of active. |
ai:typing | { active } | The AI assistant starts or stops typing. | On each change of active. |
unread:changed | { count } | The visitor's unread count changes. | On each change. |
agent:available and agent:unavailable are two sides of one signal, so exactly one of them fires per change. To track chat hours, subscribe to both. The current value is also readable at any time with $chatty.is('agent:online').
Order at startup
Only one thing is guaranteed about ordering: sdk:ready comes first, and any command you pushed before the widget loaded has already run by the time it fires.
Everything after that depends on how the widget is configured and what the visitor does:
sdk:ready— commands are accepted.agent:availableoragent:unavailable— once chat hours have been evaluated.prechat:shown— if a pre-chat form is configured and the visitor opens the chat.chat:opened— when the window opens.prechat:submitted, andemail:capturedwith it — if the visitor completes the form.conversation:started— when the first message goes through.
Do not write code that depends on step 2 arriving before step 4, or on conversation:started arriving before a reply. Treat each event as independent.
Avoiding duplicate handling
ai:reply and human:reply cover the same replies that message:received reports, split by sender. Subscribe to either message:received or the ai:reply / human:reply pair, not both. Otherwise a single reply runs your handler twice. This matters most for analytics, where it doubles your counts.
// Either this: one handler, branch on the payload
window.$chatty.on('message:received', ({ isAutomated }) => { /* ... */ })
// Or this: two handlers, no branching. Not both.
window.$chatty.on('ai:reply', () => { /* ... */ })
window.$chatty.on('human:reply', () => { /* ... */ })The same trap applies to prechat:submitted and email:captured, which fire together for one submission, and to prechat:submitted and conversation:started, which both fire for one visitor on a shop with a required pre-chat form.
Unsubscribing
off(event, callback) removes one handler. Pass the same function reference you subscribed with. An anonymous inline function cannot be removed, because you no longer hold a reference to it.
function onOpen() { /* ... */ }
window.$chatty.on('chat:opened', onOpen)
window.$chatty.off('chat:opened', onOpen)Calling off with the event name only removes every handler registered for that event, including handlers registered by other scripts on the page. Use it deliberately.
window.$chatty.off('chat:opened') // removes all chat:opened handlersAnalytics and GA4
Send Chatty activity to Google Analytics 4. Add this after your GA4 snippet, for example in theme.liquid.
<script>
window.$chatty = window.$chatty || [];
window.$chatty.push(['on', 'sdk:ready', function () {
// Chat window opened
window.$chatty.on('chat:opened', function () {
gtag('event', 'chatty_chat_opened');
});
// First message submitted, a new conversation exists
window.$chatty.on('conversation:started', function (data) {
gtag('event', 'chatty_conversation_started', {
conversation_id: data.conversationId
});
});
// Every message the visitor sends
window.$chatty.on('message:sent', function () {
gtag('event', 'chatty_message_sent');
});
// Pre-chat form submitted
window.$chatty.on('prechat:submitted', function (data) {
gtag('event', 'chatty_lead_submitted', {
conversation_id: data.conversationId
});
});
// Email captured in the pre-chat form
window.$chatty.on('email:captured', function () {
gtag('event', 'chatty_email_captured');
});
}]);
</script>Then mark one of them as a conversion in the GA4 admin.
Pick one conversion event. On a shop with a required pre-chat form, prechat:submitted and conversation:started both fire for the same visitor, so marking both counts the lead twice.
Note the shape of the snippet above: it registers listeners inside on('sdk:ready', …), and it reaches the SDK through push. Before the widget bundle loads, window.$chatty is a plain array, so push is the only method that exists. See When your commands run.
Migrating from another chat widget? The usual callbacks map like this:
| Typical callback | Chatty equivalent |
|---|---|
onOpen | on('chat:opened', …) |
onClose | on('chat:closed', …) |
onMessage outbound | on('message:sent', …) |
onMessage inbound | on('message:received', …) |
onLeadSubmitted | on('prechat:submitted', …) or on('conversation:started', …) |
Custom events you raise yourself
do event:track feeds a named event into the widget context, and the widget re-dispatches it on the page as a DOM CustomEvent named chatty:event. That is a browser event, not an SDK event, so you listen for it on window, not with $chatty.on.
window.$chatty.push(['do', 'event:track', 'viewed_size_guide', { product: 'shirt-101' }])
window.addEventListener('chatty:event', (e) => {
console.log(e.detail.name, e.detail.data) // 'viewed_size_guide', { product: 'shirt-101' }
})The re-dispatch happens only once the chat window has rendered. See event:track for the size limits on name and data.
Need help?
If an event never reaches your handler, confirm the widget is loading on the page and that you registered the listener inside or after sdk:ready. Check the console for a [ChattySDK] Unknown event warning, which means the event name is misspelt. Still stuck? Contact the Chatty support team from your dashboard.
Chatty Help Center