npm install signalbird · composer require pariette/signalbird · pip install signalbirdManagement - run your own project from code
Everything you do by clicking in the panel, done from code: opening Radio channels, reading the event stream, working the chat inbox, managing app records and device lists.
This is not an admin surface. The key is bound to a single team and touches only that team's records; another team's record returns 404. User management, billing, subscriptions and plan operations are not in the SDK and will not be: those belong to the panel and to the company owner.
When it helps:
- Environment setup: opening Radio channels in CI for every new environment (staging, prod, a customer installation).
- Your own agent interface: embedding chat in your own panel or bridging it into a CRM.
- Bots: writing an automatic first reply into the inbox, classifying a conversation.
- Shipping a mobile release without having to open the app record by hand.
The key
The scope system was removed on 1 Sep 2026. Management uses the same key
as Messaging: the secret domain key (sb_secret_live_…). Create it in the
panel under Domains → [domain] → Keys → New key; it is shown once and goes
into your project's .env as SIGNALBIRD_DOMAIN_KEY. A server key needs no
DNS verification of the domain.
The secret key only works server-side; a request carrying an Origin header
(i.e. from a browser) gets 401 SECRET_KEY_IN_BROWSER. The public key
(sb_public_live_…) never opens management endpoints.
Install
npm install signalbird # Node.js / TypeScript
composer require pariette/signalbird # PHP / Laravel
pip install signalbird # Python
go get github.com/Pariette-Inc/signalbird.sdk/v2
dotnet add package Signalbird.SdkThe method names are identical in all five languages; only the naming
convention changes (createModuleKey / create_module_key /
CreateModuleKey / CreateModuleKeyAsync).
Quick start
import { management } from 'signalbird'
// read from the SIGNALBIRD_DOMAIN_KEY environment variable
const created = await management().createModuleKey('logger', { name: 'payment-service' })
// `secret` is returned HERE ONLY: the server keeps only its SHA-256 digest.
// If you lose it, issue a new one in the panel; it cannot be read back.
await vault.write('SIGNALBIRD_DOMAIN_KEY', created.data!.secret)If you would rather build your own client:
import { SignalbirdManagement } from 'signalbird'
const admin = new SignalbirdManagement({
domainKey: process.env.SIGNALBIRD_DOMAIN_KEY!,
timeout: 15000,
throwOnError: false, // the default; you can turn it on in development
})If you hand over a public key (sb_public_live_…) you get
WRONG_KEY_TYPE while constructing the client, not on the first request.
Result shape
Every method returns the same envelope:
{ ok: true, status: 201, data: { … } }
{ ok: false, status: 403, code: 'API_KEY_SCOPE', message: '…', data: { … } }With throwOnError: true a SignalbirdError is raised instead of the error
envelope (carrying code, status, body).
Radio management
Channels
Radio has no projects; a channel is a module key (module logger). The
channel holds the notification rule: who gets it, push or email, what happens
in quiet hours, how often the same message alerts. A channel has NO secret:
its name lives in your code. The only secret is the domain key, created in the
panel.
await admin.listModuleKeys('logger')
const { data } = await admin.createModuleKey('logger', {
title: 'Payments',
key: 'payments', // the name you call as radio('payments') in code
level: 'critical', // the channel's default level
notify: ['push', 'email'],
recipient_user_ids: [12, 34], // empty = the whole team
quiet_from: 0,
quiet_to: 7, // critical still breaks quiet hours
dedupe_seconds: 300, // do not re-announce the same message within 5 min
})
await admin.getModuleKey('logger', data!.module_key.id)
await admin.updateModuleKey('logger', data!.module_key.id, { level: 'error' })
await admin.deleteModuleKey('logger', data!.module_key.id)
keycan be renamed; the old name is still accepted for 30 days so production code does not lose records before the next deploy.
A log sent to an unknown name opens the channel automatically and silently (the team sets the rule). For AWS SNS / CloudWatch, create the channel's inbound address in the panel (Radio → Channels → AWS / Webhook).
Event stream
await admin.radioSummary()
await admin.radioEvents({
key: 'payments',
level: ['critical', 'error'],
q: 'payment',
from: '2026-08-01',
page: 1,
per_page: 50,
})Chat - the agent side
The same endpoints as the panel's Inbox screen. The user who minted the key is recorded as the "agent"; an ownerless (inherited) key cannot write.
const inbox = await admin.listConversations({ status: 'open', per_page: 20 })
const detail = await admin.getConversation(conversationId)
const messages = await admin.listConversationMessages(conversationId, {
after: lastMessageId, // a cm_… cursor; does not pull the whole list again
include_internal: true, // internal notes: the visitor never sees these
})Replying
await admin.reply(conversationId, { body: 'Hello! We will be right with you.' })
// An internal note: visible in the inbox, NEVER sent to the visitor
await admin.reply(conversationId, { body: 'Asked the payments team', is_internal: true })
await admin.editChatMessage(conversationId, messageId, 'Corrected text')
await admin.deleteChatMessage(conversationId, messageId)
await admin.reactToChatMessage(conversationId, messageId, '👍') // a second call removes itManaging the flow
await admin.assignConversation(conversationId) // assign to the key's owner
await admin.assignConversation(conversationId, 42) // assign to a specific member
await admin.setConversationStatus(conversationId, 'resolved')
await admin.updateConversation(conversationId, { priority: 'high', tags: ['payment'] })
await admin.readConversation(conversationId, lastMessageId)
await admin.setTyping(conversationId, true)Visitors and canned replies
await admin.getVisitor(visitorId)
await admin.updateVisitor(visitorId, { name: 'Ayşe Yılmaz', attributes: { plan: 'pro' } })
await admin.banVisitor(visitorId)
await admin.listCannedReplies()
await admin.createCannedReply({ shortcut: '/shipping', title: 'Shipping', body: 'Your parcel is on its way.' })
await admin.updateCannedReply(id, { body: 'Your parcel went out today.' })
await admin.deleteCannedReply(id)A bot pattern
// Every minute: write the first reply into newly opened conversations
const inbox = await management().listConversations({ status: 'open' })
for (const conversation of inbox.data?.data ?? []) {
if (conversation.unread_count === 0) continue
await management().reply(conversation.id, {
body: 'Hello! Our team will answer within a few minutes.',
})
await management().setConversationStatus(conversation.id, 'pending')
}Keep the polling interval reasonable: the read limit is 240 requests per minute, the write limit 60.
Chat - triggers
"When this happens, do that." The rule lives in the record, not in code, so the customer does not have to ship a release to change a behaviour.
await management().createChatTrigger({
name: 'Pricing question → sales',
event: 'visitor.message', // conversation.created | visitor.message | no_reply
conditions: {
match: 'all',
rules: [{ field: 'body', op: 'contains', value: 'price' }],
},
actions: [
{ type: 'reply', body: 'Hello! We are sending our price list right away.' },
{ type: 'tag', value: 'sales' },
{ type: 'priority', value: 'high' },
],
})| Field | Values |
|---|---|
event |
conversation.created · visitor.message · no_reply (after delay_seconds, if there is still no reply) |
| condition field | body · page_url · source · within_hours · visitor_is_new · visitor_email · tag |
| operator | contains · not_contains · eq · neq · exists · not_exists · is_true · is_false |
| action | reply · internal_note · tag · priority · assign |
The condition list may be left empty: the trigger then runs on every occurrence of that event (this is how a greeting message is set up). The same trigger speaks once per conversation; otherwise every message containing "price" would get the same reply.
An automatic reply is written with the system sender, not as an agent. If
it looked like an agent the visitor would believe a human was there, the
first_response_at stamp would lie, and your SLA report would be ruined.
The assign action runs only on an unassigned conversation: if a human has taken it, automation does not take it off their desk.
Chat - reporting
const { data } = await management().chatReport('30d') // 7d | 30d | 90d{
"volume": { "total": 412, "open": 18, "resolved": 380, "closed": 14, "unanswered": 6 },
"first_response": { "median_s": 96, "p90_s": 640, "answered": 406 },
"resolution": { "median_s": 1820, "p90_s": 9400, "resolved": 380 },
"satisfaction": { "average": 4.6, "count": 210, "breakdown": { "5": 160, "4": 35 } },
"agents": [{ "user_id": 7, "name": "Zeynep", "assigned": 190, "replies": 812,
"median_first_response_s": 74, "rating_average": 4.8 }]
}Two things are deliberate here:
- Median plus p90, not the mean. One reply at midnight drags the mean up by hours and produces a number nobody recognises. The median describes the typical day, p90 the bad day.
nullwhen there is no data, not0. "We answer in 0 seconds" is the most dangerous lie on a reporting screen. Show a dash instead.
volume.unanswered is the most important number in the report: conversations
that never saw an agent reply.
Apps
An app owns the public key (sb_public_live_…) embedded in your customer's site or
mobile application.
const app = await admin.createModuleKey('chat', {
name: 'Mobile - iOS',
platform: 'ios',
chat_enabled: true,
push_enabled: true,
})
// public_key is NOT secret; it is embedded in the client anyway
console.log(app.data!.public_key) // sb_public_live_…
await admin.updateModuleKey('chat', id, { allowed_origins: ['https://example.com'] })
await admin.updateModuleKey('chat', id) // the old key is invalid IMMEDIATELY
await admin.listModuleKeyDevices('push', id, { page: 1 }) // tokens come back masked
await admin.deleteModuleKey('chat', id)Rotating a key does NOT change the channel name: you rotate the domain key in the panel and the
data-channelon your site stays the same. In v1, rotating a key broke the embed line.
Showing a Signalbird screen in YOUR panel
You can embed the inbox, the monitors or the campaign screen in your own administration panel. The screen is Signalbird itself, not a copy: as we update it, yours updates too.
const { data } = await admin.embedToken({ module: 'chat' })
// data.url → a 120-second, SINGLE-USE address
// <iframe src={data.url} />$embed = Signalbird::management()->embedToken(['module' => 'chat']);
$url = $embed['data']['url'];Modules: chat · topics · messages · contacts · campaigns ·
monitoring · radio · members.
Three things to know:
- Mint the address on your server. The team key must not reach a browser; only the short-lived address does.
- The token lives 120 seconds and is used once. Because it travels in the
URL it lands in the
Refererheader and in logs; that is what the short life is for. Once the iframe has loaded, a 60-minute session is established. - Your key must have the
embed:issuescope. That scope is separate on purpose: an embed token turns into a panel session, so letting a narrowly-scoped key mint one would make the scope restriction meaningless. If you pass nouser_idthe key's owner is used; if you do pass one, that person must be a member of the team.
Error codes
| Code | Meaning |
|---|---|
WRONG_KEY_TYPE |
A key other than sb_ was given, at construction time |
API_KEY_INVALID |
The key is missing, expired or revoked |
API_KEY_SCOPE |
The key lacks the required scope (see the required_scope field) |
API_KEY_IP_BLOCKED |
The key hit its IP restriction |
MODULE_DISABLED |
Your package does not include this module (logger, chat) |
VALIDATION_ERROR |
422 - the body did not pass validation |
NETWORK_ERROR / TIMEOUT |
Network (status 0) |
HTTP_<status> |
When the server returned no code |
A 404 means two things and they are not distinguished: the record does not exist, or it belongs to another team. That is deliberate: otherwise guessing ids would reveal how many records other teams have.
Other languages
// PHP / Laravel
Signalbird::management()->createModuleKey('logger', ['name' => 'payment-service']);
Signalbird::management()->reply($conversationId, ['body' => 'Hello!']);# Python
admin = SignalbirdManagement(domain_key=os.environ["SIGNALBIRD_DOMAIN_KEY"])
admin.create_module_key({"name": "payment-service"})
admin.reply(conversation_id, {"body": "Hello!"})// Go
admin, _ := signalbird.NewManagement(signalbird.KeyConfig{DomainKey: key})
admin.CreateModuleKey(ctx, "logger", map[string]any{"title": "Payment errors"})// .NET
await management.CreateModuleKeyAsync("logger", new Dictionary<string, object?> { ["title"] = "Payment errors" });The full method list (45 methods) and the rules of behaviour:
signalbird.sdk/docs/CONTRACT.md § 10.