pip install signalbirdsignalbird - Python
For Django, FastAPI, Flask, Celery workers and plain scripts. It carries three surfaces: Radio (logs), Messaging (email/SMS/push) and Management (projects, chat inbox, apps).
Install
pip install signalbirdPython 3.8+ is required. It has no dependencies: the standard library's
urllib is used. An SDK forcing a requests version is the most common reason
dependency resolution deadlocks.
Keys
| Surface | Key | Where from |
|---|---|---|
| Radio | sb_secret_live_… |
Panel → Radio → Projects (shown once) |
| Messaging + Management | sb_… |
Panel → Console → API Keys |
# .env
SIGNALBIRD_DOMAIN_KEY=sb_secret_live_xxxxxxxxxxxxxxxxxxxxxxxx
SIGNALBIRD_SOURCE=api-01
SIGNALBIRD_DOMAIN_KEY=sb_xxxxxxxxxxxxxxxxxxxxxxxxRadio - logs and events
from signalbird import signalbird
# read from the SIGNALBIRD_DOMAIN_KEY environment variable, a singleton
signalbird().critical("critical", "the payment service is not responding", {
"service": "iyzico",
"attempt": 3,
})
signalbird().info("info", "ahmet@x.com created a new account")Your own client:
from signalbird import SignalbirdClient
radio = SignalbirdClient(
domain_key=settings.SIGNALBIRD_DOMAIN_KEY,
source="api-01",
timeout=5.0,
throw_on_error=False, # default: silent failure
)
radio.log("deploy", "v2.4.0 is live", level="info")There are five levels: debug, info, warn, error, critical. If you do
not pass a level, the channel's own default applies.
Batch sending
result = radio.batch([
{"channel": "deploy", "message": "v2.4.0 is live", "level": "info"},
{"channel": "critical", "message": "the queue filled up"},
])At most 100 records. Partial success is normal (the quota can run out halfway through); the result comes back row by row and failed rows are not retried.
With Django
# settings.py
SIGNALBIRD = SignalbirdClient(domain_key=env("SIGNALBIRD_DOMAIN_KEY"), source=env("APP_ENV"))
# middleware.py
class SignalbirdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exception):
# Silent failure: if Radio is unreachable the request already returns 500,
# the SDK should not produce a second error on top of it.
settings.SIGNALBIRD.error("api", str(exception), {
"path": request.path,
"method": request.method,
})With Celery
@app.task(bind=True)
def process_payment(self, order_id):
try:
charge(order_id)
signalbird().info("payment", f"Order #{order_id} succeeded")
except Exception as error:
signalbird().critical("critical", f"Order #{order_id}: {error}")
raiseMessaging - email, SMS, push
from signalbird import SignalbirdMessaging
sb = SignalbirdMessaging(domain_key=os.environ["SIGNALBIRD_DOMAIN_KEY"])
result = sb.send_email({
"to": "ahmet@example.com",
"class": "transactional", # REQUIRED, there is no default
"subject": "Your order has shipped",
"body": "<p>Tracking number: 1234</p>",
})
if not result.ok:
logger.warning("signalbird: %s - %s", result.code, result.message)The
classfield is a legal gate:transactionalorcommercial. It has no default: the SDK cannot make that call for you.
sb.send_sms({"to": "+905551112233", "class": "transactional", "body": "Your code: 123456"})
sb.preview_sms("Hello, your order is ready.") # segment maths, spends no quota
sb.send_push({"to": "contact:42", "class": "transactional", "subject": "Order", "body": "It has shipped"})Contacts and lists
sb.list_contacts({"q": "ahmet", "page": 1})
sb.create_contact({"email": "ahmet@example.com", "first_name": "Ahmet"})
sb.update_contact(contact_id, {"phone": "+905551112233"})
sb.delete_contact(contact_id)
# uploads SEQUENTIALLY, in chunks of 1000
sb.bulk_contacts({
"contacts": rows,
"list_id": 42,
"consent_source": "web_form",
"consent_text": "I agree to subscribe to the newsletter",
})Bulk upload is not parallel, so that the same email appearing in two chunks cannot race. If a chunk fails it stops there and returns the counts collected up to that point: throwing away a half-loaded list as "failed" means not knowing which contacts made it in.
Campaigns
sb.create_campaign({
"name": "August newsletter",
"channel": "email",
"list_id": 42,
"subject": "What happened this month?",
"body": "<h1>Hello</h1>",
"scheduled_at": "2026-09-01T09:00:00Z",
})
sb.get_campaign(batch_id)
sb.cancel_campaign(batch_id)
# walks page by page, never pulls the whole list into memory
for message in sb.iterate_campaign_messages(batch_id):
reconcile(message["external_ref"], message["status"])Every message leaving a campaign is necessarily commercial.
Webhook verification
from signalbird import verify_webhook
# Django - request.body is the RAW body
if not verify_webhook(request.body, request.headers.get("X-Signalbird-Signature"), SECRET):
return HttpResponseForbidden()
# FastAPI
raw = await request.body()
if not verify_webhook(raw, request.headers.get("x-signalbird-signature"), SECRET):
raise HTTPException(403)Verification runs on the raw body. Reading
request.json()and turning it back withjson.dumpsbreaks the signature: field order and whitespace change. This is the trap people fall into most often in Python.
Management - set up your own project
from signalbird import SignalbirdManagement
admin = SignalbirdManagement(domain_key=os.environ["SIGNALBIRD_DOMAIN_KEY"])
# in a deploy script: open a project and a channel for a new environment
created = admin.create_radio_project({"name": "payment-service"})
secret = created.data["secret"] # returned HERE ONLY
admin.create_radio_channel(created.data["project"]["id"], {
"key": "payment",
"name": "Payment",
"level": "critical",
"notify_push": True,
"quiet_from": 0,
"quiet_to": 7,
})
# a chat inbox bot
inbox = admin.list_conversations({"status": "open"})
for conversation in inbox.data["data"]:
admin.reply(conversation["id"], {"body": "Hello! We will be right with you."})
admin.set_conversation_status(conversation["id"], "pending")Your key must carry the radio:*, chat:* and apps:* scopes. Details:
the Management document.
Result shape
Every method returns the same envelope. Result is a dict that also offers
attribute access:
result = sb.send_email({...})
result.ok # bool
result.status # int
result.data # the parsed JSON body
result.code # error code (None on success)
result.message
result["data"] # the same thingWith throw_on_error=True a SignalbirdError is raised instead (carrying
code, status, body).
Visitor identity verification - identityHash
The chat widget and the app SDK introduce a signed-in user by external_id.
Because the public key sits in the page, Signalbird trusts that identity only
when it comes with a valid hash generated on your server (2.7.0). Without
it the visitor is not linked to the contact record, the device is not bound to
that user as a push target, and the channel agent's tools see the identity
only under visitor.unverified.
from signalbird import signalbird
hash = signalbird().identity_hash(str(user.id))It lives on the Radio client, which already holds the secret key; no extra setup. The algorithm (same output in every language):
identity_hash = hex( HMAC-SHA256( hex(SHA-256(sb_secret_live_…)), external_id ) )The key is the SHA-256 digest of the secret key (the server never stores the key in plain text). Usage: Widget - identity verification, App SDK.
Version notice
The SDK reports its version on every request: X-Signalbird-Sdk: python/<version>.
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 (signalbird logger (logging)).
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:
pip install -U signalbirdError codes
| Code | Meaning |
|---|---|
NO_KEY / WRONG_KEY_TYPE |
Caught at construction time |
INVALID_KEY |
The key is missing, wrong, or the project is inactive |
API_KEY_SCOPE |
The key lacks the required scope |
MODULE_DISABLED |
Your package does not include this module |
LIMIT_REACHED |
Your monthly limit is full |
VALIDATION_ERROR |
422 |
NETWORK_ERROR / TIMEOUT |
Network (status 0) |
Rules of behaviour
- Silent failure is the default. If Signalbird cannot be reached you get
ok: Falseand no exception: shipping a log is not your application's real job. - There is no retry. Sending the same message twice costs more than not sending it at all; the retry decision is yours.
- Timeouts are 5 s for Radio and 15 s for Messaging and Management.
Nonefields are stripped from the body: "not sent" and "set to null" are not the same thing.