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.
Files changed (51) hide show
  1. waba_sdk-1.0.0/LICENSE +21 -0
  2. waba_sdk-1.0.0/PKG-INFO +625 -0
  3. waba_sdk-1.0.0/README.md +573 -0
  4. waba_sdk-1.0.0/pyproject.toml +51 -0
  5. waba_sdk-1.0.0/setup.cfg +4 -0
  6. waba_sdk-1.0.0/tests/test_client_lifecycle.py +57 -0
  7. waba_sdk-1.0.0/tests/test_errors.py +70 -0
  8. waba_sdk-1.0.0/tests/test_import.py +69 -0
  9. waba_sdk-1.0.0/tests/test_payload_shapes.py +261 -0
  10. waba_sdk-1.0.0/tests/test_phone_normalization.py +33 -0
  11. waba_sdk-1.0.0/tests/test_send_and_mark_read.py +70 -0
  12. waba_sdk-1.0.0/tests/test_validation.py +58 -0
  13. waba_sdk-1.0.0/tests/test_webhook.py +184 -0
  14. waba_sdk-1.0.0/waba_sdk/__init__.py +153 -0
  15. waba_sdk-1.0.0/waba_sdk/_http.py +171 -0
  16. waba_sdk-1.0.0/waba_sdk/client.py +508 -0
  17. waba_sdk-1.0.0/waba_sdk/config.py +48 -0
  18. waba_sdk-1.0.0/waba_sdk/errors.py +133 -0
  19. waba_sdk-1.0.0/waba_sdk/integrations/__init__.py +5 -0
  20. waba_sdk-1.0.0/waba_sdk/integrations/fastapi.py +89 -0
  21. waba_sdk-1.0.0/waba_sdk/media/__init__.py +5 -0
  22. waba_sdk-1.0.0/waba_sdk/media/client.py +125 -0
  23. waba_sdk-1.0.0/waba_sdk/messages/__init__.py +159 -0
  24. waba_sdk-1.0.0/waba_sdk/messages/_base.py +64 -0
  25. waba_sdk-1.0.0/waba_sdk/messages/contacts.py +111 -0
  26. waba_sdk-1.0.0/waba_sdk/messages/interactive/__init__.py +44 -0
  27. waba_sdk-1.0.0/waba_sdk/messages/interactive/_base.py +137 -0
  28. waba_sdk-1.0.0/waba_sdk/messages/interactive/buttons.py +57 -0
  29. waba_sdk-1.0.0/waba_sdk/messages/interactive/cta_url.py +26 -0
  30. waba_sdk-1.0.0/waba_sdk/messages/interactive/flow.py +50 -0
  31. waba_sdk-1.0.0/waba_sdk/messages/interactive/list.py +63 -0
  32. waba_sdk-1.0.0/waba_sdk/messages/interactive/location_request.py +23 -0
  33. waba_sdk-1.0.0/waba_sdk/messages/interactive/products.py +100 -0
  34. waba_sdk-1.0.0/waba_sdk/messages/location.py +33 -0
  35. waba_sdk-1.0.0/waba_sdk/messages/media.py +84 -0
  36. waba_sdk-1.0.0/waba_sdk/messages/reaction.py +26 -0
  37. waba_sdk-1.0.0/waba_sdk/messages/template.py +180 -0
  38. waba_sdk-1.0.0/waba_sdk/messages/text.py +25 -0
  39. waba_sdk-1.0.0/waba_sdk/oauth.py +53 -0
  40. waba_sdk-1.0.0/waba_sdk/types.py +27 -0
  41. waba_sdk-1.0.0/waba_sdk/webhook/__init__.py +127 -0
  42. waba_sdk-1.0.0/waba_sdk/webhook/events.py +38 -0
  43. waba_sdk-1.0.0/waba_sdk/webhook/handler.py +182 -0
  44. waba_sdk-1.0.0/waba_sdk/webhook/incoming.py +372 -0
  45. waba_sdk-1.0.0/waba_sdk/webhook/payload.py +64 -0
  46. waba_sdk-1.0.0/waba_sdk/webhook/status.py +61 -0
  47. waba_sdk-1.0.0/waba_sdk.egg-info/PKG-INFO +625 -0
  48. waba_sdk-1.0.0/waba_sdk.egg-info/SOURCES.txt +49 -0
  49. waba_sdk-1.0.0/waba_sdk.egg-info/dependency_links.txt +1 -0
  50. waba_sdk-1.0.0/waba_sdk.egg-info/requires.txt +11 -0
  51. 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.
@@ -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).