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 23 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.
Supported surfaces
Events work on the Legacy chatbox, on AI Mode, and on the Embedded chatbox. Most events are supported everywhere. These are the exceptions:
| Event | Legacy chatbox | AI Mode | Embedded chatbox |
|---|---|---|---|
chat:opened, chat:closed | Supported | Supported | Not supported |
unread:changed | Supported | Supported | Not supported |
conversation:removed | Supported | Not supported | Legacy mode only |
| Every other event | Supported | Supported | Supported |
- The Embedded chatbox is always open and has no launcher, so it has no open or close and no unread count. For everything else it follows the chatbox mode your store uses.
- AI Mode has no live signal for a deleted conversation, so
conversation:removednever fires there. - One SDK per page. A page with both a floating chatbox and an Embedded chatbox shares one SDK, and each listener receives a given message or conversation event once.
Live activity only
Message and conversation events describe what happens while the visitor is on the page. Restoring history, reloading the page, or scrolling up to load older messages emits none of them.
- AI Mode starts listening for live message and conversation events after the panel is first opened in a page view. Agent availability is published from page load.
- A streamed AI reply emits
message:receivedandai:replyonce, with its final text.
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 | { id, conversationId, text, type } | The visitor's message is accepted. | Every message the visitor sends. |
message:received | { id, conversationId, text, isAdmin, isAutomated } | A reply arrives. | Every reply. |
ai:reply | Same as message:received | A reply arrives with isAutomated true. | Every automated reply. |
human:reply | Same as message:received | A reply arrives with isAutomated false. | Every reply sent by a team member. |
message:updated | The updated message object, including id, conversationId, text, isAdmin and isAutomated | An existing message is edited. | Every edit. |
message:removed | { id, conversationId, text, isAdmin, isAutomated } | A message is removed. | Every removal. |
Payload fields:
id— the message ID. Onmessage:receivedit is the same ID thatresolution:confirmedreports asmessageId.conversationId— the conversation the message belongs to.text— the message body as plain text.type—'text', or'attachment'when the visitor sent attachments with no text.isAutomated—truefor an automated reply,falsefor a reply sent by a team member. This is the field to branch on.isAdmin—trueonmessage:received, because the event only covers replies coming into the widget. It does not tell you whether a team member or an automated reply wrote it. Branch onisAutomatedinstead.
ai:reply and isAutomated: true cover every automated reply, not only AI answers:
- AI answers.
- Starter answers you preset.
- The automatic acknowledgement after the pre-chat form.
- Campaign and system bot messages.
human:reply and isAutomated: false mean only a reply a team member sent. Internal notes and hidden system records emit no reply event.
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. After a customer reset, fires again on the first message of the next one. |
conversation:resolved | { conversationId } | The conversation status changes to resolved. | On each change into resolved, including when a customer reset resolves the old conversation. |
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.
When the visitor resets the conversation
A customer reset resolves the old conversation and emits conversation:resolved for it. This covers the visitor starting over from the widget and do session:reset, on the Legacy chatbox and on AI Mode.
The reset does not create the next conversation. That happens on the visitor's first message, which emits conversation:started with the new ID, before message:sent.
Customer-confirmed resolution
| Event | Payload | Fires when | How often |
|---|---|---|---|
resolution:confirmed | { conversationId, messageId } | The customer confirms that their problem or question is solved. | Once per confirming AI reply. |
messageId is the ID of the AI reply that answered the confirmation. It matches the id in that reply's message:received payload.
It fires when the customer confirms the problem is solved in any language, whether typed or sent through a chip. That includes answering "yes" when the assistant asks whether the issue is resolved.
It does not fire for:
- A plain thank-you or a sign-off.
- Partial success, a new question, or a "no".
- Page load, restoring history, closing the chat, or inactivity.
- Auto-resolve, or a team member resolving the conversation.
resolution:confirmed does not change the conversation status, and it is separate from conversation:resolved. If a page carries both a floating chatbox and an Embedded chatbox, it still fires once.
For example, play a short animation on your theme when a customer says they are happy:
window.$chatty.on('resolution:confirmed', function () {
document.body.classList.add('chatty-celebrate')
setTimeout(function () {
document.body.classList.remove('chatty-celebrate')
}, 3000)
})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 visitor submits a valid email through one of Chatty's forms: the pre-chat form, the pre-chat subscribe form, a newsletter campaign, the human handover contact form, the order-tracking updates form, or the post-purchase support form. | Once per email address per page session. |
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 Chatty's own forms. An email the visitor types as a normal chat message, or one you pass withset user:email, does not emit it. Submitting the same address again in the same page session (in any letter case) does not emit it a second time.
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 with an email.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 pre-chat 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