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

Messaging SDK - Node.js & PHP / Laravel

The Signalbird Messaging client. From your server it sends email, SMS and push; manages contacts and lists; launches campaigns; reads message status and verifies webhook signatures.

It is separate from the Radio (Telsiz) client (SignalbirdClient / Signalbird::log()): different key, different gate, different quota. Same package, different class.

text
team key   →  sb_…                       (Console → SDK keys)
scopes     →  email:send, contacts:write… (chosen when the key is created)
class      →  transactional | commercial  (REQUIRED on every send)

Key and scopes

In the panel open Console → SDK keys → New key to create a team key (sb_…). It is shown once; it is secret and server-only - never embed it in a browser or mobile app.

Scopes are picked when the key is created. A method only works if its scope is on the key; otherwise you get API_KEY_SCOPE.

Scope Unlocks
email:send · sms:send · push:send sendEmail, sendSms, previewSms, sendPush
contacts:read · contacts:write contact and list read / write, bulkContacts
campaigns:read · campaigns:write list, create and cancel campaigns
email:read · sms:read · push:read message log (listMessages, getMessage)

If a public key is passed (sb_public_live_…) the client throws WRONG_KEY_TYPE at construction - in the first second, not as a 401 discovered weeks later.

Install

bash
npm install signalbird        # Node 18+, zero dependencies
composer require pariette/signalbird    # PHP 8.1+, ext-curl, no Guzzle

Quick start

Node.js / TypeScript

typescript
import { SignalbirdMessaging } from 'signalbird'

const sb = new SignalbirdMessaging({ domainKey: process.env.SIGNALBIRD_DOMAIN_KEY! })

const res = await sb.sendEmail({
  to: 'ahmet@example.com',
  class: 'transactional',
  subject: 'Your order was received',
  body: '<p>Order #1042 is being prepared.</p>',
})

if (res.ok) console.log(res.data.id, res.data.status)   // 'msg_…', 'queued'
else console.warn(res.code, res.message)                // 'NO_SENDING_DOMAIN', …

Config: { domainKey, baseUrl?, timeout?, throwOnError?, debug? } - defaults https://signalbird.io/api, 15000 ms, false, false.

PHP

php
use Signalbird\Sdk\Messaging\MessagingClient;

$sb = new MessagingClient(getenv('SIGNALBIRD_DOMAIN_KEY'));

$res = $sb->sendSms([
    'to'    => '+905321234567',
    'class' => 'transactional',
    'body'  => 'Your verification code: 482913',
]);

if ($res['ok']) { $id = $res['data']['id']; }
else            { $code = $res['code']; }

Constructor: new MessagingClient(string $domainKey, ?string $baseUrl = null, int $timeout = 15, bool $throwOnError = false).

Laravel

bash
# .env
SIGNALBIRD_DOMAIN_KEY=sb_xxxxxxxxxxxxxxxxxxxxxxxx
# SIGNALBIRD_MESSAGING_URL=https://signalbird.io/api   # optional
php
use Signalbird\Sdk\Facades\Signalbird;

$res = Signalbird::messaging()->sendPush([
    'to'      => 'external:user-1042',
    'class'   => 'commercial',
    'subject' => 'Weekend sale',
    'body'    => '20% off everything in your cart',
]);

Signalbird::messaging() is built from config('signalbird.messaging_key') and is a singleton. Radio methods (Signalbird::info() etc.) are unaffected.

Methods

Method names are identical in Node and PHP; Node takes objects, PHP takes arrays. Field names match the API (snake_case) - the SDK does not rename them.

Area Method HTTP Notes
Send sendEmail({ to, class, subject?, body?, template?, template_id?, vars?, sending_domain_id?, sending_address_id?, contact_id?, from_name?, reply_to?, attachments?, module_key? }) POST /v1/email/send 202 → { id, status, units }
Send sendSms({ to, class, body, brand_id?, contact_id? }) POST /v1/sms/send E.164 number
Send previewSms(body) POST /v1/sms/preview Segment/character count, no quota used
Send sendPush({ to, class, subject, body, vars?, contact_id? }) POST /v1/push/send to: token, contact:<id> or external:<id>
Contacts listContacts({ q?, list_id?, page?, per_page? }) GET /v1/contacts Paginated
Contacts createContact({ email?, phone?, first_name?, last_name?, attributes?, list_ids?, consent_source?, consent_text? }) POST /v1/contacts
Contacts updateContact(id, partial) PATCH /v1/contacts/{id}
Contacts deleteContact(id) DELETE /v1/contacts/{id}
Contacts bulkContacts({ contacts, list_id?, consent_source?, consent_text? }) POST /v1/contacts/bulk Chunks of 1000, sequential
Lists listContactLists() GET /v1/contact-lists
Lists createContactList({ name, description? }) POST /v1/contact-lists
Lists deleteContactList(id) DELETE /v1/contact-lists/{id}
Campaigns listCampaigns({ status?, channel?, page?, per_page? }) GET /v1/campaigns
Campaigns createCampaign({ name, channel, domain_id, list_id | segment_id, body, subject?, sending_domain_id?, brand_id?, scheduled_at?, metadata?, external_ref? }) POST /v1/campaigns 202 → { batch, class, summary }
Campaigns getCampaign(id) GET /v1/campaigns/{id} { batch, status_breakdown, jobs }
Campaigns cancelCampaign(id) POST /v1/campaigns/{id}/cancel ALREADY_FINISHED if done
Campaigns listCampaignMessages(id, { page?, per_page?, status? }) GET /v1/campaigns/{id}/messages Paginated
Campaigns iterateCampaignMessages(id, query?) (multiple GETs) Node: for await; PHP: Generator. Throws if a page fails
Messages listMessages({ status?, channel?, batch_id?, page?, per_page? }) GET /v1/messages Recipient is masked
Messages getMessage(id) GET /v1/messages/{id}

class is required and has no default. The SDK does not decide whether a message is transactional or commercial - that is a legal gate. Commercial messages are subject to consent (NO_CONSENT) and the suppression list (SUPPRESSED).

Result shape and error handling

Every method returns the same shape:

typescript
// Node
type SbResult<T> =
  | { ok: true;  status: number; data: T }
  | { ok: false; status: number; code: string; message: string; data?: unknown }
php
// PHP
['ok' => bool, 'status' => int, 'data' => ?array, 'code' => ?string, 'message' => ?string]

Silent failure is the default. If the network drops, the quota is full or the recipient is suppressed, the call returns ok: false + code and your application keeps running. An email failure should not crash a checkout flow.

In development, or wherever you want the error to propagate, use throwOnError: true:

typescript
const sb = new SignalbirdMessaging({ domainKey, throwOnError: true })
try {
  await sb.sendEmail({ … })
} catch (e) {
  if (e instanceof SignalbirdError) console.error(e.code, e.status, e.data)
}
php
$sb = new MessagingClient($key, throwOnError: true);
try {
    $sb->sendEmail([...]);
} catch (\Signalbird\Sdk\SignalbirdException $e) {
    $e->getErrorCode();   // 'LIMIT_REACHED'
    $e->getStatus();      // 429
    $e->getBody();        // raw API response
}

There is no retry. Sending the same message twice costs more than not sending it at all; after NETWORK_ERROR / TIMEOUT the retry decision is yours.

Bulk contact import

typescript
const list = await sb.createContactList({ name: 'Newsletter - August' })

const res = await sb.bulkContacts({
  list_id: list.data.id,
  consent_source: 'web-form',
  consent_text: 'I agree to receive marketing emails.',
  contacts: rows.map(r => ({
    email: r.email,
    first_name: r.name,
    attributes: { plan: r.plan },
  })),
})

// { imported: 4120, updated: 310, skipped: [{ email: 'x', reason: 'invalid' }, …] }

bulkContacts splits the array into chunks of 1000 and sends them sequentially (not in parallel: if the same email is in two chunks there must be no race). Counts are merged into a single response. If a chunk fails the import stops there and the counts accumulated so far are returned in data - you can see how many contacts were processed.

Campaign flow

typescript
// 1. list + contacts
const list = await sb.createContactList({ name: 'New feature announcement' })
await sb.bulkContacts({ list_id: list.data.id, contacts })

// 2. create the campaign (immediate or scheduled)
const created = await sb.createCampaign({
  name: 'August announcement',
  channel: 'email',
  domain_id: 7,               // your TXT-verified domain (required)
  list_id: list.data.id,
  subject: 'New: calendar integration',
  body: '<p>Hi {{first_name}}, …</p>',
  scheduled_at: '2026-08-21T09:00:00+03:00',
  external_ref: 'crm-cmp-77',
})
// created.data.summary → { total, queued, skipped, stopped_reason }

// 3. watch status
const detail = await sb.getCampaign(created.data.batch.id)
// detail.data.status_breakdown → { queued: 120, sent: 3900, bounced: 12, … }

// 4. walk the messages
for await (const m of sb.iterateCampaignMessages(created.data.batch.id, { status: 'bounced' })) {
  console.log(m.recipient, m.last_code)
}

listCampaignMessages returns a single page; iterateCampaignMessages walks the pages itself and throws if a page cannot be fetched - a silent half list is mistaken for "that's all", which is more dangerous. cancelCampaign only works on batches that have not finished.

Webhook verification

Define an endpoint in the panel under Console → Webhooks. Every delivery carries these headers:

Header Content
X-Signalbird-Event message.queued · sent · delivered · bounced · failed · opened · clicked · unsubscribed · complained · campaign.finished · campaign.canceled
X-Signalbird-Delivery Delivery id (use it to de-duplicate)
X-Signalbird-Signature sha256=<hex hmac-sha256(raw_body, secret)>
X-Signalbird-Timestamp Unix timestamp

Body: { id, type, occurred_at, team_id, data: { message_id, batch_id, channel, contact_id, external_ref, batch_external_ref, status, code, detail, recipient } }. A non-2xx response is retried 3 times (1 min / 5 min / 30 min).

The signature is computed over the raw body. Parsing the JSON and re-serialising it changes key order and breaks the signature.

Express

typescript
import express from 'express'
import { verifyWebhook } from 'signalbird'

app.post('/webhooks/signalbird', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(req.body, req.header('X-Signalbird-Signature'), process.env.SIGNALBIRD_WEBHOOK_SECRET!)
  if (!ok) return res.status(401).end()

  const event = JSON.parse(req.body.toString('utf8'))
  if (event.type === 'message.bounced') { /* … */ }
  res.status(204).end()
})

Laravel

php
use Illuminate\Http\Request;
use Signalbird\Sdk\Messaging\Webhook;

Route::post('/webhooks/signalbird', function (Request $request) {
    $ok = Webhook::verify(
        $request->getContent(),                       // raw body
        $request->header('X-Signalbird-Signature'),
        config('services.signalbird.webhook_secret'),
    );
    abort_unless($ok, 401);

    $event = $request->json()->all();
    // $event['type'], $event['data']['message_id'] …
    return response()->noContent();
})->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);

Error codes

Code Meaning
API_KEY_MISSING · API_KEY_INVALID Key missing, wrong or revoked
API_KEY_SCOPE The key lacks the scope for this operation
API_KEY_IP_BLOCKED Request came from outside the key's IP allowlist
API_KEY_NO_TEAM The key's team is missing / inactive
MODULE_DISABLED Your plan does not include this channel (email/sms/push)
LIMIT_REACHED Monthly quota exhausted
OVERAGE_CEILING_REACHED Overage ceiling reached - can be raised in the panel
SUPPRESSED Recipient is on the suppression list (bounce / complaint / unsubscribe)
NO_CONSENT No consent on file for a commercial message
NO_SENDING_DOMAIN No verified sending domain
INVALID_PHONE Number is not in E.164 format
LIST_NOT_FOUND list_id does not belong to this team
NO_RECIPIENTS The list has no sendable contacts
ALREADY_FINISHED Campaign already finished, cannot be canceled
VALIDATION_ERROR Field validation (HTTP 422); details in data.errors
NETWORK_ERROR · TIMEOUT Request did not go out or timed out - retry is your call