signalbird/app · /react · /vue · /angular · /react-nativeApp - your own chat interface and mobile push
This surface is for your customer's user: a visitor, or a user of your app. It does live chat and push device registration.
You have two options:
| The ready-made widget | The App SDK | |
|---|---|---|
| Install | one <script> |
a package plus your own interface |
| Appearance | managed from the panel | entirely yours |
| Mobile | none | iOS, Android, React Native |
| When | chat on a site, quickly | chat embedded inside the product |
For the ready-made widget see the Widget document. This page describes the SDK.
The key
Open an app in the panel with Inbox → Settings → Apps (or with
createApp via the SDK). The sb_public_live_… key you are given
is public and is embedded in the client.
Its safety comes from restriction, not from secrecy:
- it works only from allowed origins (web),
- it touches only the visitor's own data,
- it cannot send anything and cannot read a contact list.
Do not put your team key (
sb_…) in the client. The constructor catches that and errors; and even if it did not, a key once published belongs to everyone.
The visitor secret and storage
The identity has two parts: the app key and the visitor secret. The secret
is returned only in the startSession response and storing it is
mandatory: if it does not survive on the device, the user loses their chat
history every time they open the app.
| Platform | Default | How to change it |
|---|---|---|
| Web | localStorage |
the storage option |
| React Native | none - you must supply one | asyncStorageAdapter(AsyncStorage) |
| iOS (Swift) | UserDefaults |
a SignalbirdStorage adapter |
| Android (Kotlin) | memory (tests only) | a SharedPreferences wrapper |
The stored record carries the app key too. If you rotate the key, the old identity is discarded automatically and a new session opens.
React / Next.js
npm install signalbirdimport { SignalbirdProvider, useChat, useIdentify } from 'signalbird/react'
export function App() {
return (
<SignalbirdProvider publicKey={process.env.NEXT_PUBLIC_SIGNALBIRD_APP_KEY!}>
<Chat />
</SignalbirdProvider>
)
}
function Chat() {
const {
enabled, // is chat on for this app (the bootstrap answer)
loading,
messages,
unread,
agentTyping,
withinHours, // false outside working hours
send,
typing,
markRead,
close,
} = useChat({ open: true })
// When the user signs in, bind the visitor to the contact record
useIdentify(user && { external_id: user.id, email: user.email, name: user.name })
if (!enabled) return null
return (
<>
{!withinHours && <OfflineBanner />}
{messages.map((m) => <Bubble key={m.id} message={m} />)}
{agentTyping && <Typing />}
<Composer onSend={send} onType={() => typing(true)} />
</>
)
}open changes only the polling rate; the session is not rebuilt every time
the panel is opened. For a badge there is a shortcut that works without the
chat interface being mounted:
import { useUnreadCount } from 'signalbird/react'
const unread = useUnreadCount()Push registration:
import { usePushRegistration } from 'signalbird/react'
usePushRegistration(token ? { token, platform: 'web', provider: 'webpush' } : null)Vue 3
import { signalbirdPlugin, useChat } from 'signalbird/vue'
app.use(signalbirdPlugin, { publicKey: import.meta.env.VITE_SIGNALBIRD_APP_KEY })<script setup lang="ts">
import { ref } from 'vue'
import { useChat } from 'signalbird/vue'
const isOpen = ref(false)
const { state, send, typing, markRead } = useChat({ open: isOpen })
</script>
<template>
<div v-for="m in state.messages" :key="m.id">{{ m.body }}</div>
<span v-if="state.agentTyping">typing…</span>
<input @input="typing(true)" @keyup.enter="send(($event.target as HTMLInputElement).value)" />
</template>Polling stops automatically when the component is unmounted.
Angular
// main.ts
import { provideSignalbird } from 'signalbird/angular'
bootstrapApplication(App, {
providers: [provideSignalbird({ publicKey: environment.signalbirdAppKey })],
})// chat.component.ts
import { SignalbirdService } from 'signalbird/angular'
@Component({
template: `
<div *ngFor="let m of (chat$ | async)?.messages">{{ m.body }}</div>
<input #box (keyup.enter)="send(box.value)" />
`,
})
export class ChatComponent implements OnInit {
private signalbird = inject(SignalbirdService)
chat$ = this.signalbird.chat$()
ngOnInit() { this.signalbird.setOpen(true) }
send(body: string) { this.signalbird.send(body) }
}The package carries no compile-time dependency on Angular (it uses no decorators), so upgrading your Angular version does not mean waiting for a new release.
React Native / Expo
import AsyncStorage from '@react-native-async-storage/async-storage'
import { AppState } from 'react-native'
import {
createSignalbirdApp,
asyncStorageAdapter,
useNativeChat,
useNativePush,
} from 'signalbird/react-native'
// Supplying the storage is MANDATORY
const client = createSignalbirdApp({
publicKey: 'sb_public_live_…',
storage: asyncStorageAdapter(AsyncStorage),
})
export function ChatScreen() {
const [foreground, setForeground] = useState(true)
useEffect(
() => AppState.addEventListener('change', (s) => setForeground(s === 'active')).remove,
[],
)
const { messages, send, agentTyping } = useNativeChat(client, {
open: true,
isForeground: foreground, // in the background the polling round is skipped: battery
})
useNativePush(client, token ? { token, platform: 'ios' } : null)
return <MessageList data={messages} typing={agentTyping} onSend={send} />
}Obtaining the push token (Expo Notifications or Firebase Messaging) and showing the permission dialog is the app's job: when you ask for that permission is a product decision, and the store rules take it seriously.
Swift (iOS)
Swift Package Manager: https://github.com/Pariette-Inc/signalbird.sdk
import Signalbird
let client = try SignalbirdApp(config: .init(
publicKey: "sb_public_live_…",
chatKey: "destek",
pushKey: "mobil"
))
// Is chat on, which colour, are we inside working hours
let boot = try await client.bootstrap()
try await client.startSession(["name": "Ayşe", "email": "ayse@example.com"])
try await client.startConversation(body: "Where is my parcel?")
// New messages: with a cursor, so the whole list is not downloaded again
let updates = try await client.getConversation(conversationID, after: lastMessageID)
try await client.setTyping(conversationID: conversationID, isTyping: true)
try await client.markRead(conversationID: conversationID, lastMessageID: lastID)
// APNs
try await client.registerDevice(token: apnsToken, externalID: user.id, identityHash: user.signalbirdHash)If you want Keychain, supply your own storage:
struct KeychainStorage: SignalbirdStorage {
func get(_ key: String) -> String? { … }
func set(_ key: String, _ value: String) { … }
func remove(_ key: String) { … }
}
let client = try SignalbirdApp(config: .init(
publicKey: "sb_public_live_…",
chatKey: "destek",
pushKey: "mobil",
storage: KeychainStorage()
))Kotlin (Android)
implementation("io.signalbird:signalbird-sdk:1.9.0")import io.signalbird.sdk.*
class PrefsStorage(private val prefs: SharedPreferences) : SignalbirdStorage {
override fun get(key: String) = prefs.getString(key, null)
override fun set(key: String, value: String) = prefs.edit().putString(key, value).apply()
override fun remove(key: String) = prefs.edit().remove(key).apply()
}
val client = SignalbirdApp(SignalbirdAppConfig(
publicKey = "sb_public_live_…",
chatKey = "destek",
pushKey = "mobil",
storage = PrefsStorage(prefs),
))
lifecycleScope.launch {
client.bootstrap()
client.startSession(mapOf("name" to "Ayşe"))
client.startConversation("Where is my parcel?")
val updates = client.getConversation(conversationId, after = lastMessageId)
client.registerDevice(token = fcmToken, externalId = user.id, identityHash = user.signalbirdHash)
}Every method is suspend and runs on the IO dispatcher.
Without a framework (plain TypeScript)
import { SignalbirdApp, ChatSession } from 'signalbird/app'
const client = new SignalbirdApp({ publicKey: 'sb_public_live_…' })
// The raw endpoints
await client.bootstrap()
await client.startSession({ name: 'Ayşe' })
await client.startConversation({ body: 'Hello' })
// or the state layer
const session = new ChatSession(client, { active: true })
session.subscribe((state) => render(state))
await session.start()
await session.send('Where is my parcel?')ChatSession takes on: optimistic sending (the message lands in the list right
away and is replaced by the server's answer), the polling ladder, the unread
count and the typing state.
How polling works
There is no WebSocket. Cursor-based polling recovers by itself when the connection drops and does not burn battery on a mobile network.
| State | Interval |
|---|---|
| Panel open | 3 s |
| Panel closed - first 3 rounds | 20 s |
| the next 2 rounds | 60 s |
| after that | 180 s |
| Tab/app in the background | the round is skipped |
The ladder resets when new data arrives: waiting three minutes for the next poll just as a conversation comes back to life would leave the user feeling abandoned.
Identity verification (identity_hash)
The app key is public; anyone could put someone else's id into external_id.
Signalbird therefore treats external_id (and email) as trusted only when
a valid identity_hash comes with it (2.7.0). Without the hash:
- the visitor is not linked to your contact record,
- the device is not bound to that user as a push target,
- the channel agent's tools see the identity only under
visitor.unverified.
Generate the hash on your own server with the secret key and hand it to the app with the signed-in user (e.g. in your login response):
identity_hash = hex( HMAC-SHA256( hex(SHA-256(sb_secret_live_…)), external_id ) )Server helpers: PHP Signalbird::identityHash($id), Node
client.identityHash(id), Python client.identity_hash(id), Go
client.IdentityHash(id), .NET client.IdentityHash(id).
await client.startSession({ external_id: user.id, identity_hash: user.signalbirdHash })
await client.identify({ external_id: user.id, identity_hash: user.signalbirdHash, email: user.email })
await client.registerDevice({ token, platform: 'web', external_id: user.id, identity_hash: user.signalbirdHash })
// `identityHash` is accepted too; it is sent as `identity_hash`.In Swift and Kotlin add "identity_hash" to the dictionary/map input;
registerDevice(…, identityHash:) takes it as a parameter.
The visitor object carries two fields: identity_verified (the hash was
valid) and verified (captcha passed). Captcha exists only in the hosted web
widget; mobile requests without an Origin are exempt and this SDK sends no
token. The captcha field in the bootstrap response is informational.
Version notice
The SDK reports its version on every request: X-Signalbird-Sdk: app/<version> (react-native/<version> in React Native).
When a newer version is released the API responds with
Signalbird-Sdk-Status: outdated (or unsupported once it is no longer
supported) and the SDK writes a warning once per process (console.warn).
The warning is not an error: the request result does not change.
In the panel, Domains → [domain] → Keys shows which SDK version each key is used with; keys on an old version also get a banner at the top of the panel and a notification. To update:
npm i signalbird@latestError behaviour
No method throws an exception (apart from the key check in the constructor). The
result is always the {ok, status, code?, message?} envelope.
| Code | Meaning |
|---|---|
VISITOR_INVALID |
The secret is invalid: the local identity is cleared and the next call opens a new session |
CHAT_UNAVAILABLE |
The quota is full or chat is off |
ORIGIN_NOT_ALLOWED |
This domain is not defined on the app |
MODULE_DISABLED |
Your package does not include the chat or push module |
NETWORK_ERROR / TIMEOUT |
Network (status 0) |
The full method list (17 methods) and the rules of behaviour:
signalbird.sdk/docs/CONTRACT.md § 11.