waba-sdk 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- waba_sdk-1.0.0/LICENSE +21 -0
- waba_sdk-1.0.0/PKG-INFO +625 -0
- waba_sdk-1.0.0/README.md +573 -0
- waba_sdk-1.0.0/pyproject.toml +51 -0
- waba_sdk-1.0.0/setup.cfg +4 -0
- waba_sdk-1.0.0/tests/test_client_lifecycle.py +57 -0
- waba_sdk-1.0.0/tests/test_errors.py +70 -0
- waba_sdk-1.0.0/tests/test_import.py +69 -0
- waba_sdk-1.0.0/tests/test_payload_shapes.py +261 -0
- waba_sdk-1.0.0/tests/test_phone_normalization.py +33 -0
- waba_sdk-1.0.0/tests/test_send_and_mark_read.py +70 -0
- waba_sdk-1.0.0/tests/test_validation.py +58 -0
- waba_sdk-1.0.0/tests/test_webhook.py +184 -0
- waba_sdk-1.0.0/waba_sdk/__init__.py +153 -0
- waba_sdk-1.0.0/waba_sdk/_http.py +171 -0
- waba_sdk-1.0.0/waba_sdk/client.py +508 -0
- waba_sdk-1.0.0/waba_sdk/config.py +48 -0
- waba_sdk-1.0.0/waba_sdk/errors.py +133 -0
- waba_sdk-1.0.0/waba_sdk/integrations/__init__.py +5 -0
- waba_sdk-1.0.0/waba_sdk/integrations/fastapi.py +89 -0
- waba_sdk-1.0.0/waba_sdk/media/__init__.py +5 -0
- waba_sdk-1.0.0/waba_sdk/media/client.py +125 -0
- waba_sdk-1.0.0/waba_sdk/messages/__init__.py +159 -0
- waba_sdk-1.0.0/waba_sdk/messages/_base.py +64 -0
- waba_sdk-1.0.0/waba_sdk/messages/contacts.py +111 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/__init__.py +44 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/_base.py +137 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/buttons.py +57 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/cta_url.py +26 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/flow.py +50 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/list.py +63 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/location_request.py +23 -0
- waba_sdk-1.0.0/waba_sdk/messages/interactive/products.py +100 -0
- waba_sdk-1.0.0/waba_sdk/messages/location.py +33 -0
- waba_sdk-1.0.0/waba_sdk/messages/media.py +84 -0
- waba_sdk-1.0.0/waba_sdk/messages/reaction.py +26 -0
- waba_sdk-1.0.0/waba_sdk/messages/template.py +180 -0
- waba_sdk-1.0.0/waba_sdk/messages/text.py +25 -0
- waba_sdk-1.0.0/waba_sdk/oauth.py +53 -0
- waba_sdk-1.0.0/waba_sdk/types.py +27 -0
- waba_sdk-1.0.0/waba_sdk/webhook/__init__.py +127 -0
- waba_sdk-1.0.0/waba_sdk/webhook/events.py +38 -0
- waba_sdk-1.0.0/waba_sdk/webhook/handler.py +182 -0
- waba_sdk-1.0.0/waba_sdk/webhook/incoming.py +372 -0
- waba_sdk-1.0.0/waba_sdk/webhook/payload.py +64 -0
- waba_sdk-1.0.0/waba_sdk/webhook/status.py +61 -0
- waba_sdk-1.0.0/waba_sdk.egg-info/PKG-INFO +625 -0
- waba_sdk-1.0.0/waba_sdk.egg-info/SOURCES.txt +49 -0
- waba_sdk-1.0.0/waba_sdk.egg-info/dependency_links.txt +1 -0
- waba_sdk-1.0.0/waba_sdk.egg-info/requires.txt +11 -0
- waba_sdk-1.0.0/waba_sdk.egg-info/top_level.txt +1 -0
waba_sdk-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Asim Mohamed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
waba_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: waba-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async Python SDK for the WhatsApp Business Cloud API
|
|
5
|
+
Author-email: Asim Mohamed <amohamed@aimsammi.org>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Asim Mohamed
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/asimzz/waba-sdk
|
|
29
|
+
Project-URL: Source, https://github.com/asimzz/waba-sdk
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Framework :: AsyncIO
|
|
38
|
+
Classifier: Topic :: Communications :: Chat
|
|
39
|
+
Requires-Python: >=3.9
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: aiohttp>=3.8.1
|
|
43
|
+
Requires-Dist: pydantic>=2.0
|
|
44
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
47
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
48
|
+
Requires-Dist: aioresponses>=0.7.6; extra == "test"
|
|
49
|
+
Provides-Extra: fastapi
|
|
50
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# waba-sdk
|
|
54
|
+
|
|
55
|
+
An async Python SDK for the [WhatsApp Business Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). Send messages, handle webhooks, upload and download media — all with typed pydantic models and an API designed for ergonomics.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from waba_sdk import WhatsApp
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
async with WhatsApp.from_env() as client:
|
|
63
|
+
await client.send_text("+15551234567", "Hello from waba-sdk!")
|
|
64
|
+
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Table of contents
|
|
69
|
+
|
|
70
|
+
- [Installation](#installation)
|
|
71
|
+
- [Configuration](#configuration)
|
|
72
|
+
- [Quickstart](#quickstart)
|
|
73
|
+
- [Sending messages](#sending-messages)
|
|
74
|
+
- [Text](#text)
|
|
75
|
+
- [Media](#media)
|
|
76
|
+
- [Location](#location)
|
|
77
|
+
- [Contacts](#contacts)
|
|
78
|
+
- [Reaction](#reaction)
|
|
79
|
+
- [Reply (in-thread)](#reply-in-thread)
|
|
80
|
+
- [Template](#template)
|
|
81
|
+
- [Interactive — buttons](#interactive--buttons)
|
|
82
|
+
- [Interactive — list](#interactive--list)
|
|
83
|
+
- [Interactive — CTA URL](#interactive--cta-url)
|
|
84
|
+
- [Interactive — flow](#interactive--flow)
|
|
85
|
+
- [Interactive — products & catalog](#interactive--products--catalog)
|
|
86
|
+
- [Interactive — location request](#interactive--location-request)
|
|
87
|
+
- [Mark as read](#mark-as-read)
|
|
88
|
+
- [Media (upload & download)](#media-upload--download)
|
|
89
|
+
- [Webhooks](#webhooks)
|
|
90
|
+
- [Error handling](#error-handling)
|
|
91
|
+
- [Development](#development)
|
|
92
|
+
- [License](#license)
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Installation
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv add git+https://github.com/asimzz/waba-sdk.git
|
|
100
|
+
# or with pip:
|
|
101
|
+
pip install git+https://github.com/asimzz/waba-sdk.git
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Requires Python **3.9+**.
|
|
105
|
+
|
|
106
|
+
For the optional FastAPI helper (`mount_webhook`):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install "waba-sdk[fastapi] @ git+https://github.com/asimzz/waba-sdk.git"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
The SDK reads credentials from environment variables (or a `.env` in the working directory) when you call `WhatsApp.from_env()`. Direct construction (`WhatsApp(token=..., phone_number_id=...)`) doesn't read env vars at all.
|
|
115
|
+
|
|
116
|
+
| Variable | Required | Description |
|
|
117
|
+
| ----------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
|
118
|
+
| `WABA_ACCESS_TOKEN` | yes (for `from_env()`) | Permanent or system-user access token. |
|
|
119
|
+
| `WABA_NUMBER_ID` | yes (for `from_env()`) | WhatsApp phone number ID from the Meta dashboard. |
|
|
120
|
+
| `WABA_API_VERSION` | no | Graph API version. Defaults to `v21.0`. |
|
|
121
|
+
| `WABA_BASE_URL` | no | Base URL. Defaults to `https://graph.facebook.com`. |
|
|
122
|
+
| `WABA_TIMEOUT` | no | HTTP timeout in seconds. Defaults to `30.0`. |
|
|
123
|
+
| `WABA_MAX_RETRIES` | no | Max retries on 429/5xx. Defaults to `2`. |
|
|
124
|
+
| `WABA_ID` | no | WhatsApp Business Account (WABA) ID. |
|
|
125
|
+
| `WABA_BUSINESS_ID` | no | Facebook Business Manager ID. |
|
|
126
|
+
| `FACEBOOK_VERIFY_TOKEN` | webhook only | Token echoed during the Meta webhook verification handshake. |
|
|
127
|
+
|
|
128
|
+
The composed Graph base URL is `${WABA_BASE_URL}/${WABA_API_VERSION}` — bumping the API version no longer requires a code change to the SDK.
|
|
129
|
+
|
|
130
|
+
## Quickstart
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
import asyncio
|
|
134
|
+
from waba_sdk import WhatsApp
|
|
135
|
+
|
|
136
|
+
async def main():
|
|
137
|
+
async with WhatsApp.from_env() as client:
|
|
138
|
+
await client.send_text("+15551234567", "Hello from waba-sdk!")
|
|
139
|
+
await client.send_image(
|
|
140
|
+
"+15551234567",
|
|
141
|
+
url="https://example.com/cat.jpg",
|
|
142
|
+
caption="A very good cat",
|
|
143
|
+
)
|
|
144
|
+
await client.send_buttons(
|
|
145
|
+
"+15551234567",
|
|
146
|
+
body="Did this help?",
|
|
147
|
+
buttons=[("yes", "Yes"), ("no", "No")],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
asyncio.run(main())
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Or construct directly without env vars:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
client = WhatsApp(token="EAAG...", phone_number_id="1234567890")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Phone numbers accept any reasonable format (`+15551234567`, `+1 555 123 4567`, `+1-(555)-123-4567`) and are normalized to digits-only internally.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Sending messages
|
|
164
|
+
|
|
165
|
+
Two equivalent styles for every message type:
|
|
166
|
+
|
|
167
|
+
- **Convenience methods** on the client — best for the common case.
|
|
168
|
+
- **Typed messages** passed to `client.send(message)` — best when you need every field, when you build messages elsewhere, or when you reuse them.
|
|
169
|
+
|
|
170
|
+
Each `client.send_*` helper accepts an optional `reply_to=<message_id>` keyword to send the message in-thread.
|
|
171
|
+
|
|
172
|
+
### Text
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from waba_sdk import TextMessage
|
|
176
|
+
|
|
177
|
+
# Convenience
|
|
178
|
+
await client.send_text("+15551234567", "https://example.com check this out", preview_url=True)
|
|
179
|
+
|
|
180
|
+
# Typed equivalent
|
|
181
|
+
await client.send(TextMessage(
|
|
182
|
+
to="+15551234567",
|
|
183
|
+
body="https://example.com check this out",
|
|
184
|
+
preview_url=True,
|
|
185
|
+
))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Media
|
|
189
|
+
|
|
190
|
+
One method per media type. For each call, supply **exactly one** of `url=` or `media_id=` — the SDK enforces this at validation time.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
# By public URL
|
|
194
|
+
await client.send_image("+15551234567", url="https://example.com/cat.jpg", caption="hi")
|
|
195
|
+
|
|
196
|
+
# By previously uploaded media_id
|
|
197
|
+
await client.send_image("+15551234567", media_id="123456789012345")
|
|
198
|
+
|
|
199
|
+
await client.send_video("+15551234567", url="https://example.com/clip.mp4", caption="watch")
|
|
200
|
+
await client.send_audio("+15551234567", url="https://example.com/voice.mp3")
|
|
201
|
+
await client.send_document(
|
|
202
|
+
"+15551234567",
|
|
203
|
+
url="https://example.com/invoice.pdf",
|
|
204
|
+
filename="invoice.pdf",
|
|
205
|
+
caption="your invoice",
|
|
206
|
+
)
|
|
207
|
+
await client.send_sticker("+15551234567", media_id="987654321")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`AudioMessage` and `StickerMessage` reject `caption` (Graph API does too).
|
|
211
|
+
|
|
212
|
+
Typed:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from waba_sdk import ImageMessage
|
|
216
|
+
|
|
217
|
+
await client.send(ImageMessage(
|
|
218
|
+
to="+15551234567",
|
|
219
|
+
link="https://example.com/cat.jpg",
|
|
220
|
+
caption="hi",
|
|
221
|
+
))
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Location
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
await client.send_location(
|
|
228
|
+
"+15551234567",
|
|
229
|
+
latitude=37.7749,
|
|
230
|
+
longitude=-122.4194,
|
|
231
|
+
name="San Francisco",
|
|
232
|
+
address="San Francisco, CA",
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Contacts
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
from waba_sdk import Contact, ContactName, ContactPhone
|
|
240
|
+
|
|
241
|
+
await client.send_contacts(
|
|
242
|
+
"+15551234567",
|
|
243
|
+
contacts=[
|
|
244
|
+
Contact(
|
|
245
|
+
name=ContactName(formatted_name="Ada Lovelace"),
|
|
246
|
+
phones=[ContactPhone(phone="+15551112222", type="WORK", wa_id="15551112222")],
|
|
247
|
+
)
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`send_contacts` also accepts plain `dict` objects — they're validated into `Contact` models for you.
|
|
253
|
+
|
|
254
|
+
### Reaction
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
# React
|
|
258
|
+
await client.react("+15551234567", "wamid.HBg...", "🎉")
|
|
259
|
+
|
|
260
|
+
# Remove the reaction
|
|
261
|
+
await client.react("+15551234567", "wamid.HBg...", "")
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Reply (in-thread)
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
# Sugar over send_text(..., reply_to=...)
|
|
268
|
+
await client.reply("+15551234567", "wamid.HBg...", "thanks!")
|
|
269
|
+
|
|
270
|
+
# Or any send_* method:
|
|
271
|
+
await client.send_image(
|
|
272
|
+
"+15551234567",
|
|
273
|
+
url="https://example.com/yes.png",
|
|
274
|
+
reply_to="wamid.HBg...",
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Template
|
|
279
|
+
|
|
280
|
+
A single `TemplateMessage` covers every shape. Parameters are a discriminated union — use the right subclass per parameter type instead of leaving five fields as `None`.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from waba_sdk import (
|
|
284
|
+
TemplateMessage, BodyComponent, HeaderComponent, ButtonComponent,
|
|
285
|
+
TextParameter, CurrencyParameter, CurrencyValue, ImageParameter,
|
|
286
|
+
ButtonParameter,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
await client.send(TemplateMessage(
|
|
290
|
+
to="+15551234567",
|
|
291
|
+
name="order_confirmation",
|
|
292
|
+
language="en_US",
|
|
293
|
+
components=[
|
|
294
|
+
HeaderComponent(parameters=[
|
|
295
|
+
ImageParameter(link="https://example.com/order-header.jpg"),
|
|
296
|
+
]),
|
|
297
|
+
BodyComponent(parameters=[
|
|
298
|
+
TextParameter(text="Ada"),
|
|
299
|
+
TextParameter(text="#A1024"),
|
|
300
|
+
CurrencyParameter(currency=CurrencyValue(
|
|
301
|
+
fallback_value="$29.00", code="USD", amount_1000=29000,
|
|
302
|
+
)),
|
|
303
|
+
]),
|
|
304
|
+
ButtonComponent(
|
|
305
|
+
sub_type="quick_reply",
|
|
306
|
+
index=0,
|
|
307
|
+
parameters=[ButtonParameter(type="payload", payload="track_A1024")],
|
|
308
|
+
),
|
|
309
|
+
],
|
|
310
|
+
))
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
For simple templates, the convenience method is shorter:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
await client.send_template("+15551234567", "hello_world")
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Interactive — buttons
|
|
320
|
+
|
|
321
|
+
`buttons` accepts a list of `Button(...)` models, `("id", "title")` tuples, or `{"id": ..., "title": ...}` dicts.
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
await client.send_buttons(
|
|
325
|
+
"+15551234567",
|
|
326
|
+
body="Did this help?",
|
|
327
|
+
buttons=[("yes", "Yes"), ("no", "No")],
|
|
328
|
+
header="Quick check", # str → text header shortcut
|
|
329
|
+
footer="You can change this later",
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Typed:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from waba_sdk import ButtonsMessage, TextHeader
|
|
337
|
+
|
|
338
|
+
await client.send(ButtonsMessage(
|
|
339
|
+
to="+15551234567",
|
|
340
|
+
body="Did this help?",
|
|
341
|
+
buttons=[("yes", "Yes"), ("no", "No")],
|
|
342
|
+
header=TextHeader(text="Quick check"),
|
|
343
|
+
footer="You can change this later",
|
|
344
|
+
))
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Interactive — list
|
|
348
|
+
|
|
349
|
+
`sections` accepts dicts or `ListSection` models; rows accept dicts or `ListRow` models.
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
await client.send_list(
|
|
353
|
+
"+15551234567",
|
|
354
|
+
body="Pick a plan",
|
|
355
|
+
button_text="View plans",
|
|
356
|
+
sections=[
|
|
357
|
+
{
|
|
358
|
+
"title": "Monthly",
|
|
359
|
+
"rows": [
|
|
360
|
+
{"id": "basic", "title": "Basic", "description": "$9/mo"},
|
|
361
|
+
{"id": "pro", "title": "Pro", "description": "$29/mo"},
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Interactive — CTA URL
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
await client.send_cta_url(
|
|
372
|
+
"+15551234567",
|
|
373
|
+
body="Your order is ready",
|
|
374
|
+
button_text="Track shipment",
|
|
375
|
+
url="https://example.com/track/123",
|
|
376
|
+
)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Interactive — flow
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
await client.send_flow(
|
|
383
|
+
"+15551234567",
|
|
384
|
+
body="Complete your profile",
|
|
385
|
+
flow_id="FLOW_ID",
|
|
386
|
+
flow_cta="Start",
|
|
387
|
+
flow_action="navigate",
|
|
388
|
+
mode="published",
|
|
389
|
+
screen="WELCOME",
|
|
390
|
+
data={"user_id": "42"},
|
|
391
|
+
)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`flow_token` is optional; pass it when Meta requires correlation between flow runs.
|
|
395
|
+
|
|
396
|
+
### Interactive — products & catalog
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
# Single product card
|
|
400
|
+
await client.send_single_product(
|
|
401
|
+
"+15551234567",
|
|
402
|
+
catalog_id="CATALOG_ID",
|
|
403
|
+
product_retailer_id="SKU-123",
|
|
404
|
+
body="Check this out",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Multi-product list
|
|
408
|
+
await client.send_multi_product(
|
|
409
|
+
"+15551234567",
|
|
410
|
+
body="Featured items",
|
|
411
|
+
catalog_id="CATALOG_ID",
|
|
412
|
+
sections=[
|
|
413
|
+
{"title": "Best sellers", "product_retailer_ids": ["SKU-1", "SKU-2", "SKU-3"]},
|
|
414
|
+
],
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Full catalog
|
|
418
|
+
await client.send_catalog(
|
|
419
|
+
"+15551234567",
|
|
420
|
+
body="Browse our catalog",
|
|
421
|
+
thumbnail_product_retailer_id="SKU-1",
|
|
422
|
+
)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### Interactive — location request
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
await client.send_location_request(
|
|
429
|
+
"+15551234567",
|
|
430
|
+
body="Where would you like delivery?",
|
|
431
|
+
)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Mark as read
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
# Just mark read
|
|
440
|
+
await client.mark_read("wamid.HBg...")
|
|
441
|
+
|
|
442
|
+
# Mark read + show typing indicator
|
|
443
|
+
await client.mark_read("wamid.HBg...", typing=True)
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Media (upload & download)
|
|
447
|
+
|
|
448
|
+
`client.media` exposes three helpers:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
# Upload from a file path or raw bytes
|
|
452
|
+
media_id = await client.media.upload("photo.jpg") # mime guessed from filename
|
|
453
|
+
media_id = await client.media.upload(open("voice.ogg", "rb").read(), mime_type="audio/ogg")
|
|
454
|
+
|
|
455
|
+
# Resolve a media_id (e.g. from an inbound webhook) to a CDN URL + metadata
|
|
456
|
+
info = await client.media.get_url(media_id)
|
|
457
|
+
# info.url, info.mime_type, info.sha256, info.file_size
|
|
458
|
+
|
|
459
|
+
# Download — accepts a media_id or a direct CDN URL
|
|
460
|
+
download = await client.media.download(media_id)
|
|
461
|
+
# or:
|
|
462
|
+
download = await client.media.download(info.url)
|
|
463
|
+
with open("photo.jpg", "wb") as f:
|
|
464
|
+
f.write(download.content)
|
|
465
|
+
print(download.content_type) # "image/jpeg"
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`MediaInfo` is a typed pydantic model; `MediaDownload` is a frozen dataclass with `.content: bytes` and `.content_type: str`.
|
|
469
|
+
|
|
470
|
+
## Webhooks
|
|
471
|
+
|
|
472
|
+
`WebhookHandler` is framework-agnostic. It exposes three methods:
|
|
473
|
+
|
|
474
|
+
- `verify(query)` — validates Meta's GET handshake. Accepts a `dict`, query-string, or iterable of `(k, v)` pairs.
|
|
475
|
+
- `parse(payload)` — pure: validates the envelope and returns a list of typed events (`MessageEvent` for inbound messages, `StatusEvent` for sent/delivered/read/failed updates).
|
|
476
|
+
- `handle(payload, *, auto_mark_read=False)` — calls `on_message`/`on_status`/`on_error` callbacks. Optionally marks each inbound message as read.
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
from fastapi import FastAPI, Request
|
|
480
|
+
from waba_sdk import WhatsApp
|
|
481
|
+
from waba_sdk.webhook import (
|
|
482
|
+
WebhookHandler, MessageEvent, StatusEvent, IncomingTextMessage,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
app = FastAPI()
|
|
486
|
+
client = WhatsApp.from_env()
|
|
487
|
+
|
|
488
|
+
async def on_message(event: MessageEvent) -> None:
|
|
489
|
+
msg = event.message
|
|
490
|
+
if isinstance(msg, IncomingTextMessage):
|
|
491
|
+
await client.send_text(event.contact_wa_id, f"You said: {msg.text.body}")
|
|
492
|
+
|
|
493
|
+
async def on_status(event: StatusEvent) -> None:
|
|
494
|
+
print(event.status.id, event.status.status) # delivered/read/failed
|
|
495
|
+
|
|
496
|
+
handler = WebhookHandler(
|
|
497
|
+
verify_token="<FACEBOOK_VERIFY_TOKEN value>",
|
|
498
|
+
client=client,
|
|
499
|
+
on_message=on_message,
|
|
500
|
+
on_status=on_status,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
@app.get("/webhook")
|
|
504
|
+
async def verify(request: Request):
|
|
505
|
+
challenge = handler.verify(dict(request.query_params))
|
|
506
|
+
return challenge if challenge else ("forbidden", 403)
|
|
507
|
+
|
|
508
|
+
@app.post("/webhook")
|
|
509
|
+
async def receive(request: Request):
|
|
510
|
+
body = await request.json()
|
|
511
|
+
await handler.handle(body, auto_mark_read=True)
|
|
512
|
+
return {"ok": True}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`StatusEvent` lets you track delivery / read receipts. The handler emits one event per status update, independently of inbound messages — you'll receive these even when a webhook payload contains no new messages.
|
|
516
|
+
|
|
517
|
+
### FastAPI shortcut
|
|
518
|
+
|
|
519
|
+
If you're on FastAPI, the same wiring is one call:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from waba_sdk import WhatsApp
|
|
523
|
+
from waba_sdk.webhook import MessageEvent, IncomingTextMessage
|
|
524
|
+
from waba_sdk.integrations.fastapi import mount_webhook
|
|
525
|
+
from fastapi import FastAPI
|
|
526
|
+
|
|
527
|
+
app = FastAPI()
|
|
528
|
+
client = WhatsApp.from_env()
|
|
529
|
+
|
|
530
|
+
async def on_message(event: MessageEvent) -> None:
|
|
531
|
+
if isinstance(event.message, IncomingTextMessage):
|
|
532
|
+
await client.send_text(event.contact_wa_id, "got it")
|
|
533
|
+
|
|
534
|
+
mount_webhook(
|
|
535
|
+
app,
|
|
536
|
+
"/webhook",
|
|
537
|
+
client=client,
|
|
538
|
+
verify_token="<FACEBOOK_VERIFY_TOKEN value>",
|
|
539
|
+
on_message=on_message,
|
|
540
|
+
auto_mark_read=True,
|
|
541
|
+
)
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
`mount_webhook` registers a shutdown hook so the underlying `aiohttp` session is closed cleanly when FastAPI tears down. Install with `pip install waba-sdk[fastapi]`.
|
|
545
|
+
|
|
546
|
+
### Inbound message types
|
|
547
|
+
|
|
548
|
+
`event.message` is a discriminated union; `isinstance` checks narrow the type:
|
|
549
|
+
|
|
550
|
+
```python
|
|
551
|
+
from waba_sdk.webhook import (
|
|
552
|
+
IncomingTextMessage, IncomingImageMessage, IncomingAudioMessage,
|
|
553
|
+
IncomingVideoMessage, IncomingDocumentMessage, IncomingStickerMessage,
|
|
554
|
+
IncomingLocationMessage, IncomingContactMessage, IncomingReactionMessage,
|
|
555
|
+
IncomingInteractiveMessage, IncomingButtonMessage, IncomingOrderMessage,
|
|
556
|
+
IncomingSystemMessage, IncomingUnknownMessage, IncomingUnsupportedMessage,
|
|
557
|
+
)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
For interactive replies (button click, list pick, flow response):
|
|
561
|
+
|
|
562
|
+
```python
|
|
563
|
+
from waba_sdk.webhook import ButtonReply, ListReply, NFMReply
|
|
564
|
+
|
|
565
|
+
if isinstance(event.message, IncomingInteractiveMessage):
|
|
566
|
+
reply = event.message.interactive
|
|
567
|
+
if isinstance(reply, ButtonReply):
|
|
568
|
+
button_id = reply.button_reply.id
|
|
569
|
+
elif isinstance(reply, ListReply):
|
|
570
|
+
row_id = reply.list_reply.id
|
|
571
|
+
elif isinstance(reply, NFMReply):
|
|
572
|
+
flow_payload = reply.nfm_reply.response_json # already JSON-decoded
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
## Error handling
|
|
576
|
+
|
|
577
|
+
Every non-2xx response from Graph maps to a typed exception:
|
|
578
|
+
|
|
579
|
+
| Status | Exception | Notes |
|
|
580
|
+
| ------ | ---------------------- | -------------------------------------------------- |
|
|
581
|
+
| 401 | `AuthenticationError` | Bad / expired token. |
|
|
582
|
+
| 429 | `RateLimitError` | `.retry_after` in seconds (from `Retry-After`). |
|
|
583
|
+
| 4xx | `InvalidRequestError` | Anything else 4xx (validation, missing field). |
|
|
584
|
+
| 5xx | `ServerError` | Graph API instability. |
|
|
585
|
+
| — | `MediaError` | Raised by `client.media.*` on upload/download. |
|
|
586
|
+
| — | `WebhookVerificationError` | Reserved for handshake failures. |
|
|
587
|
+
| — | `WhatsAppError` | Base class. All other errors inherit from this. |
|
|
588
|
+
|
|
589
|
+
Every exception carries `.status_code`, `.error_code` (Meta's `error.code`), `.error_subcode`, `.fbtrace_id`, and the raw `.response` dict — so you can log a complete picture without re-parsing.
|
|
590
|
+
|
|
591
|
+
```python
|
|
592
|
+
from waba_sdk import RateLimitError, AuthenticationError, WhatsAppError
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
await client.send_text("+15551234567", "hi")
|
|
596
|
+
except RateLimitError as e:
|
|
597
|
+
print(f"rate limited; retry in {e.retry_after}s; trace: {e.fbtrace_id}")
|
|
598
|
+
except AuthenticationError:
|
|
599
|
+
print("token is invalid or expired")
|
|
600
|
+
except WhatsAppError as e:
|
|
601
|
+
print(f"send failed [{e.status_code}/{e.error_code}]: {e.message}")
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
The HTTP layer automatically retries on 429 and 5xx with exponential backoff and jitter, honoring any `Retry-After` header. Configure via `WhatsApp(max_retries=...)` (default `2`) or `WABA_MAX_RETRIES`.
|
|
605
|
+
|
|
606
|
+
## Development
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
git clone https://github.com/asimzz/waba-sdk.git
|
|
610
|
+
cd waba-sdk
|
|
611
|
+
uv sync --extra test # install + test deps
|
|
612
|
+
uv run pytest -q # run the test suite
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
The test suite (66 tests) covers wire-format equivalence per message type, validation rules (media `media_id` xor `link`, audio/sticker reject captions, location bounds), webhook parsing, error mapping, and session lifecycle.
|
|
616
|
+
|
|
617
|
+
To add a new dependency:
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
uv add <package>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## License
|
|
624
|
+
|
|
625
|
+
MIT — see [pyproject.toml](pyproject.toml).
|