How to Build Live Customer Support Chat

Build a live chat widget for your product — a customer-facing chat window and an agent dashboard that both update in real time using WebSockets.

Live customer support chat has become a baseline expectation for SaaS products. Users want answers fast, and a real-time chat widget delivers that without forcing them to context-switch to email. This post walks through building both sides of the system: the customer-facing widget embedded on your site, and the internal agent dashboard where your support team handles conversations. Both update instantly over WebSockets using Apinator.

The Architecture

Two distinct clients talk through the same infrastructure:

  • Customer widget — a small chat bubble rendered on every page of your product. When clicked, it opens a chat window, connects to Apinator, and subscribes to the conversation channel.
  • Agent dashboard — an internal tool your support team has open all day. It lists every active conversation and subscribes to each one so messages from any customer appear instantly.

Each conversation gets its own private channel: private-conversation-{id}. Private channels require authentication, which means only your server can grant access — customers can only join their own conversation, and agents are granted access server-side.

A second set of private channels handles routing: private-agent-{agentId}. When a new conversation comes in, your server publishes to the assigned agent's channel so their dashboard lights up with the new ticket.

Server Setup

Install the server SDK:

npm install @apinator/server

Initialize the client and wire up two endpoints: one to authenticate channel subscriptions, and one to receive new messages.

// server.js
import express from 'express';
import Apinator from '@apinator/server';
import { db } from './db.js';

const app = express();
app.use(express.json());

const apinator = new Apinator({
  appId: process.env.APINATOR_APP_ID,
  key: process.env.APINATOR_KEY,
  secret: process.env.APINATOR_SECRET,
});

// Channel authentication
app.post('/apinator/auth', async (req, res) => {
  const { socket_id, channel_name } = req.body;
  const userId = req.session.userId;
  const userRole = req.session.role; // 'customer' | 'agent'

  // Parse channel to enforce access rules
  if (channel_name.startsWith('private-conversation-')) {
    const conversationId = channel_name.replace('private-conversation-', '');
    const conversation = await db.conversations.findById(conversationId);

    const allowed =
      (userRole === 'customer' && conversation.customerId === userId) ||
      (userRole === 'agent' && conversation.agentId === userId);

    if (!allowed) return res.status(403).json({ error: 'Forbidden' });
  }

  if (channel_name.startsWith('private-agent-')) {
    const agentId = channel_name.replace('private-agent-', '');
    if (userRole !== 'agent' || userId !== agentId) {
      return res.status(403).json({ error: 'Forbidden' });
    }
  }

  const auth = apinator.authenticate(socket_id, channel_name);
  res.json(auth);
});

// New message from customer
app.post('/messages', async (req, res) => {
  const { conversationId, text } = req.body;
  const customerId = req.session.userId;

  const message = await db.messages.create({
    conversationId,
    senderId: customerId,
    senderRole: 'customer',
    text,
    createdAt: new Date(),
  });

  // Publish to the conversation channel — agent sees it instantly
  await apinator.trigger(`private-conversation-${conversationId}`, 'message:new', {
    id: message.id,
    text: message.text,
    senderRole: 'customer',
    createdAt: message.createdAt,
  });

  res.json({ ok: true, messageId: message.id });
});

app.listen(3000);

Customer Widget

The widget is a self-contained script you embed on your product pages. It renders a chat bubble, manages the conversation lifecycle, and connects to Apinator only when the customer actually opens the window — no unnecessary WebSocket connections on every page load.

// widget.js  (bundled and served as a script tag)
import Apinator from '@apinator/sdk';

export function initChatWidget({ customerId, conversationId }) {
  const bubble = document.createElement('button');
  bubble.className = 'chat-bubble';
  bubble.textContent = '💬';
  document.body.appendChild(bubble);

  let client = null;
  let channel = null;
  let typingTimer = null;

  bubble.addEventListener('click', () => {
    if (document.querySelector('.chat-window')) return;
    openChatWindow();
  });

  function openChatWindow() {
    const win = document.createElement('div');
    win.className = 'chat-window';
    win.innerHTML = `
      <div class="chat-messages" id="chat-messages"></div>
      <div class="chat-input-row">
        <input id="chat-input" placeholder="Type a message…" />
        <button id="chat-send">Send</button>
      </div>
      <div class="chat-status" id="chat-status"></div>
    `;
    document.body.appendChild(win);

    // Connect and subscribe when the window opens
    client = new Apinator({
      key: 'YOUR_APP_KEY',
      authEndpoint: '/apinator/auth',
    });

    channel = client.subscribe(`private-conversation-${conversationId}`);

    channel.bind('message:new', (data) => {
      if (data.senderRole === 'agent') appendMessage(data.text, 'agent');
    });

    channel.bind('client-typing', (data) => {
      if (data.role === 'agent') {
        document.getElementById('chat-status').textContent = 'Agent is typing…';
        clearTimeout(typingTimer);
        typingTimer = setTimeout(() => {
          document.getElementById('chat-status').textContent = '';
        }, 2000);
      }
    });

    // Load message history
    fetch(`/conversations/${conversationId}/messages`)
      .then(r => r.json())
      .then(messages => messages.forEach(m =>
        appendMessage(m.text, m.senderRole)
      ));

    const input = document.getElementById('chat-input');
    const sendBtn = document.getElementById('chat-send');

    input.addEventListener('input', () => {
      channel.trigger('client-typing', { role: 'customer' });
    });

    sendBtn.addEventListener('click', async () => {
      const text = input.value.trim();
      if (!text) return;
      input.value = '';
      appendMessage(text, 'customer');
      await fetch('/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ conversationId, text }),
      });
    });
  }

  function appendMessage(text, role) {
    const el = document.createElement('div');
    el.className = `message message--${role}`;
    el.textContent = text;
    document.getElementById('chat-messages').appendChild(el);
  }
}

Typing indicators use channel.trigger('client-typing', data) — a client event sent directly over the WebSocket without hitting your server. The receiving side clears the "typing…" label after two seconds of silence.

Agent Dashboard

The agent dashboard subscribes to every active conversation plus the agent's own notification channel. When a customer sends a message, it appears in the right conversation panel without any polling.

// dashboard.js
import Apinator from '@apinator/sdk';

const client = new Apinator({
  key: 'YOUR_APP_KEY',
  authEndpoint: '/apinator/auth',
});

const agentId = window.__AGENT_ID__;

// Subscribe to this agent's notification channel for new conversation assignments
const agentChannel = client.subscribe(`private-agent-${agentId}`);

agentChannel.bind('conversation:assigned', (data) => {
  addConversationToSidebar(data.conversationId, data.customerName);
  subscribeToConversation(data.conversationId);
});

// Subscribe to all conversations already assigned to this agent on page load
async function init() {
  const { conversations } = await fetch('/agent/conversations').then(r => r.json());
  conversations.forEach(c => {
    addConversationToSidebar(c.id, c.customerName);
    subscribeToConversation(c.id);
  });
}

function subscribeToConversation(conversationId) {
  const ch = client.subscribe(`private-conversation-${conversationId}`);
  let typingTimer = null;

  ch.bind('message:new', (data) => {
    if (data.senderRole === 'customer') {
      renderMessage(conversationId, data.text, 'customer');
      markConversationUnread(conversationId);
    }
  });

  ch.bind('client-typing', (data) => {
    if (data.role === 'customer') {
      showTypingIndicator(conversationId);
      clearTimeout(typingTimer);
      typingTimer = setTimeout(() => hideTypingIndicator(conversationId), 2000);
    }
  });
}

// Agent replies
document.getElementById('reply-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const conversationId = getActiveConversationId();
  const text = document.getElementById('reply-input').value.trim();
  if (!text) return;

  await fetch('/agent/messages', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ conversationId, text }),
  });

  document.getElementById('reply-input').value = '';
});

init();

Agent replies go through a /agent/messages endpoint on your server which persists the message and triggers to the same conversation channel, so the customer sees the reply appear instantly in the widget.

Conversation Routing

When a customer starts a new conversation, your server needs to pick an agent and notify them:

app.post('/conversations', async (req, res) => {
  const customerId = req.session.userId;
  const conversation = await db.conversations.create({ customerId });

  // Simple round-robin or availability-based assignment
  const agent = await db.agents.findAvailable();
  await db.conversations.assignAgent(conversation.id, agent.id);

  // Notify the assigned agent via their private channel
  await apinator.trigger(`private-agent-${agent.id}`, 'conversation:assigned', {
    conversationId: conversation.id,
    customerName: req.session.name,
  });

  res.json({ conversationId: conversation.id });
});

More sophisticated routing — skills-based, load-balanced, or queue-based — slots in at this layer without touching the real-time plumbing.

Handling Offline Agents

Private channels deliver events only to currently connected subscribers. If an agent is offline or has closed the tab, they will miss events published while they were away. The fix is straightforward: always persist messages to your database first, then publish to Apinator. When the agent reopens the dashboard, fetch the message history from your API and render it before subscribing to live events. This way the channel delivers only the delta — new messages that arrive after the agent connects — and the database fills in everything before that.

The same principle applies to customers. If a customer closes the widget and returns later, load the conversation history from /conversations/{id}/messages before re-subscribing.

What You Get

With roughly 150 lines of code across three files you have a fully functional live support system: a chat bubble that connects on demand, an agent dashboard that handles multiple simultaneous conversations, typing indicators in both directions, and clean conversation routing with offline resilience. Apinator handles the WebSocket infrastructure, cross-server fanout, and channel authentication — you own only the product logic.