npmjs.com ↗
Installnpm install signalbird · composer require pariette/signalbird · pip install signalbird

Management - 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

bash
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.Sdk

The method names are identical in all five languages; only the naming convention changes (createModuleKey / create_module_key / CreateModuleKey / CreateModuleKeyAsync).

Quick start

typescript
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:

typescript
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:

typescript
{ 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.

typescript
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)

key can 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

typescript
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.

typescript
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

typescript
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 it

Managing the flow

typescript
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

typescript
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

typescript
// 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.

ts
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

ts
const { data } = await management().chatReport('30d')   // 7d | 30d | 90d
json
{
  "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.
  • null when there is no data, not 0. "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.

typescript
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-channel on 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.

ts
const { data } = await admin.embedToken({ module: 'chat' })

// data.url → a 120-second, SINGLE-USE address
// <iframe src={data.url} />
php
$embed = Signalbird::management()->embedToken(['module' => 'chat']);
$url = $embed['data']['url'];

Modules: chat · topics · messages · contacts · campaigns · monitoring · radio · members.

Three things to know:

  1. Mint the address on your server. The team key must not reach a browser; only the short-lived address does.
  2. The token lives 120 seconds and is used once. Because it travels in the URL it lands in the Referer header and in logs; that is what the short life is for. Once the iframe has loaded, a 60-minute session is established.
  3. Your key must have the embed:issue scope. 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 no user_id the 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
// PHP / Laravel
Signalbird::management()->createModuleKey('logger', ['name' => 'payment-service']);
Signalbird::management()->reply($conversationId, ['body' => 'Hello!']);
python
# Python
admin = SignalbirdManagement(domain_key=os.environ["SIGNALBIRD_DOMAIN_KEY"])
admin.create_module_key({"name": "payment-service"})
admin.reply(conversation_id, {"body": "Hello!"})
go
// Go
admin, _ := signalbird.NewManagement(signalbird.KeyConfig{DomainKey: key})
admin.CreateModuleKey(ctx, "logger", map[string]any{"title": "Payment errors"})
csharp
// .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.