Guides

Web chat widget

AdminUpdated Sep 19, 2026

Web chat widget

The complete install + customization + identity verification guide for the Chatly widget.

The web channel is a Preact bundle (Shadow-DOM isolated) you embed on any HTML page with a one-line snippet. It works on every framework — React, Vue, Svelte, Next, Astro, raw HTML — because it's just a script tag. Once loaded, window.Chatly is both a callable function and an object of methods your app can use to identify visitors, fire events, and control the panel.

Info — Five minutes to live

The minimum-viable install is three steps: create a Web channel in the dashboard, copy your publicId, paste the snippet before </body>. Everything below is optional polish.

Install

1. Create the channel

  1. Sign in to your dashboard.

  2. Go to ChannelsNew channelWeb.

  3. Give it a name (e.g. "Marketing site") and a public ID.

Warning — You choose the public ID

publicId is not generated for you. It is a required field (1–64 characters) that must be unique across the deployment, and it cannot be changed afterwards. Pick something readable and stable — acme-web, acme-docs — because it is what the snippet on every page carries.

2. Paste the snippet

Paste this immediately before the closing </body> of every page on your site:

<script>
(function (w, d, s, p) {
  w.Chatly = w.Chatly || function () { (w.Chatly.q = w.Chatly.q || []).push(arguments); };
  w.Chatly.publicId = p;
  w.Chatly.apiUrl = 'https://api.chatlychat.com';
  var a = d.createElement(s); a.async = 1;
  a.src = 'https://widget.chatlychat.com/loader.js';
  d.head.appendChild(a);
})(window, document, 'script', 'acme-web');
</script>

Swap acme-web for the public ID from step 1.

Tip — Where to put it

On Next.js: app/layout.tsx inside the <body>, or pages/_document.tsx. On Astro: in your shared layout, after <slot />. On WordPress: a "Header & Footer Scripts" plugin, footer slot. On SPAs: put it in index.html once, not in each route.

Warning — Chatly is a function, not an array

Do not write window.Chatly = window.Chatly || [] and push onto it. The snippet defines a function that queues its own arguments; replacing it with an array stops every call made before the bundle loads.

3. Verify it's working

Open your site. You should see a chat bubble bottom-right, a panel that opens on click, and a conversation appearing in your dashboard inbox.

If nothing appears, open DevTools → Console:

Error

Cause

Fix

[Chatly] missing publicId — widget not started

The placeholder was never replaced, or the snippet was mangled

Use the snippet verbatim — don't run it through your bundler

Nothing at all, no bubble

The bundle was blocked (ad blocker, CSP, CDN blip) — the loader logs an explicit error rather than failing silently

See Content Security Policy

403 origin not allowed on this channel

You enabled the origin allow-list and this origin isn't in it

Add the origin to allowedOrigins

Identifying visitors

By default the widget mints an anonymous visitor token in localStorage (keyed chatly:vt:<publicId>) and tracks the visitor from that browser only. Calling identify() binds the session to a stable contact.

Vanilla JS

<script>
  Chatly('identify', {
    externalId: 'u_42',
    email: '[email protected]',
    name: 'Jamie Park',
    attributes: {
      plan: 'business',
      mrr: 499,
      signedUpAt: '2024-03-12T00:00:00Z'
    }
  });
</script>

React

'use client';
import { useEffect } from 'react';

export function ChatlyIdentity({ user }: { user: { id: string; email: string; name: string } }) {
  useEffect(() => {
    if (typeof window === 'undefined') return;
    window.Chatly?.('identify', {
      externalId: user.id,
      email: user.email,
      name: user.name,
    });
  }, [user]);
  return null;
}

Vue

<script setup lang="ts">
import { watchEffect } from 'vue';
const props = defineProps<{ user: { id: string; email: string } }>();
watchEffect(() => {
  // @ts-expect-error global
  window.Chatly?.('identify', { externalId: props.user.id, email: props.user.email });
});
</script>

Svelte

<script lang="ts">
  import { onMount } from 'svelte';
  export let user: { id: string; email: string };
  onMount(() => {
    // @ts-expect-error global
    window.Chatly?.('identify', { externalId: user.id, email: user.email });
  });
</script>

identify() payload — the complete set

Name

Type

Description

externalId

string

Your stable database identifier. Optional in the type, but it is the only field contacts are matched on — without it every visit creates a new contact. Always pass it when you have it.

email

string

Written onto the contact. An email-only identity is not an identity claim: matching is on externalId, never on email, so it lands on this browser's own contact and leaves the session unverified.

name

string

Display name as you want agents to see it.

attributes

object

Arbitrary JSON. Used by triggers and audience filters.

userHash

string

Required if Identity Verification is enabled on this channel. See Identity Verification.

Warning — avatarUrl, phone, locale and timezone are not identify() fields

The widget's identity payload is the five fields above. Set the display locale with Chatly('setLocale', 'es-MX'); set anything else through the API or as an attributes key.

Warning — Identify is idempotent — call it on every page load

Don't try to "only call identify once." Calling it on every page load is the supported pattern — it lets us pick up attribute changes (a plan upgrade, say) in real time.

CDP events

Track product events so triggers and audiences can react to them:

Chatly('track', 'checkout.completed', {
  orderId: 'O-7421',
  amount: 199.0,
  currency: 'USD',
  items: 3
});

Event names should be domain.verb (order.shipped, subscription.canceled, feature.upgraded). Properties are arbitrary JSON. Server-side, the same events go to POST /v1/cdp/events, one of the routes a ck_ API key can call.

Programmatic control

The full method surface, callable either way — Chatly('open') or Chatly.open():

window.Chatly methods

Name

Type

Description

init(opts)

method

Called by the loader. Takes publicId, plus optional apiUrl, realtimeUrl, visitor, theme, behavior, copy, copyByLocale, quickLinks, cssOverrides, icons, on.

open(tab?)

method

Opens the panel, optionally on 'home', 'messages' or 'help'.

close()

method

Closes the panel.

toggle(state?)

method

Pass 'open', 'close', true, false, or nothing to flip. Anything else is ignored with a console warning rather than guessed at.

identify(identity)

method

Returns a promise resolving to { contactId, workspaceId }.

setUser(externalId, attributes?)

method

Shorthand for identify.

setCustomAttributes(obj) / deleteCustomAttribute(key)

method

Contact attributes.

setLabel(label) / removeLabel(label)

method

Conversation labels.

setLocale(locale)

method

Switches to the matching copyByLocale entry.

track(name, properties?)

method

CDP event.

reset()

method

Forgets the identity keys for this browser. Warns instead of throwing if called before init.

on(event, handler) / off(event, handler)

method

Events: ready, open, close, message, conversation, identified, reset, error. ready is replayed to late subscribers.

destroy()

method

Tears the widget down.

Warning — There is no send() and no logout()

Chatly('send', …) and Chatly('logout') do not exist — an unknown method name is named in a console warning rather than throwing into your page. Use reset() to forget the visitor. setConversationCustomAttributes and popoutChatWindow are also deliberately absent: the backend cannot honour either, and a method that silently does nothing is worse than its absence.

You can replace the launcher with your own button — call Chatly('toggle') from any element's onclick and set theme.hideLauncher = true.

Appearance

The widget theme is a strict schema — an unknown key is rejected, and a malformed value degrades to the built-in default rather than taking the embed down. The merge order is workspace → brand → channel → snippet.

theme (selected)

Name

Type

Description

primaryColor

hex

#rgb, #rrggbb or #rrggbbaa. Also: primaryContrast, background, backgroundSubtle, foreground, foregroundMuted, border, bubbleAgentBg, bubbleAgentFg.

position

"left" | "right"

Which side of the viewport. Default: right.

offsetX / offsetY

integer

0–400 px from the side and bottom edges.

colorScheme

"auto" | "light" | "dark"

The dark-mode control. There is no darkMode key.

launcherSize / panelWidth / panelHeight

integer

32–120, 280–720, 320–1200.

hideLauncher

bool

Hide the default bubble and drive the panel yourself.

launcherIcon / launcherImage / launcherPing / launcherIdleAnimation

mixed

Launcher art and motion (none | wiggle | pulse | bounce).

fontFamily / fontSize / borderRadius

mixed

Typography and shape.

headerStyle / headerGradient

mixed

gradient | solid | plain, plus a two-colour tuple.

logoUrl / agentAvatarUrl

url

Branding images.

hideBranding

bool

Paid tiers only. Set it in Widget → Appearance, not in the snippet: the value is resolved against your plan on the server and the snippet cannot override it.

behavior

Name

Type

Description

tabs / defaultTab

enum[]

Any of home, messages, help.

autoOpenAfterMs

integer

500–600,000 ms. Zero is disallowed — instant auto-open is a dark pattern.

hideOnUrls

string[]

Up to 50 patterns; don't render on matching pages.

helpSearchOnly

bool

Help tab is search-only.

draggable

bool

Let the visitor drag the launcher. The dragged position is remembered per browser and survives reset().

quips

object[]

Rotating micro-copy bound to a selector.

Copy — the greeting and every string in the panel — lives under copy (and copyByLocale), not in the theme:

Chatly.init({
  publicId: 'acme-web',
  copy: {
    greeting: 'How can we help?',
    greetingSubtitle: 'We usually reply in a few minutes',
    composerPlaceholder: 'Type your message…',
  },
});

For deeper customization the widget injects styles into a Shadow DOM, so your site's CSS can't bleed in — supply cssOverrides (plural, a snippet-level init option) to inject inside the Shadow root. Class names are prefixed cl-:

/* cssOverrides */
.cl-launcher { border: 2px solid white; }
.cl-bubble { font-family: "Inter", sans-serif; }

Widget features

Separate from appearance, config.widget on the channel controls behaviour the server enforces:

Parameters

Name

Type

Description

preChat

object

Pre-chat form. Defaults to a required email field.

offline

object

Away state outside business hours — headline, message, and whether the composer still accepts messages (it does by default).

csat

object

Off by default. Needs a surveyId — without one there is nowhere to record a score.

attachments

object

On by default, capped at 10 MiB per file (hard ceiling 25 MiB), against a fixed MIME allow-list.

soundOnReply

bool

Tone when an agent replies while the panel is closed. Default: true.

emailTranscript

bool

Offer "email me a copy". Default: true.

voiceNotes

bool

Off by default — it needs a microphone permission prompt. Default: false.

gifs

object

GIF picker. BYOK: there is no platform Tenor key, so without tenorApiKey the picker is simply not offered.

Info — No voice or video call control ships in the widget

There is no calling toggle in the widget feature config, and the bundle's video-call client methods are not wired to any UI. See Voice for what is reachable today.

Origin allow-list

By default any site can embed your widget. To lock it down, set allowedOrigins on the channel:

https://acme.com
https://*.acme.com
http://localhost:3000

An empty or absent list means no enforcement. When the list is non-empty, a request whose Origin or Referer origin matches any entry is allowed; anything else — including a request with no Origin at all — is rejected with 403 origin not allowed on this channel. Matching is case-insensitive, and * matches exactly one DNS label, so https://*.acme.com matches https://app.acme.com but not https://a.b.acme.com.

Tip — Add localhost for dev

Forgetting http://localhost:3000 is the number-one reason "the widget worked on prod but not locally".

Identity Verification (recommended for authed apps)

Without IDV, anyone can copy your snippet onto their site and call:

Chatly('identify', { externalId: 'your-real-customer-id' });

— and see that customer's history. Identity Verification stops this by requiring an HMAC your server signs.

// your backend
const userHash = createHmac('sha256', process.env.CHATLY_IDV_SECRET!)
  .update(user.id).digest('hex');

// pass to widget
Chatly('identify', { externalId: user.id, userHash });

The secret lives at config.identityVerification.secret on the channel and is { enabled, secret }. The hash is HMAC-SHA256(secret, externalId) compared in constant time; a sha256: or sha256= prefix is stripped if you send one. Verifying mints a verified session, which is what gates profile writes — an unverified session on an IDV channel can still chat but cannot write to the contact.

See the Identity Verification doc for the full setup.

Performance

  • Loader: ~700 bytes gzipped (1.3 KB raw).

  • Widget bundle: ~46 KB gzipped, under a hard 50 KB build gate.

  • The loader is async — it never blocks page rendering.

Warning — The bundle loads on page load, not on click

The loader appends the widget script as soon as it runs. There is no click-to-load deferral. If you need one, omit the snippet and inject it yourself on an interaction.

The bundle URL is versioned (widget.js?v=<buildId>) so a deploy reaches every visitor at once and a stale edge-cached 404 cannot be inherited. A bundleUrl you set on the shim wins over the default.

Content Security Policy

If you run a strict CSP, allowlist these origins (replace with your real hosts when self-hosting):

script-src   'self' https://widget.chatlychat.com;
connect-src  'self' https://api.chatlychat.com wss://ws.chatlychat.com;
img-src      'self' data: https:;
style-src    'self' 'unsafe-inline';  // for Shadow DOM injection
font-src     'self' data:;
frame-src    'self';

'unsafe-inline' is unfortunate but Shadow-DOM-injected stylesheets need it.

Multi-brand setups

If you operate multiple brands under one workspace, create a Web channel per brand. Each gets its own publicId and its own config, and the theme merge order (workspace → brand → channel → snippet) means you only override the per-brand fields you need.

Troubleshooting

Warning — Widget bubble shows up but messages never arrive in the inbox

Usually one of:

  • The widget is on acme.com but allowedOrigins only lists www.acme.com.

  • Identity Verification is on and userHash is missing, so the profile write is refused.

  • The visitor token in localStorage was cleared and a stale publicId is in the snippet.

Warning — Every visit creates a new contact

identify() is being called without externalId. Contacts match on externalId alone — never on email — so an email-only identify writes to this browser's anonymous contact rather than finding the existing one.

Info — When in doubt, check the network tab

Every widget action POSTs to api.chatlychat.com/v1/widget/*. 4xx means the widget reached us and was rejected (read the JSON error code); 5xx means we failed.

Was this page helpful?