noria-sendkit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,794 @@
1
+ Metadata-Version: 2.4
2
+ Name: noria-sendkit
3
+ Version: 0.1.0
4
+ Summary: Sync and async Python SDK for WhatsApp and bulk SMS gateways (Meta, Onfon, Africa's Talking).
5
+ Author: Noria Labs
6
+ License-Expression: MIT
7
+ Keywords: sms,whatsapp,bulk-sms,africastalking,onfon,meta,messaging,httpx,async,sdk
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Classifier: Topic :: Communications
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: httpx>=0.28.1
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
24
+ Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.15.9; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # `sendkit`
29
+
30
+ Modular Python SDK for WhatsApp and bulk SMS providers.
31
+
32
+ Python `>=3.11` is required. SendKit ships **sync and async** clients backed by
33
+ `httpx`, and is designed for services, workers, and serverless messaging flows.
34
+
35
+ Use `sendkit` when you want direct provider wrappers:
36
+
37
+ - Meta WhatsApp Cloud API
38
+ - Onfon bulk SMS
39
+ - Africa's Talking SMS
40
+
41
+ Each provider exposes a synchronous client and an asynchronous `Async*`
42
+ counterpart that share the same request models, payload building, and response
43
+ parsing — one definition, two execution models.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install noria-sendkit
49
+ ```
50
+
51
+ The distribution is published as `noria-sendkit`; the import package is `sendkit`.
52
+
53
+ ## Imports
54
+
55
+ Import the whole package:
56
+
57
+ ```python
58
+ from sendkit import (
59
+ AfricasTalkingSmsClient,
60
+ MetaWhatsAppClient,
61
+ OnfonSmsClient,
62
+ )
63
+ ```
64
+
65
+ Or import the async clients:
66
+
67
+ ```python
68
+ from sendkit import (
69
+ AsyncAfricasTalkingSmsClient,
70
+ AsyncMetaWhatsAppClient,
71
+ AsyncOnfonSmsClient,
72
+ )
73
+ ```
74
+
75
+ Modular subpackages are also available:
76
+
77
+ - `sendkit` — everything
78
+ - `sendkit.sms` — SMS clients and models
79
+ - `sendkit.whatsapp` — WhatsApp client and models
80
+
81
+ ## Quick Start
82
+
83
+ ### WhatsApp
84
+
85
+ ```python
86
+ from sendkit import MetaWhatsAppClient, WhatsAppTextRequest
87
+
88
+ whatsapp = MetaWhatsAppClient(
89
+ access_token="...",
90
+ phone_number_id="...",
91
+ )
92
+
93
+ with whatsapp:
94
+ whatsapp.send_text(
95
+ WhatsAppTextRequest(recipient="254700123456", text="Hello from Noria")
96
+ )
97
+ ```
98
+
99
+ The async client mirrors the sync API:
100
+
101
+ ```python
102
+ import asyncio
103
+
104
+ from sendkit import AsyncMetaWhatsAppClient, WhatsAppTextRequest
105
+
106
+
107
+ async def main() -> None:
108
+ async with AsyncMetaWhatsAppClient(
109
+ access_token="...",
110
+ phone_number_id="...",
111
+ ) as whatsapp:
112
+ await whatsapp.send_text(
113
+ WhatsAppTextRequest(recipient="254700123456", text="Hello from Noria")
114
+ )
115
+
116
+
117
+ asyncio.run(main())
118
+ ```
119
+
120
+ ### Onfon SMS
121
+
122
+ ```python
123
+ from sendkit import OnfonSmsClient, SmsMessage, SmsSendRequest
124
+
125
+ sms = OnfonSmsClient(
126
+ access_key="...",
127
+ api_key="...",
128
+ client_id="...",
129
+ default_sender_id="NORIALABS",
130
+ )
131
+
132
+ sms.send(
133
+ SmsSendRequest(
134
+ messages=[
135
+ SmsMessage(recipient="254700123456", text="Your OTP is 123456", reference="otp-1"),
136
+ ]
137
+ )
138
+ )
139
+ ```
140
+
141
+ ### Africa's Talking SMS
142
+
143
+ ```python
144
+ from sendkit import AfricasTalkingSmsClient, SmsMessage, SmsSendRequest
145
+
146
+ sms = AfricasTalkingSmsClient(
147
+ api_key="...",
148
+ username="...",
149
+ default_sender_id="NORIALABS",
150
+ )
151
+
152
+ sms.send(
153
+ SmsSendRequest(
154
+ messages=[
155
+ SmsMessage(recipient="+254700123456", text="Your OTP is 123456", reference="otp-1"),
156
+ ]
157
+ )
158
+ )
159
+ ```
160
+
161
+ ## Provider Coverage
162
+
163
+ | Provider | Capabilities |
164
+ | --- | --- |
165
+ | Meta WhatsApp | Text, templates, media by id or URL, media upload/get/delete, location, contacts, reactions, interactive buttons/lists, catalog, single product, product list, flows, mark-read, typing indicator, template management, delivery parsing, inbound parsing |
166
+ | Onfon SMS | Bulk SMS send, scheduled SMS, Unicode/flash flags, balance, groups, templates, delivery report parsing |
167
+ | Africa's Talking SMS | Bulk SMS send, premium SMS reply, incoming message fetch, subscription create/delete, balance, delivery report parsing |
168
+
169
+ Non-SMS Africa's Talking products such as Airtime, Voice, USSD, Payments, and
170
+ Data Bundles are intentionally outside the current SMS scope.
171
+
172
+ ## Shared Transport
173
+
174
+ Every provider client accepts the same transport options:
175
+
176
+ - `client`: a custom `httpx.Client` / `httpx.AsyncClient`
177
+ - `timeout_seconds`: request timeout
178
+ - `default_headers`: extra default headers
179
+ - `retry`: a `RetryPolicy` (or `False` to disable)
180
+ - `hooks`: `Hooks(before_request=..., after_response=..., on_error=...)`
181
+
182
+ Per-request options are passed via `RequestOptions`:
183
+
184
+ - `headers`
185
+ - `timeout_seconds`
186
+ - `retry`
187
+
188
+ ```python
189
+ from sendkit import Hooks, OnfonSmsClient, RetryPolicy
190
+
191
+ sms = OnfonSmsClient(
192
+ access_key="access-key",
193
+ api_key="api-key",
194
+ client_id="client-id",
195
+ timeout_seconds=15.0,
196
+ retry=RetryPolicy(
197
+ max_attempts=3,
198
+ retry_methods=("GET", "POST"),
199
+ retry_on_statuses=(429, 500, 502, 503, 504),
200
+ retry_on_network_error=True,
201
+ base_delay_seconds=0.25,
202
+ ),
203
+ hooks=Hooks(
204
+ before_request=lambda ctx: ctx.headers.__setitem__("x-trace-id", "trace-123"),
205
+ ),
206
+ )
207
+ ```
208
+
209
+ ## Construction From Environment
210
+
211
+ Each client exposes a `from_env(...)` classmethod.
212
+
213
+ ```python
214
+ sms = OnfonSmsClient.from_env()
215
+ whatsapp = MetaWhatsAppClient.from_env()
216
+ at = AfricasTalkingSmsClient.from_env()
217
+ ```
218
+
219
+ ### Onfon env vars
220
+
221
+ - `ONFON_ACCESS_KEY`
222
+ - `ONFON_API_KEY`
223
+ - `ONFON_CLIENT_ID`
224
+ - `ONFON_SENDER_ID`
225
+ - `ONFON_BASE_URL`
226
+ - `ONFON_TIMEOUT_SECONDS`
227
+
228
+ ### Meta WhatsApp env vars
229
+
230
+ - `META_WHATSAPP_ACCESS_TOKEN`
231
+ - `META_WHATSAPP_PHONE_NUMBER_ID`
232
+ - `META_WHATSAPP_WHATSAPP_BUSINESS_ACCOUNT_ID`
233
+ - `META_WHATSAPP_APP_SECRET`
234
+ - `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN`
235
+ - `META_WHATSAPP_API_VERSION`
236
+ - `META_WHATSAPP_BASE_URL`
237
+ - `META_WHATSAPP_TIMEOUT_SECONDS`
238
+
239
+ `whatsapp_business_account_id` is required only for template management methods.
240
+
241
+ ### Africa's Talking env vars
242
+
243
+ - `AFRICASTALKING_API_KEY`
244
+ - `AFRICASTALKING_USERNAME`
245
+ - `AFRICASTALKING_SENDER_ID`
246
+ - `AFRICASTALKING_BASE_URL`
247
+ - `AFRICASTALKING_TIMEOUT_SECONDS`
248
+
249
+ Fallback names are also accepted: `AFRICAS_TALKING_API_KEY`,
250
+ `AFRICAS_TALKING_USERNAME`, `AFRICAS_TALKING_SENDER_ID`,
251
+ `AFRICAS_TALKING_BASE_URL`.
252
+
253
+ ## WhatsApp: Meta Cloud API
254
+
255
+ ### WhatsApp Method Reference
256
+
257
+ | Method | Purpose |
258
+ | --- | --- |
259
+ | `send_text(request, options=None)` | Send text messages with optional URL preview |
260
+ | `send_template(request, options=None)` | Send approved template messages |
261
+ | `send_media(request, options=None)` | Send image, audio, document, sticker, or video by media id or URL |
262
+ | `send_location(request, options=None)` | Send a location pin |
263
+ | `send_contacts(request, options=None)` | Send one or more contacts |
264
+ | `send_reaction(request, options=None)` | React to an existing message |
265
+ | `send_interactive(request, options=None)` | Send reply-button or list interactive messages |
266
+ | `send_catalog(request, options=None)` | Send catalog messages |
267
+ | `send_product(request, options=None)` | Send a single-product message |
268
+ | `send_product_list(request, options=None)` | Send a multi-product list |
269
+ | `send_flow(request, options=None)` | Send a WhatsApp Flow interactive message |
270
+ | `mark_message_read(request, options=None)` | Mark an inbound message as read |
271
+ | `send_typing_indicator(request, options=None)` | Mark as read and show a typing indicator |
272
+ | `upload_media(request, options=None)` | Upload media bytes to Meta |
273
+ | `get_media(media_id, options=None)` | Get media metadata and download URL |
274
+ | `delete_media(media_id, options=None)` | Delete uploaded media |
275
+ | `list_templates(request=None, options=None)` | List templates for a WABA |
276
+ | `get_template(template_id, fields=None, options=None)` | Fetch one template |
277
+ | `create_template(request, options=None)` | Create a template |
278
+ | `update_template(template_id, request, options=None)` | Update a template |
279
+ | `delete_template(request, options=None)` | Delete a template by name, id, or ids |
280
+ | `parse_events(payload)` | Parse delivery/read/failed webhook statuses |
281
+ | `parse_inbound_messages(payload)` | Parse inbound messages |
282
+ | `parse_event(payload)` | Return the first parsed delivery event, or `None` |
283
+ | `parse_inbound_message(payload)` | Return the first parsed inbound message, or `None` |
284
+
285
+ ### Text
286
+
287
+ ```python
288
+ from sendkit import WhatsAppTextRequest
289
+
290
+ whatsapp.send_text(
291
+ WhatsAppTextRequest(
292
+ recipient="254700123456",
293
+ text="Plain text message",
294
+ preview_url=True,
295
+ reply_to_message_id="wamid.previous",
296
+ )
297
+ )
298
+ ```
299
+
300
+ ### Templates
301
+
302
+ Template messages support text, media, and button parameters. For media headers
303
+ pass a parameter of type `image`, `video`, or `document` and either a `value`
304
+ (an uploaded media id) or a provider-specific object via `provider_options`.
305
+
306
+ ```python
307
+ from sendkit import (
308
+ WhatsAppTemplateComponent,
309
+ WhatsAppTemplateParameter,
310
+ WhatsAppTemplateRequest,
311
+ )
312
+
313
+ whatsapp.send_template(
314
+ WhatsAppTemplateRequest(
315
+ recipient="254700123456",
316
+ template_name="order_update",
317
+ language_code="en",
318
+ components=[
319
+ WhatsAppTemplateComponent(
320
+ type="header",
321
+ parameters=[
322
+ WhatsAppTemplateParameter(
323
+ type="document",
324
+ provider_options={
325
+ "document": {"id": "media-id", "filename": "invoice.pdf"}
326
+ },
327
+ )
328
+ ],
329
+ ),
330
+ WhatsAppTemplateComponent(
331
+ type="body",
332
+ parameters=[
333
+ WhatsAppTemplateParameter(type="text", value="NORIA-123"),
334
+ WhatsAppTemplateParameter(type="text", value="Ready for pickup"),
335
+ ],
336
+ ),
337
+ WhatsAppTemplateComponent(
338
+ type="button",
339
+ sub_type="quick_reply",
340
+ index=0,
341
+ parameters=[WhatsAppTemplateParameter(type="payload", value="track-order")],
342
+ ),
343
+ ],
344
+ )
345
+ )
346
+ ```
347
+
348
+ ### Media And Attachments
349
+
350
+ Send media by public URL, by uploaded id, or upload bytes first and use the
351
+ returned media id.
352
+
353
+ ```python
354
+ from sendkit import WhatsAppMediaRequest, WhatsAppMediaUploadRequest
355
+
356
+ whatsapp.send_media(
357
+ WhatsAppMediaRequest(
358
+ recipient="254700123456",
359
+ media_type="image",
360
+ link="https://example.com/product.jpg",
361
+ caption="Preview",
362
+ )
363
+ )
364
+
365
+ uploaded = whatsapp.upload_media(
366
+ WhatsAppMediaUploadRequest(
367
+ filename="menu.pdf",
368
+ mime_type="application/pdf",
369
+ content=b"file-bytes",
370
+ )
371
+ )
372
+
373
+ whatsapp.send_media(
374
+ WhatsAppMediaRequest(
375
+ recipient="254700123456",
376
+ media_type="document",
377
+ media_id=uploaded.media_id,
378
+ filename="menu.pdf",
379
+ )
380
+ )
381
+
382
+ whatsapp.get_media(uploaded.media_id)
383
+ whatsapp.delete_media(uploaded.media_id)
384
+ ```
385
+
386
+ Supported media types: `image`, `audio`, `document`, `sticker`, `video`.
387
+
388
+ ### Location, Contacts, And Reactions
389
+
390
+ ```python
391
+ from sendkit import (
392
+ WhatsAppContact,
393
+ WhatsAppContactName,
394
+ WhatsAppContactPhone,
395
+ WhatsAppContactsRequest,
396
+ WhatsAppLocationRequest,
397
+ WhatsAppReactionRequest,
398
+ )
399
+
400
+ whatsapp.send_location(
401
+ WhatsAppLocationRequest(
402
+ recipient="254700123456",
403
+ latitude=-1.286389,
404
+ longitude=36.817223,
405
+ name="Nairobi Office",
406
+ address="Nairobi, Kenya",
407
+ )
408
+ )
409
+
410
+ whatsapp.send_contacts(
411
+ WhatsAppContactsRequest(
412
+ recipient="254700123456",
413
+ contacts=[
414
+ WhatsAppContact(
415
+ name=WhatsAppContactName(formatted_name="Noria Support", first_name="Noria"),
416
+ phones=[WhatsAppContactPhone(phone="+254700000000", type="WORK")],
417
+ )
418
+ ],
419
+ )
420
+ )
421
+
422
+ whatsapp.send_reaction(
423
+ WhatsAppReactionRequest(recipient="254700123456", message_id="wamid.inbound", emoji="👍")
424
+ )
425
+ ```
426
+
427
+ ### Interactive, Catalog, Product, And Flow Messages
428
+
429
+ ```python
430
+ from sendkit import (
431
+ WhatsAppFlowMessageRequest,
432
+ WhatsAppInteractiveButton,
433
+ WhatsAppInteractiveHeader,
434
+ WhatsAppInteractiveRequest,
435
+ WhatsAppInteractiveRow,
436
+ WhatsAppInteractiveSection,
437
+ WhatsAppProductItem,
438
+ WhatsAppProductListRequest,
439
+ WhatsAppProductSection,
440
+ )
441
+
442
+ whatsapp.send_interactive(
443
+ WhatsAppInteractiveRequest(
444
+ recipient="254700123456",
445
+ interactive_type="button",
446
+ body_text="Choose one",
447
+ buttons=[
448
+ WhatsAppInteractiveButton(identifier="yes", title="Yes"),
449
+ WhatsAppInteractiveButton(identifier="no", title="No"),
450
+ ],
451
+ )
452
+ )
453
+
454
+ whatsapp.send_interactive(
455
+ WhatsAppInteractiveRequest(
456
+ recipient="254700123456",
457
+ interactive_type="list",
458
+ body_text="Choose a product",
459
+ button_text="View options",
460
+ sections=[
461
+ WhatsAppInteractiveSection(
462
+ title="Products",
463
+ rows=[
464
+ WhatsAppInteractiveRow(identifier="sku-1", title="Starter"),
465
+ WhatsAppInteractiveRow(identifier="sku-2", title="Pro"),
466
+ ],
467
+ )
468
+ ],
469
+ )
470
+ )
471
+
472
+ whatsapp.send_product_list(
473
+ WhatsAppProductListRequest(
474
+ recipient="254700123456",
475
+ catalog_id="catalog-1",
476
+ header=WhatsAppInteractiveHeader(type="text", text="Featured"),
477
+ sections=[
478
+ WhatsAppProductSection(
479
+ title="Top Picks",
480
+ product_items=[WhatsAppProductItem(product_retailer_id="sku-1")],
481
+ )
482
+ ],
483
+ )
484
+ )
485
+
486
+ whatsapp.send_flow(
487
+ WhatsAppFlowMessageRequest(
488
+ recipient="254700123456",
489
+ flow_cta="Start",
490
+ flow_id="flow-1",
491
+ flow_action="navigate",
492
+ )
493
+ )
494
+ ```
495
+
496
+ ### Read Receipts And Typing Indicator
497
+
498
+ ```python
499
+ from sendkit import WhatsAppReadRequest
500
+
501
+ whatsapp.mark_message_read(WhatsAppReadRequest(message_id="wamid.inbound"))
502
+ whatsapp.send_typing_indicator(WhatsAppReadRequest(message_id="wamid.inbound"))
503
+ ```
504
+
505
+ ### Template Management
506
+
507
+ ```python
508
+ from sendkit import (
509
+ WhatsAppTemplateCreateRequest,
510
+ WhatsAppTemplateDeleteRequest,
511
+ WhatsAppTemplateListRequest,
512
+ WhatsAppTemplateUpdateRequest,
513
+ )
514
+
515
+ result = whatsapp.list_templates(
516
+ WhatsAppTemplateListRequest(limit=20, status=["approved"])
517
+ )
518
+
519
+ whatsapp.get_template("template-id")
520
+
521
+ whatsapp.create_template(
522
+ WhatsAppTemplateCreateRequest(
523
+ name="order_update",
524
+ language="en_US",
525
+ category="utility",
526
+ )
527
+ )
528
+
529
+ whatsapp.update_template("template-id", WhatsAppTemplateUpdateRequest(category="utility"))
530
+ whatsapp.delete_template(WhatsAppTemplateDeleteRequest(template_id="template-id"))
531
+ ```
532
+
533
+ ### WhatsApp Webhooks
534
+
535
+ ```python
536
+ delivery_events = whatsapp.parse_events(meta_webhook_payload)
537
+ inbound_messages = whatsapp.parse_inbound_messages(meta_webhook_payload)
538
+ ```
539
+
540
+ `parse_inbound_messages` supports inbound text, media, location, contacts,
541
+ button replies, interactive replies, reactions, and unsupported message
542
+ fallback metadata.
543
+
544
+ ## SMS: Shared Request Shape
545
+
546
+ All SMS providers use the shared `SmsSendRequest`:
547
+
548
+ ```python
549
+ from datetime import datetime
550
+
551
+ from sendkit import SmsMessage, SmsSendRequest
552
+
553
+ sms.send(
554
+ SmsSendRequest(
555
+ sender_id="NORIALABS",
556
+ messages=[
557
+ SmsMessage(
558
+ recipient="254700123456",
559
+ text="Hello",
560
+ reference="internal-id",
561
+ metadata={"account_id": "acct_1"},
562
+ )
563
+ ],
564
+ schedule_at=datetime(2026, 6, 26, 9, 0, 0),
565
+ is_unicode=False,
566
+ is_flash=False,
567
+ provider_options={},
568
+ )
569
+ )
570
+ ```
571
+
572
+ `provider_options` is passed through to the underlying provider payload when you
573
+ need provider-specific fields.
574
+
575
+ ## SMS: Onfon
576
+
577
+ ### Onfon Method Reference
578
+
579
+ | Method | Purpose |
580
+ | --- | --- |
581
+ | `send(request, options=None)` | Send one or more SMS messages |
582
+ | `get_balance(options=None)` | Read SMS balance |
583
+ | `list_groups(options=None)` | List contact groups |
584
+ | `create_group(request, options=None)` | Create a contact group |
585
+ | `update_group(group_id, request, options=None)` | Update a contact group |
586
+ | `delete_group(group_id, options=None)` | Delete a contact group |
587
+ | `list_templates(options=None)` | List SMS templates |
588
+ | `create_template(request, options=None)` | Create an SMS template |
589
+ | `update_template(template_id, request, options=None)` | Update an SMS template |
590
+ | `delete_template(template_id, options=None)` | Delete an SMS template |
591
+ | `parse_delivery_report(payload)` | Parse delivery-report callbacks |
592
+
593
+ ```python
594
+ from sendkit import SmsGroupUpsertRequest, SmsMessage, SmsSendRequest, SmsTemplateUpsertRequest
595
+
596
+ result = sms.send(
597
+ SmsSendRequest(
598
+ sender_id="NORIALABS",
599
+ messages=[
600
+ SmsMessage(recipient="254700123456", text="Hello there", reference="msg-1"),
601
+ SmsMessage(recipient="254711111111", text="Hello again", reference="msg-2"),
602
+ ],
603
+ is_unicode=False,
604
+ )
605
+ )
606
+
607
+ sms.get_balance()
608
+
609
+ group = sms.create_group(SmsGroupUpsertRequest(name="VIP Customers"))
610
+ sms.update_group(group.resource_id, SmsGroupUpsertRequest(name="Priority Customers"))
611
+ sms.delete_group(group.resource_id)
612
+
613
+ template = sms.create_template(SmsTemplateUpsertRequest(name="otp", body="Your OTP is {{1}}"))
614
+ sms.update_template(template.resource_id, SmsTemplateUpsertRequest(name="otp", body="Use code {{1}}"))
615
+ sms.delete_template(template.resource_id)
616
+
617
+ report = sms.parse_delivery_report(
618
+ {"messageId": "abc123", "mobile": "254700123456", "status": "Delivered"}
619
+ )
620
+ ```
621
+
622
+ ## SMS: Africa's Talking
623
+
624
+ Use `AFRICASTALKING_SANDBOX_SMS_BASE_URL` for sandbox clients:
625
+
626
+ ```python
627
+ from sendkit import AFRICASTALKING_SANDBOX_SMS_BASE_URL, AfricasTalkingSmsClient
628
+
629
+ sandbox_sms = AfricasTalkingSmsClient(
630
+ api_key="...",
631
+ username="sandbox",
632
+ base_url=AFRICASTALKING_SANDBOX_SMS_BASE_URL,
633
+ )
634
+ ```
635
+
636
+ ### Africa's Talking Method Reference
637
+
638
+ | Method | Purpose |
639
+ | --- | --- |
640
+ | `send(request, options=None)` | Send normal bulk SMS |
641
+ | `send_premium(request, options=None)` | Send premium SMS replies using keyword and link id |
642
+ | `fetch_messages(request=None, options=None)` | Fetch incoming SMS messages |
643
+ | `create_subscription(request, options=None)` | Opt a phone number into a premium SMS subscription |
644
+ | `delete_subscription(request, options=None)` | Remove a premium SMS subscription |
645
+ | `get_balance(options=None)` | Read account balance |
646
+ | `parse_delivery_report(payload)` | Parse delivery-report callbacks |
647
+
648
+ The client groups messages by text because Africa's Talking accepts one message
649
+ body per request and many recipients.
650
+
651
+ ```python
652
+ from sendkit import (
653
+ AfricasTalkingFetchMessagesRequest,
654
+ AfricasTalkingPremiumSmsRequest,
655
+ AfricasTalkingSubscriptionRequest,
656
+ SmsMessage,
657
+ SmsSendRequest,
658
+ )
659
+
660
+ sms.send(
661
+ SmsSendRequest(
662
+ sender_id="NORIALABS",
663
+ messages=[
664
+ SmsMessage(recipient="+254700123456", text="Hello", reference="msg-1"),
665
+ SmsMessage(recipient="+254711111111", text="Hello", reference="msg-2"),
666
+ ],
667
+ provider_options={"enqueue": "1"},
668
+ )
669
+ )
670
+
671
+ sms.send_premium(
672
+ AfricasTalkingPremiumSmsRequest(
673
+ recipient="+254700123456",
674
+ short_code="22384",
675
+ keyword="NORIA",
676
+ link_id="link-id-from-inbound-message",
677
+ text="Thanks for subscribing",
678
+ retry_duration_in_hours=2,
679
+ )
680
+ )
681
+
682
+ inbox = sms.fetch_messages(AfricasTalkingFetchMessagesRequest(last_received_id=42))
683
+ for message in inbox.messages:
684
+ print(message.provider_message_id, message.sender, message.text)
685
+
686
+ sms.create_subscription(
687
+ AfricasTalkingSubscriptionRequest(
688
+ phone_number="+254700123456", short_code="22384", keyword="NORIA"
689
+ )
690
+ )
691
+ sms.delete_subscription(
692
+ AfricasTalkingSubscriptionRequest(
693
+ phone_number="+254700123456", short_code="22384", keyword="NORIA"
694
+ )
695
+ )
696
+
697
+ sms.get_balance()
698
+
699
+ event = sms.parse_delivery_report(
700
+ {
701
+ "id": "at-message-id",
702
+ "phoneNumber": "+254700123456",
703
+ "status": "Success",
704
+ "networkCode": "63902",
705
+ "retryCount": "0",
706
+ }
707
+ )
708
+ ```
709
+
710
+ ## Webhooks
711
+
712
+ ### Meta Verification Challenge
713
+
714
+ ```python
715
+ from sendkit import resolve_meta_subscription_challenge
716
+
717
+ challenge = resolve_meta_subscription_challenge(
718
+ {
719
+ "hub.mode": "subscribe",
720
+ "hub.verify_token": "verify-me",
721
+ "hub.challenge": "12345",
722
+ },
723
+ "verify-me",
724
+ )
725
+ ```
726
+
727
+ ### Meta Signature Verification
728
+
729
+ ```python
730
+ from sendkit import require_valid_meta_signature
731
+
732
+ require_valid_meta_signature(raw_body, signature_header, app_secret)
733
+ ```
734
+
735
+ ### SMS Delivery Reports
736
+
737
+ ```python
738
+ from sendkit import parse_africastalking_sms_delivery_report, parse_onfon_delivery_report
739
+
740
+ onfon_event = parse_onfon_delivery_report(query_params, onfon_client)
741
+ at_event = parse_africastalking_sms_delivery_report(body, africastalking_client)
742
+ ```
743
+
744
+ ## Errors
745
+
746
+ Exported errors:
747
+
748
+ - `SendKitError`
749
+ - `ConfigurationError`
750
+ - `ApiError`
751
+ - `ProviderError`
752
+ - `NetworkError`
753
+ - `TimeoutError`
754
+ - `WebhookVerificationError`
755
+
756
+ Provider errors include provider response details where available.
757
+
758
+ ```python
759
+ from sendkit import ProviderError, SmsSendRequest
760
+
761
+ try:
762
+ sms.send(SmsSendRequest(messages=[...]))
763
+ except ProviderError as error:
764
+ print(error.provider, error.error_code, error.response_body)
765
+ ```
766
+
767
+ ## Lifecycle
768
+
769
+ Sync clients are context managers and expose `close()`; async clients are async
770
+ context managers and expose `aclose()`. When you pass your own `httpx` client,
771
+ SendKit will not close it for you.
772
+
773
+ ```python
774
+ with OnfonSmsClient(access_key="...", api_key="...", client_id="...") as sms:
775
+ sms.get_balance()
776
+
777
+ async with AsyncOnfonSmsClient(access_key="...", api_key="...", client_id="...") as sms:
778
+ await sms.get_balance()
779
+ ```
780
+
781
+ ## Runtime Exports
782
+
783
+ Root constant exports:
784
+
785
+ - `ONFON_BASE_URL`
786
+ - `ONFON_SMS_BASE_URL`
787
+ - `AFRICASTALKING_SMS_BASE_URL`
788
+ - `AFRICASTALKING_SANDBOX_SMS_BASE_URL`
789
+ - `META_GRAPH_BASE_URL`
790
+ - `META_GRAPH_API_VERSION`
791
+
792
+ Provider clients, request/result models, transport types (`RequestOptions`,
793
+ `RetryPolicy`, `Hooks`, `DeliveryEvent`, `DeliveryState`, `MessageChannel`), and
794
+ webhook helpers are all available from the top-level `sendkit` package.