npm install signalbird · composer require pariette/signalbird · pip install signalbirdMessaging 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.
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
npm install signalbird # Node 18+, zero dependencies
composer require pariette/signalbird # PHP 8.1+, ext-curl, no GuzzleQuick start
Node.js / 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
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
# .env
SIGNALBIRD_DOMAIN_KEY=sb_xxxxxxxxxxxxxxxxxxxxxxxx
# SIGNALBIRD_MESSAGING_URL=https://signalbird.io/api # optionaluse 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} |
classis required and has no default. The SDK does not decide whether a message istransactionalorcommercial- 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:
// Node
type SbResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; code: string; message: string; data?: unknown }// 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:
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)
}$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
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
// 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
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
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 |