SDK
SDK Events
💬Get free consultation

SDK Events

ⓘ

Who can use this feature?

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:

EventLegacy chatboxAI ModeEmbedded chatbox
chat:opened, chat:closedSupportedSupportedNot supported
unread:changedSupportedSupportedNot supported
conversation:removedSupportedNot supportedLegacy mode only
Every other eventSupportedSupportedSupported
  • 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:removed never 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:received and ai:reply once, 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

EventPayloadFires whenHow often
sdk:readyNoneThe command layer is live and queued commands have been replayed.Once per page load. Subscribing later still runs your callback immediately.
chat:openedNoneThe chat window opens, by the visitor or through the SDK.Every open.
chat:closedNoneThe 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

EventPayloadFires whenHow 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:replySame as message:receivedA reply arrives with isAutomated true.Every automated reply.
human:replySame as message:receivedA reply arrives with isAutomated false.Every reply sent by a team member.
message:updatedThe updated message object, including id, conversationId, text, isAdmin and isAutomatedAn 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. On message:received it is the same ID that resolution:confirmed reports as messageId.
  • 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 — true for an automated reply, false for a reply sent by a team member. This is the field to branch on.
  • isAdmin — true on message: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 on isAutomated instead.

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

EventPayloadFires whenHow 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

EventPayloadFires whenHow 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

EventPayloadFires whenHow often
prechat:shownNoneThe 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 neither prechat:submitted nor email:captured.
  • conversationId can be null on prechat:submitted when the conversation record is not ready yet. Guard before you send it anywhere.
  • email:captured only covers Chatty's own forms. An email the visitor types as a normal chat message, or one you pass with set 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

EventPayloadFires whenHow 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:

  1. sdk:ready — commands are accepted.
  2. agent:available or agent:unavailable — once chat hours have been evaluated.
  3. prechat:shown — if a pre-chat form is configured and the visitor opens the chat.
  4. chat:opened — when the window opens.
  5. prechat:submitted, and email:captured with it — if the visitor completes the form with an email.
  6. 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 handlers

Analytics 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 callbackChatty equivalent
onOpenon('chat:opened', …)
onCloseon('chat:closed', …)
onMessage outboundon('message:sent', …)
onMessage inboundon('message:received', …)
onLeadSubmittedon('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.