bloonio_wa_relay_client
Client SDK for bloonio_wa_relay — the Bloonio WhatsApp transport PaaS. Send
template/text/media messages, query numbers, templates, suppressions and the 24h
customer-service window, and receive HMAC#1-signed webhook events, all via the same
tenant_id + tenant_secret model as bloonio_auth_relay_client /
bloonio_chat_relay_client / bloonio_mail_relay_client. Framework-agnostic core +
thin FastAPI / Django adapters.
Install
pip install "bloonio-wa-relay-client[fastapi]" # for FastAPI tenants
pip install "bloonio-wa-relay-client[django]" # for Django tenants
pip install bloonio-wa-relay-client # framework-agnostic core only
Two-minute integration
# .env
BLOONIO_WA_BASE_URL=https://wa-relay.example.com
BLOONIO_WA_TENANT_ID=<uuid>
BLOONIO_WA_TENANT_SECRET=sk_...
# optional — authenticate with a bwa_ API key (Bearer) instead of HMAC#1 signing
BLOONIO_WA_API_KEY=bwa_...
# main.py — construct the client once at startup and register it as the singleton
from fastapi import FastAPI
from bloonio_wa_relay_client import WaRelayClient, WaRelaySettings, set_wa_client
app = FastAPI()
set_wa_client(WaRelayClient(WaRelaySettings())) # reads the BLOONIO_WA_* env vars above
# anywhere else — a route, a Celery task, a management command
from bloonio_wa_relay_client import get_wa_client
wa = get_wa_client()
wa.send(from_number_id="pn_abc123", to="243810000001", text="Hi!")
There is no from_env(app) adapter class here — set_wa_client() / get_wa_client()
is the whole pattern, and it's identical under Django (call set_wa_client(...) from
AppConfig.ready() or the top of a startup/settings module). Calling get_wa_client()
before set_wa_client() raises RuntimeError with that exact fix in the message.
Sending messages
from bloonio_wa_relay_client import WaRelayClient, WaRelayError, WaRelaySettings
client = WaRelayClient(WaRelaySettings(
base_url="https://wa-relay.example.com",
tenant_id="...", # from provisioning
tenant_secret="sk_...", # shown once at provisioning
))
# Free-form text is only accepted INSIDE the contact's 24h service window (they must have
# messaged this number within the last 24h) — otherwise 409 `outside_service_window`. Check
# fetch_window() first (below), or send a template (also below) to start the conversation cold.
res = client.send(from_number_id="pn_abc123", to="243810000001", text="Your table is ready!")
print(res["message_id"], res["status"]) # every method returns the relay's raw dict/list —
# NOT a validated core.types model, see "Public surface"
# Templates work even outside the window, as long as they're `approved` (see Templates below).
# `template_components` is a flat list of positional {{1}}, {{2}}, ... values — sent on the wire
# as `template_variables`. There is no `template_components`/`components` field on send() itself;
# `components` means something different (Meta component blocks) on create_template(), below.
res = client.send(
from_number_id="pn_abc123",
to="243810000001",
type="template",
template_name="flight_cancelled",
template_language="fr",
template_components=["Alice", "AF123"],
idempotency_key="order-9981-cancelled", # optional; a replayed key returns the same result
)
# image / document sends: Meta requires media_url, and the relay does not validate it — an
# image send with no media_url is accepted here and fails only when Meta itself rejects it.
client.send(from_number_id="pn_abc123", to="243810000001", type="image",
media_url="https://cdn.example.com/receipt.jpg")
Errors. Every method raises WaRelayError on any non-2xx response: .status_code,
.message (the relay's message, or the raw response text when there's no structured message to
show), .body (the parsed JSON envelope, or None when the response body wasn't JSON at all —
raw text is never put here, only ever in .message), and .code when the relay's envelope
carried one (number_not_owned, outside_service_window, recipient_suppressed, ... —
FastAPI's own 422 validation-error shape has no code at all).
try:
client.send(from_number_id="pn_abc123", to="243810000001", text="Hi")
except WaRelayError as e:
print(e.status_code, e.code, e.message)
Reuse one WaRelayClient per process — it wraps a single httpx.Client — and either call
client.close() when done or use it as a context manager: with WaRelayClient(...) as client:.
The async twin, AsyncWaRelayClient, has full method parity (await client.send(...),
async with AsyncWaRelayClient(...) as client: / await client.aclose()).
Messages, numbers & the service window
# your sent-message log — newest first; no cursor pagination, `limit` only (1-200, default 50)
for m in client.list_messages(limit=20):
print(m["message_id"], m["status"])
# `message_id` here is the RELAY's own id (a uuidv7 — from send()'s or list_messages()'s
# result), deliberately NOT the `wamid` WhatsApp assigns. Webhook payloads carry `wamid`
# (WebhookEvent.wamid, see Webhook callbacks below) — passing THAT here 404s ("Unknown message").
msg = client.fetch_message(message_id=res["message_id"])
for n in client.list_numbers():
print(n["phone_number_id"], n["display_number"], n["status"])
number = client.fetch_number(number_id="pn_abc123")
# would a free-form (non-template) send be accepted right now?
window = client.fetch_window(phone_number_id="pn_abc123", wa_id="243810000001")
if window["is_open"]:
client.send(from_number_id="pn_abc123", to="243810000001", text="Still there?")
else:
print(window["reason"]) # "never_messaged" | "window_expired"
Templates
# `phone_number_id` is required — every call 422s without it (CreateTemplateRequest has no
# default for it server-side, even though it's easy to forget when sketching this call).
tpl = client.create_template(
phone_number_id="pn_abc123",
name="flight_cancelled",
language="fr",
category="UTILITY",
components=[{"type": "BODY", "text": "Hello {{1}}, your flight {{2}} was cancelled."}],
)
print(tpl["status"]) # "submitted" — only Meta moves it on to approved/rejected/paused
for t in client.list_templates():
print(t["name"], t["language"], t["status"])
Suppressions
# the opt-out list for a number. STOP-keyword replies are suppressed automatically; this is
# for adding one by hand — the relay always records it with reason "manual" (no reason param).
client.suppress_contact(phone_number_id="pn_abc123", wa_id="243810000003")
for s in client.list_suppressions():
print(s["wa_id"], s["reason"]) # "stop_keyword" | "manual"
Webhook callbacks
The relay delivers five events — the wa.message.* family — to your backend as HMAC#1-signed
POSTs, retrying on any non-2xx response (quadratic backoff, up to 5 attempts). Mount them
under whatever prefix you registered with the relay as your tenant's callback_url_base; the
examples below use the contract's own /api/v1/wa-callbacks/ convention.
| Path | Event | Fired from |
|---|---|---|
POST /api/v1/wa-callbacks/wa-message-received |
wa.message.received |
an inbound WhatsApp message |
POST /api/v1/wa-callbacks/wa-message-sent |
wa.message.sent |
Meta status callback sent |
POST /api/v1/wa-callbacks/wa-message-delivered |
wa.message.delivered |
Meta status callback delivered |
POST /api/v1/wa-callbacks/wa-message-read |
wa.message.read |
Meta status callback read |
POST /api/v1/wa-callbacks/wa-message-failed |
wa.message.failed |
Meta status callback failed |
FastAPI
from fastapi import FastAPI
from bloonio_wa_relay_client import WaRelaySettings, WebhookEventType
from bloonio_wa_relay_client.adapters.fastapi import build_callback_router
async def on_message_received(event): # WebhookHandler — async only under FastAPI
print(event.wamid, event.from_, event.text)
app = FastAPI()
app.include_router(
build_callback_router(
settings=WaRelaySettings(),
handlers={WebhookEventType.MESSAGE_RECEIVED: on_message_received},
),
prefix="/api/v1/wa-callbacks",
)
Django
# urls.py
from django.urls import include, path
from bloonio_wa_relay_client import WebhookEventType
from bloonio_wa_relay_client.adapters.django import build_callback_urlpatterns
def on_message_received(event): # sync or async — both work
print(event.wamid, event.from_, event.text)
urlpatterns = [
path(
"api/v1/wa-callbacks/",
include(build_callback_urlpatterns(handlers={WebhookEventType.MESSAGE_RECEIVED: on_message_received})),
),
]
Django's build_callback_urlpatterns defaults settings to None, which reads BLOONIO_WA_*
env vars for you (as shown above). FastAPI's build_callback_router has no such default —
settings is required; passing None there survives construction and only fails with
AttributeError on the first real webhook POST, so always build a WaRelaySettings() explicitly
(as the FastAPI example above does). Past that difference, both adapters share the same
semantics: 401 on missing/bad HMAC#1 headers (handlers never run), 400 on an invalid payload
or a body whose event_type doesn't match the path, handler exceptions logged but the response
is still 200 (so the relay doesn't retry a delivery your handler already received) — you own
retries and idempotency from there. Events with no registered handler are still HMAC-verified,
accepted, and return 200.
Signing. The same three headers used for outbound tenant-auth calls —
X-Bloonio-Tenant-Id / X-Bloonio-Timestamp / X-Bloonio-Signature, identical HMAC#1
formula — there is no separate webhook-signing secret.
No idempotency, no replay window. Delivery is at-least-once, not exactly-once: the relay
retries on any non-2xx response, so the same event can legitimately arrive more than once —
always dedupe your handler on event.wamid (persist it yourself; this envelope has no
event_id). There's also no replay cache on either side today — verification only checks that
the signature matches the body and the declared timestamp, not that the timestamp is recent,
so a captured valid payload would still verify tomorrow. Until a replay cache ships, don't rely
on the signature alone to keep your callback URL private.
Public surface
Every client method returns the relay's response data verbatim — a plain dict or list,
never an instance of the schemas below. The schemas exist so you can validate/type a response
yourself (e.g. Message.model_validate(res)) or type a webhook payload — WebhookEvent is the
one schema the adapters already parse for you.
| Symbol | Kind | Notes |
|---|---|---|
WaRelayClient |
client (sync) | wraps httpx.Client; reuse one per process |
AsyncWaRelayClient |
client (async) | wraps httpx.AsyncClient; async with / await .aclose() |
WaRelaySettings |
settings | reads BLOONIO_WA_* env vars (pydantic-settings) |
WaRelayError |
error | raised on any non-2xx; .status_code / .message / .body / .code |
WebhookEventType |
enum | the five wa.message.* webhook events |
MessageStatus |
enum | queued → sent → delivered → read, or terminal failed |
TemplateStatus |
enum | draft → submitted → approved | rejected | paused |
SuppressionReason |
enum | stop_keyword | manual |
SendResult |
schema | send()'s response shape |
Message |
schema | one row of list_messages() / fetch_message() |
Number |
schema | one row of list_numbers() / fetch_number() |
Template |
schema | one row of list_templates() / create_template() |
Suppression |
schema | one row of list_suppressions() |
ContactWindow |
schema | fetch_window()'s response shape |
WebhookEvent |
schema | the parsed payload your webhook handlers receive |
set_wa_client / get_wa_client |
singleton | app-startup wiring — see "Two-minute integration" |
License
Proprietary — Bloonio internal.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bloonio_wa_relay_client-0.1.1.tar.gz.
File metadata
- Download URL: bloonio_wa_relay_client-0.1.1.tar.gz
- Upload date:
- Size: 37.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
82329cea1f506486f59ee0c556ad4c1f3b0549dff2223d83048e78a7ec566bf7
|
|
| MD5 |
09571da0844a2c8660c779cd1f6edfd8
|
|
| BLAKE2b-256 |
40d54a4c062e197f953a26752fd48d924da0255ec0a7d4f1ea118e976f6a24b9
|
File details
Details for the file bloonio_wa_relay_client-0.1.1-py3-none-any.whl.
File metadata
- Download URL: bloonio_wa_relay_client-0.1.1-py3-none-any.whl
- Upload date:
- Size: 25.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bff9f1b79db2ce5397bf343ef4cec5be58f692ac32bb687496c86c42648bdff4
|
|
| MD5 |
51f7d5cf3c0ae3835eaad639c02c8b94
|
|
| BLAKE2b-256 |
bfd04c5644fa24aa31451c6e1ae64ba7ef2480eabf193b135c4b5e947a8d1578
|