mojawave 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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(python3 -m venv .venv)",
5
+ "Bash(.venv/bin/pip install *)",
6
+ "Bash(.venv/bin/python -m pytest -q)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .coverage
13
+ htmlcov/
mojawave-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2026 MojaWave
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,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: mojawave
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the MojaWave API — SMS and transactional messaging for Tanzania.
5
+ Project-URL: Homepage, https://mojawave.com
6
+ Project-URL: Documentation, https://mojawave.com/docs
7
+ Project-URL: Source, https://github.com/mojawave/mojawave-python
8
+ Author: MojaWave
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,messaging,mojawave,otp,sdk,sms,tanzania
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.8
24
+ Requires-Dist: requests>=2.25
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: responses>=0.23; extra == 'dev'
29
+ Requires-Dist: types-requests; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # MojaWave Python SDK
33
+
34
+ A thin, typed Python client for the [MojaWave](https://mojawave.com) REST API —
35
+ send SMS (single, bulk, OTP), check credit balances, and verify webhooks across
36
+ Tanzania's telco networks (Vodacom, Tigo, Airtel, Halotel).
37
+
38
+ ```bash
39
+ pip install mojawave
40
+ ```
41
+
42
+ Requires Python 3.8+.
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ from mojawave import MojaWave
48
+
49
+ client = MojaWave(api_key="sk_live_mw_...") # or set MOJAWAVE_API_KEY
50
+
51
+ msg = client.sms.send(
52
+ to="+255753276939",
53
+ sender="MojaWave",
54
+ message="Hello from Mojawave! Your verification code is 1234.",
55
+ )
56
+ print(msg.id, msg.status)
57
+ ```
58
+
59
+ The client reads `MOJAWAVE_API_KEY` from the environment when `api_key` is
60
+ omitted. Use an `sk_test_mw_` key for the sandbox — no real messages are sent
61
+ and no charges apply.
62
+
63
+ > **Never** expose your live API key in client-side code. Use environment
64
+ > variables and server-side requests only.
65
+
66
+ ## Sending SMS
67
+
68
+ ### Single message
69
+
70
+ ```python
71
+ msg = client.sms.send(
72
+ to="+255712345678",
73
+ sender="MojaWave", # sender ID (≤11 alphanumeric chars); defaults to MojaWave
74
+ message="Your code is 1234.",
75
+ webhook_url="https://example.com/webhooks/sms", # optional delivery receipts
76
+ schedule_at="2026-07-01T09:00:00Z", # optional ISO-8601 schedule
77
+ metadata={"customer_id": "cust98765"}, # optional, echoed back
78
+ tags=["onboarding", "verification"], # optional
79
+ )
80
+ ```
81
+
82
+ ### Look up a message
83
+
84
+ ```python
85
+ msg = client.sms.get("89b82624-f1a2-4f5e-85b5-102e79a06779")
86
+ if msg.delivered:
87
+ print("Delivered at", msg.timeline.delivered_at)
88
+ elif msg.failed:
89
+ print("Failed:", msg.failure_reason)
90
+ ```
91
+
92
+ ### Bulk send (up to 10,000 recipients)
93
+
94
+ Bulk jobs run asynchronously — you get a job back immediately, then poll it.
95
+
96
+ ```python
97
+ job = client.sms.bulk(
98
+ name="Marketing Campaign Q1",
99
+ sender="MojaWave",
100
+ message="Hello {name}, your code is {code}",
101
+ recipients=[
102
+ {"to": "+255712345678", "personalization": {"name": "John", "code": "ABC123"}},
103
+ {"to": "+255712345679", "personalization": {"name": "Jane", "code": "XYZ789"}},
104
+ "+255712345680", # a bare string works too (no personalization)
105
+ ],
106
+ webhook_url="https://example.com/webhooks",
107
+ )
108
+ print(job.id, job.status, job.total_recipients)
109
+
110
+ # Poll for progress
111
+ job = client.sms.get_bulk(job.id)
112
+ print(f"{job.progress_percent}% — {job.sent_count} sent")
113
+ ```
114
+
115
+ > Unicode messages have a 70-character per-segment limit (vs. 160 for plain
116
+ > SMS). Plan message length accordingly.
117
+
118
+ ## Credits
119
+
120
+ ```python
121
+ balances = client.credits.balance()
122
+ print(balances.sms.balance, balances.sms.is_low_balance)
123
+ print(balances.email.balance)
124
+ ```
125
+
126
+ ## Webhooks
127
+
128
+ MojaWave signs every webhook with an `X-MojaWave-Signature` header (HMAC-SHA256
129
+ of the raw body). Always verify against the **raw** request bytes — parsing to
130
+ JSON first can change whitespace and break the check.
131
+
132
+ ```python
133
+ from mojawave import construct_event, WebhookVerificationError, SIGNATURE_HEADER
134
+
135
+ # Flask / Django view
136
+ signature = request.headers.get(SIGNATURE_HEADER)
137
+ try:
138
+ event = construct_event(request.get_data(), signature, WEBHOOK_SECRET)
139
+ except WebhookVerificationError:
140
+ return "Forbidden", 403
141
+
142
+ if event.type == "message.delivered":
143
+ ...
144
+ ```
145
+
146
+ Event types: `message.sent`, `message.delivered`, `message.failed`,
147
+ `credits.low`. See `examples/flask_webhook.py` for a full handler.
148
+
149
+ If you only need a boolean, use `verify_signature(payload, signature, secret)`.
150
+
151
+ ## Error handling
152
+
153
+ Every documented HTTP status maps to a typed exception. All inherit from
154
+ `MojaWaveError`.
155
+
156
+ | Exception | HTTP | Code |
157
+ |---|---|---|
158
+ | `InvalidRequestError` | 400 | `invalid_request` |
159
+ | `AuthenticationError` | 401 | `unauthorized` |
160
+ | `InsufficientBalanceError` | 402 | `insufficient_balance` |
161
+ | `UnprocessableError` | 422 | `unprocessable` |
162
+ | `RateLimitError` | 429 | `rate_limit_exceeded` |
163
+ | `ServerError` | 5xx | `server_error` |
164
+ | `APIConnectionError` / `APITimeoutError` | — | transport failures |
165
+
166
+ ```python
167
+ from mojawave import InsufficientBalanceError, RateLimitError
168
+
169
+ try:
170
+ client.sms.send(to="+255712345678", message="hi")
171
+ except InsufficientBalanceError:
172
+ ... # top up
173
+ except RateLimitError as e:
174
+ time.sleep(e.retry_after or 1)
175
+ ```
176
+
177
+ The client automatically retries `429` and `5xx` responses with exponential
178
+ backoff (honouring `Retry-After`), controlled by `max_retries` (default 2).
179
+
180
+ ## Configuration
181
+
182
+ ```python
183
+ client = MojaWave(
184
+ api_key="sk_live_mw_...",
185
+ environment="live", # or "sandbox"
186
+ timeout=30.0, # seconds
187
+ max_retries=2,
188
+ base_url="https://api.mojawave.com/v1",
189
+ )
190
+ ```
191
+
192
+ Rate-limit headers from the most recent response are available on
193
+ `client.rate_limit` (`.limit`, `.remaining`, `.reset`). The client is usable as
194
+ a context manager to ensure the HTTP session is closed:
195
+
196
+ ```python
197
+ with MojaWave() as client:
198
+ client.sms.send(to="+255712345678", message="hi")
199
+ ```
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ pip install -e ".[dev]"
205
+ pytest
206
+ mypy
207
+ ```
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,180 @@
1
+ # MojaWave Python SDK
2
+
3
+ A thin, typed Python client for the [MojaWave](https://mojawave.com) REST API —
4
+ send SMS (single, bulk, OTP), check credit balances, and verify webhooks across
5
+ Tanzania's telco networks (Vodacom, Tigo, Airtel, Halotel).
6
+
7
+ ```bash
8
+ pip install mojawave
9
+ ```
10
+
11
+ Requires Python 3.8+.
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from mojawave import MojaWave
17
+
18
+ client = MojaWave(api_key="sk_live_mw_...") # or set MOJAWAVE_API_KEY
19
+
20
+ msg = client.sms.send(
21
+ to="+255753276939",
22
+ sender="MojaWave",
23
+ message="Hello from Mojawave! Your verification code is 1234.",
24
+ )
25
+ print(msg.id, msg.status)
26
+ ```
27
+
28
+ The client reads `MOJAWAVE_API_KEY` from the environment when `api_key` is
29
+ omitted. Use an `sk_test_mw_` key for the sandbox — no real messages are sent
30
+ and no charges apply.
31
+
32
+ > **Never** expose your live API key in client-side code. Use environment
33
+ > variables and server-side requests only.
34
+
35
+ ## Sending SMS
36
+
37
+ ### Single message
38
+
39
+ ```python
40
+ msg = client.sms.send(
41
+ to="+255712345678",
42
+ sender="MojaWave", # sender ID (≤11 alphanumeric chars); defaults to MojaWave
43
+ message="Your code is 1234.",
44
+ webhook_url="https://example.com/webhooks/sms", # optional delivery receipts
45
+ schedule_at="2026-07-01T09:00:00Z", # optional ISO-8601 schedule
46
+ metadata={"customer_id": "cust98765"}, # optional, echoed back
47
+ tags=["onboarding", "verification"], # optional
48
+ )
49
+ ```
50
+
51
+ ### Look up a message
52
+
53
+ ```python
54
+ msg = client.sms.get("89b82624-f1a2-4f5e-85b5-102e79a06779")
55
+ if msg.delivered:
56
+ print("Delivered at", msg.timeline.delivered_at)
57
+ elif msg.failed:
58
+ print("Failed:", msg.failure_reason)
59
+ ```
60
+
61
+ ### Bulk send (up to 10,000 recipients)
62
+
63
+ Bulk jobs run asynchronously — you get a job back immediately, then poll it.
64
+
65
+ ```python
66
+ job = client.sms.bulk(
67
+ name="Marketing Campaign Q1",
68
+ sender="MojaWave",
69
+ message="Hello {name}, your code is {code}",
70
+ recipients=[
71
+ {"to": "+255712345678", "personalization": {"name": "John", "code": "ABC123"}},
72
+ {"to": "+255712345679", "personalization": {"name": "Jane", "code": "XYZ789"}},
73
+ "+255712345680", # a bare string works too (no personalization)
74
+ ],
75
+ webhook_url="https://example.com/webhooks",
76
+ )
77
+ print(job.id, job.status, job.total_recipients)
78
+
79
+ # Poll for progress
80
+ job = client.sms.get_bulk(job.id)
81
+ print(f"{job.progress_percent}% — {job.sent_count} sent")
82
+ ```
83
+
84
+ > Unicode messages have a 70-character per-segment limit (vs. 160 for plain
85
+ > SMS). Plan message length accordingly.
86
+
87
+ ## Credits
88
+
89
+ ```python
90
+ balances = client.credits.balance()
91
+ print(balances.sms.balance, balances.sms.is_low_balance)
92
+ print(balances.email.balance)
93
+ ```
94
+
95
+ ## Webhooks
96
+
97
+ MojaWave signs every webhook with an `X-MojaWave-Signature` header (HMAC-SHA256
98
+ of the raw body). Always verify against the **raw** request bytes — parsing to
99
+ JSON first can change whitespace and break the check.
100
+
101
+ ```python
102
+ from mojawave import construct_event, WebhookVerificationError, SIGNATURE_HEADER
103
+
104
+ # Flask / Django view
105
+ signature = request.headers.get(SIGNATURE_HEADER)
106
+ try:
107
+ event = construct_event(request.get_data(), signature, WEBHOOK_SECRET)
108
+ except WebhookVerificationError:
109
+ return "Forbidden", 403
110
+
111
+ if event.type == "message.delivered":
112
+ ...
113
+ ```
114
+
115
+ Event types: `message.sent`, `message.delivered`, `message.failed`,
116
+ `credits.low`. See `examples/flask_webhook.py` for a full handler.
117
+
118
+ If you only need a boolean, use `verify_signature(payload, signature, secret)`.
119
+
120
+ ## Error handling
121
+
122
+ Every documented HTTP status maps to a typed exception. All inherit from
123
+ `MojaWaveError`.
124
+
125
+ | Exception | HTTP | Code |
126
+ |---|---|---|
127
+ | `InvalidRequestError` | 400 | `invalid_request` |
128
+ | `AuthenticationError` | 401 | `unauthorized` |
129
+ | `InsufficientBalanceError` | 402 | `insufficient_balance` |
130
+ | `UnprocessableError` | 422 | `unprocessable` |
131
+ | `RateLimitError` | 429 | `rate_limit_exceeded` |
132
+ | `ServerError` | 5xx | `server_error` |
133
+ | `APIConnectionError` / `APITimeoutError` | — | transport failures |
134
+
135
+ ```python
136
+ from mojawave import InsufficientBalanceError, RateLimitError
137
+
138
+ try:
139
+ client.sms.send(to="+255712345678", message="hi")
140
+ except InsufficientBalanceError:
141
+ ... # top up
142
+ except RateLimitError as e:
143
+ time.sleep(e.retry_after or 1)
144
+ ```
145
+
146
+ The client automatically retries `429` and `5xx` responses with exponential
147
+ backoff (honouring `Retry-After`), controlled by `max_retries` (default 2).
148
+
149
+ ## Configuration
150
+
151
+ ```python
152
+ client = MojaWave(
153
+ api_key="sk_live_mw_...",
154
+ environment="live", # or "sandbox"
155
+ timeout=30.0, # seconds
156
+ max_retries=2,
157
+ base_url="https://api.mojawave.com/v1",
158
+ )
159
+ ```
160
+
161
+ Rate-limit headers from the most recent response are available on
162
+ `client.rate_limit` (`.limit`, `.remaining`, `.reset`). The client is usable as
163
+ a context manager to ensure the HTTP session is closed:
164
+
165
+ ```python
166
+ with MojaWave() as client:
167
+ client.sms.send(to="+255712345678", message="hi")
168
+ ```
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ pip install -e ".[dev]"
174
+ pytest
175
+ mypy
176
+ ```
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,37 @@
1
+ """Receive and verify MojaWave webhooks with Flask.
2
+
3
+ pip install flask
4
+ MOJAWAVE_WEBHOOK_SECRET=whsec_... python examples/flask_webhook.py
5
+ """
6
+
7
+ import os
8
+
9
+ from flask import Flask, request
10
+
11
+ from mojawave import SIGNATURE_HEADER, WebhookVerificationError, construct_event
12
+
13
+ app = Flask(__name__)
14
+ WEBHOOK_SECRET = os.environ["MOJAWAVE_WEBHOOK_SECRET"]
15
+
16
+
17
+ @app.post("/webhooks/mojawave")
18
+ def handle_webhook():
19
+ signature = request.headers.get(SIGNATURE_HEADER)
20
+ try:
21
+ # Verify against the RAW body — never the parsed JSON.
22
+ event = construct_event(request.get_data(), signature, WEBHOOK_SECRET)
23
+ except WebhookVerificationError:
24
+ return "Forbidden", 403
25
+
26
+ if event.type == "message.delivered":
27
+ print(f"Delivered: {event.data['id']} -> {event.data['to']}")
28
+ elif event.type == "message.failed":
29
+ print(f"Failed: {event.data['id']} ({event.data.get('failure_reason')})")
30
+ elif event.type == "credits.low":
31
+ print("Credit balance is low — top up soon.")
32
+
33
+ return "", 204
34
+
35
+
36
+ if __name__ == "__main__":
37
+ app.run(port=4242)
@@ -0,0 +1,33 @@
1
+ """Minimal end-to-end example. Run with MOJAWAVE_API_KEY set in your env."""
2
+
3
+ from mojawave import MojaWave, InsufficientBalanceError
4
+
5
+ client = MojaWave() # reads MOJAWAVE_API_KEY
6
+
7
+ # Check balance first.
8
+ balances = client.credits.balance()
9
+ print(f"SMS credits remaining: {balances.sms.balance}")
10
+
11
+ # Send a single message.
12
+ try:
13
+ msg = client.sms.send(
14
+ to="+255753276939",
15
+ sender="MojaWave",
16
+ message="Hello from Mojawave! Your code is 1234.",
17
+ metadata={"customer_id": "cust98765"},
18
+ )
19
+ print(f"Sent {msg.id} — status={msg.status}, cost={msg.credits_cost}")
20
+ except InsufficientBalanceError:
21
+ print("Top up your account to send messages.")
22
+
23
+ # Bulk send with per-recipient personalization.
24
+ job = client.sms.bulk(
25
+ name="Marketing Campaign Q1",
26
+ sender="MojaWave",
27
+ message="Hello {name}, your code is {code}",
28
+ recipients=[
29
+ {"to": "+255712345678", "personalization": {"name": "John", "code": "ABC123"}},
30
+ {"to": "+255712345679", "personalization": {"name": "Jane", "code": "XYZ789"}},
31
+ ],
32
+ )
33
+ print(f"Bulk job {job.id}: {job.status} ({job.total_recipients} recipients)")
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mojawave"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for the MojaWave API — SMS and transactional messaging for Tanzania."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "MojaWave" }]
13
+ keywords = ["mojawave", "sms", "tanzania", "otp", "messaging", "api", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.8",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["requests>=2.25"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7.0", "responses>=0.23", "mypy>=1.0", "types-requests"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://mojawave.com"
34
+ Documentation = "https://mojawave.com/docs"
35
+ Source = "https://github.com/mojawave/mojawave-python"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/mojawave"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+
43
+ [tool.mypy]
44
+ python_version = "3.10"
45
+ strict = true
46
+ files = ["src/mojawave"]
@@ -0,0 +1,70 @@
1
+ """MojaWave Python SDK.
2
+
3
+ A thin, typed client for the MojaWave REST API — send SMS (single, bulk, OTP),
4
+ check credit balances, and verify webhooks.
5
+
6
+ from mojawave import MojaWave
7
+
8
+ client = MojaWave(api_key="sk_live_mw_...")
9
+ client.sms.send(to="+255712345678", message="Hello!")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "1.0.0"
15
+
16
+ from .client import MojaWave
17
+ from .errors import (
18
+ APIConnectionError,
19
+ APITimeoutError,
20
+ AuthenticationError,
21
+ InsufficientBalanceError,
22
+ InvalidRequestError,
23
+ MojaWaveError,
24
+ RateLimitError,
25
+ ServerError,
26
+ UnprocessableError,
27
+ )
28
+ from .models import (
29
+ BulkJob,
30
+ CreditBalances,
31
+ Message,
32
+ ServiceCredits,
33
+ Timeline,
34
+ )
35
+ from .webhooks import (
36
+ SIGNATURE_HEADER,
37
+ WebhookEvent,
38
+ WebhookVerificationError,
39
+ compute_signature,
40
+ construct_event,
41
+ verify_signature,
42
+ )
43
+
44
+ __all__ = [
45
+ "__version__",
46
+ "MojaWave",
47
+ # errors
48
+ "MojaWaveError",
49
+ "APIConnectionError",
50
+ "APITimeoutError",
51
+ "AuthenticationError",
52
+ "InsufficientBalanceError",
53
+ "InvalidRequestError",
54
+ "RateLimitError",
55
+ "ServerError",
56
+ "UnprocessableError",
57
+ # models
58
+ "BulkJob",
59
+ "CreditBalances",
60
+ "Message",
61
+ "ServiceCredits",
62
+ "Timeline",
63
+ # webhooks
64
+ "SIGNATURE_HEADER",
65
+ "WebhookEvent",
66
+ "WebhookVerificationError",
67
+ "compute_signature",
68
+ "construct_event",
69
+ "verify_signature",
70
+ ]