pypi.org ↗
Installpip install signalbird

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

bash
pip install signalbird

Python 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
bash
# .env
SIGNALBIRD_DOMAIN_KEY=sb_secret_live_xxxxxxxxxxxxxxxxxxxxxxxx
SIGNALBIRD_SOURCE=api-01
SIGNALBIRD_DOMAIN_KEY=sb_xxxxxxxxxxxxxxxxxxxxxxxx

Radio - logs and events

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

python
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

python
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

python
# 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

python
@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}")
        raise

Messaging - email, SMS, push

python
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 class field is a legal gate: transactional or commercial. It has no default: the SDK cannot make that call for you.

python
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

python
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

python
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

python
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 with json.dumps breaks the signature: field order and whitespace change. This is the trap people fall into most often in Python.


Management - set up your own project

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

python
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 thing

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

python
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):

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

bash
pip install -U signalbird

Error 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: False and 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.
  • None fields are stripped from the body: "not sent" and "set to null" are not the same thing.