Install
One line of HTML, once per page that should show the chat. Put it before the closing
</body> tag. Your channel's own line — with its key already in it — is
in the dashboard under Web Chat Channels → your channel → Overview.
<script src="https://widget.fellix.ai/widget.js" data-key="wc_pk_…" async></script>
async is deliberate: nothing on your page waits for us. The script draws a
launcher button and nothing else. The conversation itself lives in an iframe on Fellix's
own origin, and that iframe is not created until the visitor opens the panel — so a
visitor who never clicks pays for one small script and no more.
For a page that is about talking to you, such as a support or contact page, the panel can
be part of the page itself rather than a bubble in the corner: see
data-container.
The boundary is the point. None of our React, CSS or network traffic runs in your document. Your stylesheet cannot reach into the panel, and ours cannot leak out onto your page.
Snippet attributes
All optional except data-key. Each is read once, from the script tag itself,
at the moment the script runs — changing an attribute later has no effect, because by
then the tag is no longer identifiable.
data-key required
This channel's public embed key. Without it the widget does not start: it logs
[fellix] the embed snippet is missing its data-key attribute to the console
and returns, drawing nothing. If you pasted the snippet and no launcher appeared, that
console line is the first place to look.
The key is publishable by design — it identifies the channel, it does not authorise anything. Which websites may use it is controlled separately, under the channel's Security settings.
data-lang
What language the widget speaks.
- absent, or
page -
Follow the page's own
<html lang>, falling back to the visitor's browser when the page declares none. This is the default, and usually the right answer: the widget is furniture on your page, and an English-only site should not show a Turkish composer under English content just because the visitor's browser is set to Turkish. browser- Follow the visitor's own browser preference instead of the page.
- a language tag —
tr,de,pt-BR - Pin it, whatever the page and the visitor say.
Only the first two letters are used to pick the wording, so pt-BR and
pt reach the same translation. The widget ships English, Turkish, German,
Spanish, French, Arabic and Russian; anything else falls back to English. Arabic, Hebrew,
Persian and Urdu also flip the panel to right-to-left.
The same language picks your own texts: the title, the greeting, the
suggested questions, the pop-up messages and everything else you write in the chat's
settings. Fellix translates them into each of your organization's languages (Settings →
Languages), and you can rewrite any translation in the dashboard. A regional tag reads the
nearest of them, so a page in de-AT gets your German texts. A visitor whose
language your organization does not have reads them in the language you wrote them in. A
single-page site that changes its <html lang> without reloading gets
the texts in the new language too.
data-theme
Whether the panel is painted light or dark.
- absent, or
page -
Follow the page. We read the computed
color-schemeof your<html>element — not a class name, because.darkis Tailwind's convention anddata-bs-themeis Bootstrap's and neither is recognisable on a stranger's site.color-schemeis the one answer every framework produces, because the browser itself needs it to paint the canvas and the form controls. os- Ignore the page and follow the visitor's own system setting.
light/dark- Pin it.
The channel outranks this. If the channel's Panel theme is set to
Light or Dark in the dashboard, that wins and data-theme is not consulted at
all — a page is not entitled to overrule the brand it embedded. This attribute only does
something while Panel theme is on System.
data-position
Which corner the launcher sits in.
- absent
- The channel's Bubble position setting decides, as it does for every site the key is pasted into. That setting has a Follow the visitor's language switch of its own, so leaving this attribute off does not necessarily mean a fixed corner — it means whichever of the two the dashboard is set to give.
left/right- Pin it for this site.
auto- Follow the widget's own language: right on a left-to-right page, left on a right-to-left one. What a site that mirrors itself for Arabic wants.
Unlike data-theme, the page wins here. The setting
is one answer for everywhere the key is used; this attribute is one site talking about its
own corners, where a cookie bar or a back-to-top button may already be.
data-z-index
The stacking level of the launcher, the proactive message card and the inline composer.
The open panel sits one level below them. Without it they sit at
2147483000, above anything a page normally stacks, your own dialogs and
drawers included.
Set it when your page's dialogs should cover the chat: a number above your page content
and below your modal layer. On a Tailwind or shadcn/ui page, whose dialogs sit at
z-50, data-z-index="30" does that. The chat then stays open
behind a dialog and is where the visitor left it once the dialog closes.
<script src="https://widget.fellix.ai/widget.js" data-key="wc_pk_…" data-z-index="30" async></script>
Use a whole number from 1 up. Anything else is ignored and the default
stays, so a typo leaves the chat on top rather than under your page.
data-label
Replaces the text a screen reader announces for the launcher button. Defaults to a translated “Chat with us”. Set it when “chat” is not what the button means on your site — “Book a table”, “Ask about your order”. It is an accessible name, not visible copy; the visible label is the channel's Bubble label setting.
data-jwt
A signed identity token for the person reading the page, when your backend renders the
snippet server-side and already knows who they are. A single-page application that learns
who its user is only after a login calls
fellixWidget.identify() instead. See
Signed identity.
data-container
A CSS selector for an element on your page. The panel is drawn inside that element, open, at its full width and height, instead of behind a launcher in the corner. Use it on the one page where the chat is the content, such as a support page; leave it off the snippet on every other page.
<section id="support-chat" style="height: 640px"></section>
<script src="https://widget.fellix.ai/widget.js" data-key="wc_pk_…" data-container="#support-chat" async></script>
Because it is part of the page, it behaves like it:
- there is no launcher, no proactive message card and no close button, and on a phone it stays in its box rather than taking over the screen, so the page around it still scrolls;
-
the targeting rule that matches the page opens the conversation
in place of the greeting, so a support page can greet as a support page: a rule on
Page address contains
/supportis enough. With no rule matching, the greeting stands; -
[data-fellix-open],#fellix-chatandfellixWidget.open()scroll the page to the panel and put the visitor in it;close()andhideLauncher()do nothing there; - it is never remembered as open, so the next page, which has no container, shows the ordinary launcher;
-
while your organization has no plan running, when the chat shows nowhere on your site,
the element stays as you left it. Put your other ways to reach you next to it rather
than relying on the chat alone;
fellixWidget.isAvailable()tells your page when that is the case.
Give the element a height: the panel fills it, and is never drawn shorter than
480px. It takes the corners of the channel's Corner radius setting.
The element has to exist when the script runs, so put the snippet after it. A selector that matches nothing, or is not a selector at all, logs a warning in the console and the chat falls back to the launcher in the corner, so a typo costs the layout, not the chat.
data-panel
The origin the panel iframe is loaded from, and the origin the widget calls its
API on. Defaults to the origin of the script's own src, which is
correct for every ordinary installation — you will not normally set this. It exists for
environments that serve widget.js from a different host than the panel, such
as a reverse proxy in front of both.
A proxy you point this at must also forward /api/. The
launcher reads its colour and label from <origin>/api/v1/webchat_config
before anyone clicks, and the panel talks to that same origin. A host that serves the
panel but answers nothing under /api/ gives you a launcher in default
colours and a panel that cannot send.
Leave it off rather than empty. data-panel="" is not the
same as omitting it: an empty value is still a value, so the fallback to the script's own
origin never happens and the panel is loaded from nowhere. Templating engines that emit
every attribute whether or not they have something to put in it are the usual way this
happens.
Opening it from your own page
Two ways in that need no JavaScript of yours. Both work alongside the launcher, and both
still work when the launcher is hidden — the channel's
Use my own button setting turns the bubble off entirely and leaves these as the
way in. To take it off only the pages that carry their own opener, call
fellixWidget.hideLauncher() there.
[data-fellix-open]
Put the attribute on any element — a button, a link, a menu item — and clicking it opens the panel. An attribute rather than an id or a class, because an id of ours in your stylesheet's way is a collision waiting to happen, and this reads as intent on your own button.
<button data-fellix-open>Talk to us</button>
The click is delegated from the document, so an opener your app renders later — after a
route change, inside a modal — works without re-registering anything. On an
<a> the default navigation is prevented, so you can write a real href as
a fallback for the JS-disabled case.
The #fellix-chat fragment
Arriving on a page whose URL ends in #fellix-chat opens the panel. This is how
the link in a reply notification brings someone back to the conversation. The fragment is
taken back off the address immediately, before anything can write it to history or pass it
on as a referrer, so the visitor is left on the page they came to read with the panel on
top of it.
JavaScript API
Available on window.fellixWidget as soon as the snippet has run. Because the
script is async, that is not necessarily before your own code runs — guard
with window.fellixWidget?. rather than assuming it is there.
fellixWidget.identify({ jwt, attributes })
window.fellixWidget?.identify({
jwt: '<token your backend signed>',
attributes: { plan: 'pro', seats: 12, trial: false },
})
Hands over the signed identity token, and optionally what your page claims about the
visitor. Call it after login in an app that learns who its user is late. Passing
jwt: null hands over claimed attributes without an identity.
Flat scalars only. Attribute values that are not a string, a number or a
boolean are dropped before anything is sent — a nested object or an array does not arrive
truncated, it does not arrive. Flatten it yourself if you need it:
{ 'plan.name': 'pro' }, not { plan: { name: 'pro' } }.
fellixWidget.update({ … })
Adds or replaces claimed attributes without touching the identity token. Merges — keys you
do not mention keep their previous value. Same flat-scalars rule as
identify.
fellixWidget.track(name)
window.fellixWidget?.track('checkout_started')
Names a moment on your page. It reaches the channel's targeting rules — so a proactive message can be written to fire on it — and lands in the organization's analytics. An empty or whitespace-only name is ignored.
fellixWidget.open()
Opens the panel, as though the visitor had pressed the launcher. Also dismisses the proactive greeting bubble if one is showing.
fellixWidget.ask(question)
searchForm.addEventListener('submit', (event) => {
if (event.submitter !== askButton) return
event.preventDefault()
window.fellixWidget?.ask(searchInput.value)
})
Opens the panel and sends question as the visitor's message, as though they had
typed it there: the assistant answers, and a pre-chat form, if the channel has one, comes
first with the question kept in the composer. Surrounding spaces are trimmed and the text
is cut to 2,000 characters. An empty question does nothing, and so does any question while
the chat is shown in a dashboard preview. Hide the button that calls it on
isAvailable() === false, as below.
fellixWidget.close()
Closes the panel. The conversation is not ended and nothing is lost.
fellixWidget.isAvailable()
const sync = () => {
myChatButton.hidden = window.fellixWidget?.isAvailable() === false
}
window.addEventListener('fellix:availability', sync)
sync()
Whether the chat can open on your site right now: true, false
while your organization has no plan running (the launcher is not drawn and
open() does nothing), or null until the widget has heard from
us. Each time the answer changes, a fellix:availability event fires on
window with event.detail.available. A page with its own
[data-fellix-open] button reads it to hide
that button rather than leave one that does nothing.
Hide only on false. null means not known yet, and the chat
usually is available.
fellixWidget.respond(id, result)
const taken = await window.fellixWidget?.respond(event.detail.id, { added: true })
Answers a fellix:action request that waits for one.
The promise is true once the agent has the answer, and false for
an id that did not wait, one answered already or after the agent stopped waiting, and for
null or an answer over 4 KB. Those last two leave the request open for
another try. The answer travels as JSON: functions are dropped and dates become strings.
fellixWidget.hideLauncher()
window.fellixWidget?.hideLauncher()
Takes the bubble off this page, for a view that has its own
[data-fellix-open] opener — an app whose
signed-in screens carry a help menu while its sign-in screen does not. The panel, the
proactive messages and the unread count in the tab title all keep working. The
Use my own button setting does the same for every page of the site; this is the
per-page version.
fellixWidget.showLauncher()
Undoes hideLauncher(). It hands the
decision back to the channel rather than forcing the bubble on: with
Use my own button switched on, the bubble stays off.
fellixWidget.logout()
Drops the identity, the claimed attributes and the visitor's thread. Call it on sign-out — otherwise the next person at that computer opens the previous one's conversation.
fellixWidget.destroy()
Removes the widget from the page entirely, thread included, and deletes
window.fellixWidget. For a page that stops being entitled to the widget
without navigating away — a single-page app where signing out only unmounts a component.
It performs a logout first and removes the
iframe a moment later rather than in the same tick. That delay is load-bearing: the logout
is a message to the panel, and the panel is the half that clears the stored thread. An
iframe removed immediately never receives it.
Actions on your page
An agent can be given actions your page carries out: put a product in the cart, open a
size guide, show the visitor their order. You write them in the dashboard under
Build › Skills › Custom actions as Run on your website: a name, when the
agent should use it, the details it fills in, and the event name your script listens
for. Nothing runs on Fellix's servers. When the agent decides to use one, a
fellix:action event fires on window and your own script does
the rest.
window.addEventListener('fellix:action', async (event) => {
const { id, name, arguments: details, wait } = event.detail
if (name !== 'add_to_cart') return
const cart = await addToCart(details.sku)
if (wait) window.fellixWidget?.respond(id, { added: true, items: cart.items.length })
})
The event
id: this one request, forrespond;name: the event name the action was written with;-
arguments: what the agent filled in, by the keys the action declares, already checked against their types; wait: whether the agent is waiting for your answer.
It fires only in the tab the visitor last wrote from. Someone with your shop open in two tabs gets it once, in the tab where they asked.
Answering
When the action is set to wait, the agent holds its reply for up to 10 seconds for what
you hand to respond(id, result), and
writes the reply from it. Answer with any JSON value but null, up to 4 KB: an
object, a list, a sentence, a number. With no answer in time the agent tells the visitor
it could not do it. An action that does not wait needs no answer: the agent says what it
asked your page to do, not that it happened.
Your answer is what the page says. It comes from the visitor's own
browser, where anyone can open the console and call respond themselves. The
agent is told so and never presents it as checked. Decide anything about money or access
on your server, and let the page report what the server said.
The event fires only on a page where the chat is loaded and connected. A visitor who has closed the tab hears nothing, and an action that waits then ends unanswered.
To try an action before your code is in place, chat in the dashboard's Playground: it stands in for your page, shows each request with what the agent filled in, and lets you answer one that waits as your code would.
Signed identity
Anyone can open a chat anonymously; that is the normal case and needs nothing from you. Signed identity is for when the conversation should be attached to a person your system has already authenticated, and when the visitor's browser must not be able to claim to be someone else.
Your backend signs a token with the channel's signing secret — found under the channel's Security settings — and you hand it over in one of two ways:
-
server-rendered pages: put it straight on the tag as
data-jwt; -
single-page apps: call
fellixWidget.identify()once the login completes.
What the token carries
Two claims are required; three are optional, and those three are the reason to bother at all — they are the things the visitor would otherwise be asked for.
{
sub: 'user-8412', // required — your own id
exp: Math.floor(Date.now() / 1000) + 3600, // required
name: 'Ayşe Yılmaz',
email: '[email protected]',
phone: '+905551112233'
}
name, email and phone are written onto that
person's contact record as the session opens, and that is where the difference shows: the
pre-chat form has nothing left to ask, so it never appears, and the assistant spends its
first reply answering the question instead of asking who is asking. Pass all three — a
field you leave out is one the form or the assistant still has to ask for.
sub must be stable and unique to the person. It becomes their visitor id,
which is how the same conversation follows them to a second device — and why a signed
visitor never resumes a thread left in the browser's storage: on a shared computer that
thread belongs to whoever sat there before.
An address we already have stays where it is. If the email or phone in
the token already belongs to a different contact in your organization, that one field is
skipped rather than moved: the other record is never touched, the session opens normally,
and the skipped field behaves as though you had not sent it. Send phone in
international form — that is how it is stored, and the same number written another way is
filed as a second one.
How long a token has to live
The token is read when the panel opens a session, and a signed visitor opens one on
every page load — their thread lives with the person, not with the browser. So an
exp that passes while someone sits reading costs nothing; the next page they
open is where a stale token bites.
Mint it in the same request that renders the page, or, in a single-page app, refresh it
before it runs out and call
identify() again with the new one.
Handing over a fresh token for the same sub continues the same conversation;
it does not start a second one. Keep the lifetime short — a token is a credential, and one
copied out of a page keeps working until it expires.
When a token does not verify
It is refused, never quietly downgraded. The session request answers 401 and
the panel says "We could not verify who you are signed in as" with no composer —
deliberately, because the alternative is an integration that signs the wrong thing handing
every signed-in user an anonymous conversation that looks like it works, and nobody
finding out until someone asks where their history went.
Three ways it happens, in the order they actually happen:
- The secret was rotated. A channel holds one signing secret, so regenerating it in the dashboard retires every token signed with the old one at once. A panel already open keeps the session it has; the next page that visitor opens is the one that fails. Put the new secret on your server in the same move.
-
expis missing or past. A token without an expiry is a password, so one is required; an expired one is refused rather than honoured. - The clocks disagree. Verification allows 60 seconds of skew between your server and ours. A host whose clock has drifted further fails for a reason that looks like nothing at all.
The secret is a secret. It signs identities, so it belongs on your server
and only there. Anything you put in front of a browser — including
data-key — is public.
Attributes passed alongside the token are claims your page makes, not verified facts. They are useful for targeting and for what an agent sees; do not use them for anything the visitor should not be able to influence.
Targeting rules
A proactive message is the small card that appears above the launcher after a quiet time. Which visitor sees which message is decided by the channel's targeting rules, written in the Fellix dashboard on the website chat's Targeting tab. The rules are evaluated on Fellix's server: your page sends what it knows, and never downloads the rules.
Rules are read top to bottom. The first one whose conditions all hold is the message this visitor sees, after that rule's delay. A condition left blank is not asked about. When no rule matches, the channel's greeting is shown instead, if it has a delay set.
When the delay is over and your page keeps something fixed where the card would appear, such as a cookie banner or a bottom bar, the card waits until that is gone, so it never covers your own buttons. A fixed layer that fills the whole window, such as an app shell or a backdrop, does not hold it.
How the card looks — bold text or regular, with the agent picture or without — is set on the same tab under Pop-up appearance, and applies to every message and the greeting alike.
What a rule can ask
- Page address contains — one or more parts of an address, matched against the full address of the page the visitor is on, ignoring case: the rule holds on a page that contains any of them, and on every page when none is given. A single-page app needs nothing extra: the snippet notices navigation on its own and asks again.
- Visitor — new, or returning. Returning means they have written in this chat before.
-
After the event — a name your page passed to
fellixWidget.track. It counts for the rest of the visit, and a rule waiting for it is checked the moment it is tracked. -
Site attribute equals — an attribute passed to
identifyorupdate, or carried in the signed token, which wins when both name it. Compared ignoring case; with no value, the rule only asks that the attribute is there. - Only visitors the site vouched for — the visitor arrived with a signed identity that verified.
The last three are under Advanced settings on each rule, because they need
your page to pass something. For example, a rule on After the event
cart_abandoned and Site attribute plan =
free answers to:
window.fellixWidget?.update({ plan: 'free' })
window.fellixWidget?.track('cart_abandoned')
How often a message shows
A message shows on every page its rule matches until the visitor closes it, taps it, or
opens the chat. After that it is not shown to them again. When they close one, the next
rule that matches follows on the same page, after its own delay; opening the chat also
retires the greeting. A channel can also have its matching messages take turns: while the
visitor neither closes nor taps the card, it gives way to the next one every few seconds. This is remembered in the
teased key on your own origin (see
What it touches), so clearing the site's data shows every
message again.
Nothing is shown while the chat is open, while the chat shows as away, or on phones when
the channel keeps the card off them. A panel embedded with
data-container draws no card at all: there the
matching message is the first line of the conversation instead.
What it touches on your page
The complete footprint, so you can predict it:
-
one
<button>appended to<body>, markeddata-fellix-launcher, at a very highz-indexunlessdata-z-indexnames another; none withdata-container; -
one
<iframe>, created hidden as soon as the snippet runs, pointing at the panel's own origin; withdata-containerit is appended to your element instead, shown; -
a proactive message card, marked
data-fellix-teaser, above the launcher while one of the channel's targeting rules — or its greeting — is speaking, there but hidden while it waits on something your page keeps in its place; -
a click listener on
document— for[data-fellix-open]— plusresizeandmessagelisteners onwindow, and a once-a-second look atlocation.href, so a single-page app's navigation reaches the targeting rules without a reload; -
three storage keys on your origin, all prefixed
fellix.webchat.:mutedandteasedinlocalStorage— the visitor's sound preference, and the proactive messages they have closed, tapped or answered by opening the chat, each of which is then not shown to them again — andopeninsessionStorage, so a panel left open survives the visitor clicking through your site but not their next visit. The conversation itself is not here: it lives in the panel's own storage, on Fellix's origin. Storage being denied outright costs the remembered preferences, and the proactive messages, which are then never shown rather than shown on every page; -
two events fired on
window:fellix:availability, andfellix:actionwhen the agent runs an action on your page; - the page title, briefly, when an unread reply arrives while the tab is in the background.
Not touched: your stylesheet, your globals beyond
window.fellixWidget, your cookies, and the document's own scroll position,
except that an opener scrolls the page to a
panel embedded in it.
destroy() reverses all of it.