Metadata-Version: 2.4
Name: bloonio-wa-relay-client
Version: 0.1.1
Summary: Client SDK for bloonio_wa_relay. Backend integration for the Bloonio WhatsApp transport PaaS — send template/text messages, query numbers, templates, suppressions and the 24h service window, receive HMAC-signed webhook events. Framework-agnostic core + thin FastAPI / Django adapters.
Author: Bloonio
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/Bloonio/bloonio_wa_relay_client
Keywords: bloonio,whatsapp,messaging,otp,templates,webhook
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Chat
Classifier: Operating System :: POSIX
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.6
Requires-Dist: pydantic-settings>=2.2
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == "fastapi"
Requires-Dist: starlette>=0.36; extra == "fastapi"
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Provides-Extra: all
Requires-Dist: fastapi>=0.110; extra == "all"
Requires-Dist: starlette>=0.36; extra == "all"
Requires-Dist: django>=4.2; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Requires-Dist: fastapi>=0.110; extra == "dev"
Requires-Dist: django>=4.2; extra == "dev"
Requires-Dist: uvicorn>=0.29; extra == "dev"
Requires-Dist: redis>=5.0; extra == "dev"
Dynamic: license-file

# 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

```bash
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

```bash
# .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_...
```

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

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

```python
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).

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

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

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

```python
# 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
`POST`s, 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

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

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