<script async src="https://signalbird.io/sdk/v1/signalbird.js" data-key="sb_public_live_…" data-channel="destek"></script>Web Widget - Live chat (signalbird.js)
A live chat bubble you add to your site with a single line. Visitors write,
your team replies from Inbox in the panel. The widget also links a visitor
to a contact (identify) and registers web push devices
(push.register), so the same person can be targeted in campaigns.
Zero dependencies, under 40 KB gzip, runs inside Shadow DOM - your CSS does not leak into the widget and the widget's CSS does not leak into your site.
Install
<script async
src="https://signalbird.io/sdk/v1/signalbird.js"
data-app-key="sb_public_live_xxxxxxxxxxxxxxxx"></script>That is all. On load the script calls POST /v1/sdk/bootstrap to fetch the
app settings and draws the bubble. Put it right before </body> or in
<head> with async; it does not block page load.
Key
In the panel open Inbox → Settings → Apps → New app; you get an app key
(sb_public_live_…).
This key is public - it lives in your HTML, anyone can see it. Its safety
comes from constraint: requests from outside the allowed origins (domains)
in the app settings are rejected with 403. When installing on a new domain
(staging, a new site) add it to the allowed origins first.
The secret key (
sb_secret_live_…) does not work here;sb_…is secret and must never be embedded in HTML.
Options
Passed via the script tag (data-*) or via Signalbird.init(); both map to
the same fields.
data-* |
init() field |
Default | Description |
|---|---|---|---|
data-app-key |
publicKey |
- | Required. Must start with sb_public_live_ |
data-base-url |
baseUrl |
https://signalbird.io/api |
For self-hosted setups |
data-locale |
locale |
browser language | tr or en |
| - | user |
- | User known at page load (same shape as identify) |
data-external-id |
user.external_id |
- | The signed-in user's id in your system |
data-identity-hash |
identityHash |
- | Hash of external_id - generated on your server (Identity verification) |
| - | debug |
false |
Verbose console output |
With data-app-key present the widget boots itself; you do not need to call
init(). To decide the key at runtime, omit data-app-key and call
Signalbird.init({...}).
API
window.Signalbird is ready once the script has loaded. No method ever
throws into the host page: a widget problem must not break your site.
| Method | Description |
|---|---|
Signalbird.init({ publicKey, baseUrl?, locale?, user?, identityHash?, debug? }) |
Manual setup |
Signalbird.identify({ external_id, identityHash?, email?, name?, phone?, attributes? }) |
Links the visitor to a contact - call after login. Without identityHash the identity is treated as unverified |
Signalbird.chat.open() · close() · toggle() |
Opens / closes the panel |
Signalbird.chat.isOpen() |
boolean |
Signalbird.chat.on('unread' | 'open' | 'close', fn) · off(event, fn) |
Event listener. unread is called with the count |
Signalbird.push.register({ token, platform, provider?, external_id? }) |
Adds the device to the push registry. platform: web · ios · android; provider: fcm · apns · webpush |
Signalbird.destroy() |
Removes the widget from the DOM and stops timers |
Identifying the user
A visitor starts anonymous (localStorage['sb_visitor']). Call identify
when they log in; chat history and the contact record are linked to that
user, and your team sees their name in Inbox.
<script>
Signalbird.identify({
external_id: 'user-1042', // the id in your own system
identityHash: '{{ hash }}', // generated on YOUR server - see below
email: 'ahmet@example.com',
name: 'Ahmet Çil',
attributes: { plan: 'pro', company: 'Penyu' },
})
</script>external_id is the stable id in your system; the person stays the same even
if the email changes. With the same external_id you can call
sendPush({ to: 'external:user-1042' }) from the Messaging SDK.
Opening programmatically
Open the panel from your own "Help" link:
<button onclick="Signalbird.chat.open()">Chat with us</button>Signalbird.chat.on('unread', (count) => {
document.querySelector('#help-badge').textContent = count > 0 ? String(count) : ''
})Web push registration
After you obtain a token with your own service worker, register the device; the contact becomes targetable in push campaigns from the panel.
const token = await getFcmToken() // your FCM / Web Push flow
Signalbird.push.register({ token, platform: 'web', provider: 'fcm', external_id: 'user-1042' })React / Next.js
'use client'
import Script from 'next/script'
export function SignalbirdWidget({ user }: { user?: { id: string; email: string; name: string } }) {
return (
<Script
src="https://signalbird.io/sdk/v1/signalbird.js"
data-app-key={process.env.NEXT_PUBLIC_SIGNALBIRD_APP_KEY}
strategy="afterInteractive"
onLoad={() => {
if (user) window.Signalbird?.identify({ external_id: user.id, email: user.email, name: user.name })
}}
/>
)
}A useEffect works too: create a <script> element, append it to
document.body, and call window.Signalbird?.identify(...) on its load
event. Calling Signalbird.destroy() on unmount is optional; the widget is a
singleton and ignores a second init.
Features
- Launcher bubble + panel; fullscreen at ≤ 640 px
- The empty state is an invitation, not a form: brand avatar, greeting, response-time promise and quick-start chips derived from your support topics. Tapping a chip sends it as a message - "I don't know what to write" is the most common reason people leave
- Three ways it makes itself noticed: a greeting card shown once, 11 s in, if the visitor has never opened the panel; a preview of the reply above the bubble while the panel is closed; a quiet pulse on the bubble while anything is unread. All three are dismissible and the choice is stored in the browser
- Prechat form (name / email - per app settings), greeting, offline banner outside working hours
- Day separators, ✓ / ✓✓ read receipts, typing indicator both ways
- Message grouping: consecutive messages from the same sender within 5 min form one block; avatar and time appear only on the last line
- Emoji picker, a jump to latest button in long threads, links in message bodies are clickable
- 420 characters per message (served by the API, configurable). A counter appears over the last 60 characters and sending is blocked past the limit. It applies to both sides: what burns tokens is length, not who typed it
- File uploads: drag & drop, paste; jpeg / png / gif / webp / avif, PDF
and plain text - capped at
max_attachment_mb. There is deliberately noimage/*wildcard:image/svg+xmlis a scriptable document, not an image. The type is read from the file's contents server-side; renaming won't help - Reply with quote, emoji reactions, edit / delete your own message within 15 min
- Unread badge +
document.titleblink, sound Entersends,Shift+Enterinserts a newline- tr / en auto-detected; 1–5 rating on close
- Live connection (socket.io); falls back to polling when it cannot connect (20 s → 180 s while closed, 3 s while open)
- The visitor moves and resizes the panel: drag the header, pull the grip
in the outer top corner. The choice is stored in the browser
(
localStorage['sb_geometry']); disabled on mobile - Mobile: fullscreen (
100dvh), notch and home-bar safe areas, the composer stays above the keyboard, the page behind does not scroll, and the handle at the top closes the panel when dragged down - Light / dark theme, from the
themesetting or the visitor's own preference (auto) - Ending a chat starts a fresh one next time - a closed thread never reopens
- Shadow DOM isolation, brand colour / logo / position from the panel
Panel settings
There are no appearance options in code; everything lives under Inbox → Settings → Apps and reaches the widget via bootstrap.
| Panel setting | Effect in the widget |
|---|---|
Chat on / off (chat_enabled) |
When off the widget renders nothing |
| Allowed origins | 403 from any domain not on the list |
| Theme colour, position (right / left) | Brand colour and the panel's starting place |
Logo URL (logo_url) |
Header avatar; the launcher when launcher_icon: logo |
Theme (theme: light / dark / visitor's preference) |
The widget's own surface - never the host page |
Launcher icon (launcher_icon: bird / bubble / logo) |
The mark on the launcher |
Launcher behaviour (launcher_mode: always / on request) |
With manual the launcher is never drawn; your own button opens the chat |
Panel layout (layout: corner window / edge drawer / inline) |
sidebar docks a full-height panel to the edge; inline renders the chat inside the page |
Inline container (inline_selector) |
CSS selector of the element the inline chat renders into (defaults to #signalbird-chat) |
Agent display name (agent_display_name) |
Visitors see this instead of the agent's real name |
Per-language texts (texts) |
Greeting, offline note and launcher label follow the visitor's language |
| Launcher text, greeting | Label next to the bubble, first message |
| Prechat form fields | Whether name / email are required |
| Working hours | Offline banner and "we'll get back to you" note |
max_attachment_mb |
Upload cap |
Changing a setting needs no redeploy; it applies on the next page load.
Opening the chat from your own button
If your site already has a "Live support" button, you can hide the launcher
entirely. Pick launcher behaviour: on request (launcher_mode: manual) in
the panel and wire one line to your button:
<button onclick="Signalbird.chat.open()">Live support</button>Once a conversation starts the launcher becomes visible: if the visitor closes the window, the agent's reply is announced there with a badge and a sound. When the visitor ends the chat the launcher disappears again, and the agent can no longer write to that conversation.
Identity verification and captcha
Identity verification (2.7.0). The public key sits in your page source, so
anyone can call identify({ external_id: 'user-1042' }). Signalbird therefore
treats external_id (and email) as trusted only when a valid
identityHash comes with it. Without the hash:
- the visitor is not linked to your contact record,
- the device is not bound to that user as a push target
(
external:user-1042), - the channel agent's tools do not get it as a trusted visitor identity, only
under
visitor.unverified.
The hash is generated on YOUR server with the secret key and written into the page next to the user's id; the secret key never reaches the browser:
identity_hash = hex( HMAC-SHA256( hex(SHA-256(sb_secret_live_…)), external_id ) )Every server language ships a helper: PHP Signalbird::identityHash($id),
Node client.identityHash(id), Python client.identity_hash(id), Go
client.IdentityHash(id), .NET client.IdentityHash(id).
<!-- Laravel Blade -->
<script async src="https://signalbird.io/sdk/v1/signalbird.js"
data-key="sb_public_live_…" data-channel="support"
data-external-id="{{ $user->id }}"
data-identity-hash="{{ Signalbird::identityHash((string) $user->id) }}"></script>Signalbird.identify({ external_id: 'user-1042', identityHash: hashFromYourServer, email: 'jane@example.com' })The widget adds the known hash to the session, identify and (same
external_id) push.register calls on its own.
Captcha. When enabled on your channel, the widget obtains an invisible Cloudflare Turnstile token at two points: when a new visitor's session is created and when an unverified visitor starts their first conversation. Nothing to set up:
- The Turnstile script loads only when the visitor first opens the chat; visitors who never open it never download it.
- Most visitors see nothing; if Cloudflare needs an interaction, the box shows up inside the panel.
- If too many chats are started in a short time the visitor sees a polite
notice (429
CONVERSATION_RATE_LIMITED). - If you use a Content Security Policy, add
https://challenges.cloudflare.comtoscript-srcandframe-src.
Privacy and security
- The visitor id and secret live only in
localStorage['sb_visitor']; no cookies are written, nothing goes to third parties. - Live translation runs only when the two sides use different languages, and each side sees only the OTHER side's message translated; your own text always stays as you wrote it.
- Internal notes your team writes in Inbox are never sent to the visitor.
- The app key is public; protection is the allowed-origins list. Keep it
tight (no
*). - The widget does not read your page's DOM or touch your form fields; it only works inside its own Shadow DOM root.
- Uploaded files go to your team's storage and are visible only to the parties of that conversation.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Widget never appears, console is quiet | Chat is disabled on the app (chat_enabled: false). Enable it in the panel |
bootstrap 403 in the console |
Domain is not in allowed origins - add it with the https:// scheme; localhost:3000 must be added separately |
bootstrap 401 |
Wrong key type (secret instead of public) or the key was revoked |
| Bubble shows, messages don't send | Check /v1/sdk/* requests in the Network tab; debug: true logs details to the console |
| No history after login | identify was not called, or a different external_id was used |
| Visitor not linked to the contact, push doesn't reach the user | identityHash missing or generated with the wrong key - identity is treated as unverified |
| "We couldn't complete the security check" banner | Turnstile failed to load or was rejected; check that your CSP allows challenges.cloudflare.com |
| Site CSS broke the widget | Should not happen (Shadow DOM). If the page has aggressive global rules like all: unset, report it with debug output |