Web chat widget
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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
Sign in to your dashboard.
Go to Channels → New channel → Web.
Give it a name (e.g. "Marketing site") and a public ID.
Warning — You choose the public ID
publicIdis 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.tsxinside the<body>, orpages/_document.tsx. On Astro: in your shared layout, after<slot />. On WordPress: a "Header & Footer Scripts" plugin, footer slot. On SPAs: put it inindex.htmlonce, 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 ownarguments; 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 |
|---|---|---|
| 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 | |
| You enabled the origin allow-list and this origin isn't in it | Add the origin to |
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 |
|---|---|---|
|
| 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. |
|
| Written onto the contact. An email-only identity is not an identity claim: matching is on |
|
| Display name as you want agents to see it. |
|
| Arbitrary JSON. Used by triggers and audience filters. |
|
| 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 anattributeskey.
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 |
|---|---|---|
|
| Called by the loader. Takes |
|
| Opens the panel, optionally on |
|
| Closes the panel. |
|
| Pass |
|
| Returns a promise resolving to |
|
| Shorthand for identify. |
|
| Contact attributes. |
|
| Conversation labels. |
|
| Switches to the matching |
|
| CDP event. |
|
| Forgets the identity keys for this browser. Warns instead of throwing if called before init. |
|
| Events: |
|
| Tears the widget down. |
Warning — There is no send() and no logout()
Chatly('send', …)andChatly('logout')do not exist — an unknown method name is named in a console warning rather than throwing into your page. Usereset()to forget the visitor.setConversationCustomAttributesandpopoutChatWindoware 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 |
|---|---|---|
|
|
|
|
| Which side of the viewport. Default: |
|
| 0–400 px from the side and bottom edges. |
|
| The dark-mode control. There is no |
|
| 32–120, 280–720, 320–1200. |
|
| Hide the default bubble and drive the panel yourself. |
|
| Launcher art and motion ( |
|
| Typography and shape. |
|
|
|
|
| Branding images. |
|
| 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 |
|---|---|---|
|
| Any of |
|
| 500–600,000 ms. Zero is disallowed — instant auto-open is a dark pattern. |
|
| Up to 50 patterns; don't render on matching pages. |
|
| Help tab is search-only. |
|
| Let the visitor drag the launcher. The dragged position is remembered per browser and survives |
|
| 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 |
|---|---|---|
|
| Pre-chat form. Defaults to a required email field. |
|
| Away state outside business hours — headline, message, and whether the composer still accepts messages (it does by default). |
|
| Off by default. Needs a |
|
| On by default, capped at 10 MiB per file (hard ceiling 25 MiB), against a fixed MIME allow-list. |
|
| Tone when an agent replies while the panel is closed. Default: |
|
| Offer "email me a copy". Default: |
|
| Off by default — it needs a microphone permission prompt. Default: |
|
| GIF picker. BYOK: there is no platform Tenor key, so without |
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:3000An 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:3000is 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.combutallowedOriginsonly listswww.acme.com.Identity Verification is on and
userHashis missing, so the profile write is refused.The visitor token in
localStoragewas cleared and a stalepublicIdis in the snippet.
Warning — Every visit creates a new contact
identify()is being called withoutexternalId. Contacts match onexternalIdalone — 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 errorcode); 5xx means we failed.