whatsarab 0.1.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,6 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: whatsarab
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the WhatsArab API — WhatsApp messages, media, templates, OTP, and signed webhooks on the official Meta Cloud API.
5
+ Project-URL: Homepage, https://whatsarab.com/docs
6
+ Project-URL: Documentation, https://whatsarab.com/docs
7
+ Project-URL: Source, https://github.com/efayek/whatsapp-api/tree/main/sdks/python
8
+ Project-URL: Support, https://whatsarab.com/support
9
+ Author: WhatsArab
10
+ License: MIT
11
+ Keywords: arabic,mena,messaging,meta-cloud-api,otp,whatsapp,whatsapp-business-api
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications :: Chat
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # whatsarab
30
+
31
+ Official Python client for the [WhatsArab](https://whatsarab.com) API — send WhatsApp messages on the official Meta Cloud API, upload media, manage templates, send and verify one-time codes, and verify signed webhooks.
32
+
33
+ ```bash
34
+ pip install whatsarab
35
+ ```
36
+
37
+ Python 3.9+.
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import os
43
+ from whatsarab import WhatsArab
44
+
45
+ wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
46
+
47
+ # Free-form text — only inside the 24-hour customer-service window
48
+ wa.messages.send(to="201234567890", text="أهلًا يا سارة! طلبك رقم ١٠٤٢٨ خرج للتوصيل.")
49
+
50
+ # An approved template — works any time
51
+ wa.messages.send(
52
+ to="201234567890",
53
+ template="order_shipped",
54
+ language="ar",
55
+ components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
56
+ )
57
+ ```
58
+
59
+ Get an API key from **/app/integrations → API keys**. Keys are shown once, at creation.
60
+
61
+ ## What this covers
62
+
63
+ The API-key surface, which is the eleven operations under `/api` in the [OpenAPI document](https://whatsarab.com/public/openapi.json):
64
+
65
+ | | |
66
+ |---|---|
67
+ | `wa.messages.send()` | text · template · interactive · media |
68
+ | `wa.media.upload()` / `.download()` | multipart up, raw bytes back |
69
+ | `wa.templates.list() / .create() / .edit() / .delete()` | Meta template management |
70
+ | `wa.otp.send()` / `.verify()` | one-time codes over WhatsApp |
71
+ | `wa.events.list()` | recent webhook events |
72
+ | `verify_webhook_signature()` | HMAC check for deliveries to you |
73
+
74
+ **Not covered, deliberately:** contacts, the inbox and broadcasts are session-authenticated — they belong to a signed-in dashboard user, not to a server-side key — so there is no endpoint for this client to call. If that changes, it changes in the OpenAPI document first.
75
+
76
+ ## Sending
77
+
78
+ Exactly one of `template`, `text`, `interactive` or `media` per call — checked locally, so a mistake is a `ValueError` on your machine rather than a 400 from production.
79
+
80
+ ```python
81
+ # Media: upload once, then send by id
82
+ with open("invoice.pdf", "rb") as fh:
83
+ uploaded = wa.media.upload(fh, filename="invoice.pdf", content_type="application/pdf")
84
+
85
+ wa.messages.send(
86
+ to="201234567890",
87
+ media={"id": uploaded["media_id"], "kind": "document", "filename": "invoice.pdf"},
88
+ )
89
+
90
+ # Interactive buttons
91
+ wa.messages.send(
92
+ to="201234567890",
93
+ interactive={
94
+ "type": "button",
95
+ "body": {"text": "تحب نأكد الطلب؟"},
96
+ "action": {"buttons": [{"type": "reply", "reply": {"id": "yes", "title": "أكيد"}}]},
97
+ },
98
+ )
99
+
100
+ # Multi-number workspace: pick the sender explicitly
101
+ wa.messages.send(to="201234567890", text="hi", from_phone_number_id="113456789012345")
102
+
103
+ # Check the call without spending a message
104
+ wa.messages.send(to="201234567890", template="order_shipped", dry_run=True)
105
+ ```
106
+
107
+ `text`, `interactive` and `media` are free but only deliverable inside the recipient's 24-hour customer-service window. Outside it the call fails with `OUTSIDE_CSW` and you need an approved template.
108
+
109
+ The response is a `202`: Meta accepted the message. Delivery arrives later on the webhook bus, matched by `message_id` (the wamid).
110
+
111
+ ## One-time codes
112
+
113
+ ```python
114
+ sent = wa.otp.send(to="201234567890", template="otp_login", language="ar")
115
+ result = wa.otp.verify(request_id=sent["request_id"], code="123456")
116
+
117
+ if result["verified"]:
118
+ ...
119
+ ```
120
+
121
+ The code is generated, hashed and checked on our side — it is never returned to you.
122
+
123
+ ## Errors
124
+
125
+ Every failure — including a refused connection or a timeout — is a `WhatsArabError`, so one `except` covers the lot. Branch on `.code`, which is stable; the message is for humans and may be reworded.
126
+
127
+ ```python
128
+ from whatsarab import WhatsArabError
129
+
130
+ try:
131
+ wa.messages.send(to="201234567890", text="hi")
132
+ except WhatsArabError as err:
133
+ if err.code == "OUTSIDE_CSW":
134
+ send_template_instead()
135
+ elif err.code == "OVER_QUOTA":
136
+ queue_for_next_month()
137
+ elif err.code in ("TIMEOUT", "NETWORK_ERROR"):
138
+ retry_later()
139
+ else:
140
+ raise
141
+ ```
142
+
143
+ `err.request_id` is worth logging — quote it in a support ticket and we can find the exact request.
144
+
145
+ ## Verifying webhooks
146
+
147
+ Deliveries to your endpoint carry `X-Whatsarab-Timestamp` and `X-Whatsarab-Signature: sha256=<hex>`, computed over `f"{timestamp}.{raw_body}"` with your subscription secret.
148
+
149
+ ```python
150
+ from fastapi import FastAPI, HTTPException, Request
151
+ from whatsarab import verify_webhook_signature
152
+
153
+ app = FastAPI()
154
+
155
+ @app.post("/webhooks/whatsarab")
156
+ async def hook(request: Request):
157
+ body = await request.body() # the raw bytes
158
+ ok = verify_webhook_signature(
159
+ secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
160
+ raw_body=body,
161
+ signature_header=request.headers.get("x-whatsarab-signature"),
162
+ timestamp_header=request.headers.get("x-whatsarab-timestamp"),
163
+ )
164
+ if not ok:
165
+ raise HTTPException(status_code=401)
166
+ ...
167
+ ```
168
+
169
+ **Pass the raw body.** A body that has been parsed and re-serialized will not match — that is the single commonest way this check is got wrong. The comparison is constant-time, and deliveries more than five minutes old are rejected (`tolerance_seconds=0` turns that off).
170
+
171
+ ## Options
172
+
173
+ ```python
174
+ WhatsArab(
175
+ api_key="…", # required
176
+ base_url="https://whatsarab.com", # override for staging or self-hosted
177
+ timeout=30.0, # seconds; None disables
178
+ )
179
+ ```
180
+
181
+ The client holds an `httpx.Client`. Close it when you are done, or use it as a context manager:
182
+
183
+ ```python
184
+ with WhatsArab(api_key="…") as wa:
185
+ wa.messages.send(to="201234567890", text="hi")
186
+ ```
187
+
188
+ ## Links
189
+
190
+ - [Docs](https://whatsarab.com/docs) · [OpenAPI](https://whatsarab.com/public/openapi.json) · [Guides](https://whatsarab.com/how-to) · [Support](https://whatsarab.com/support)
191
+
192
+ MIT.
@@ -0,0 +1,164 @@
1
+ # whatsarab
2
+
3
+ Official Python client for the [WhatsArab](https://whatsarab.com) API — send WhatsApp messages on the official Meta Cloud API, upload media, manage templates, send and verify one-time codes, and verify signed webhooks.
4
+
5
+ ```bash
6
+ pip install whatsarab
7
+ ```
8
+
9
+ Python 3.9+.
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import os
15
+ from whatsarab import WhatsArab
16
+
17
+ wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
18
+
19
+ # Free-form text — only inside the 24-hour customer-service window
20
+ wa.messages.send(to="201234567890", text="أهلًا يا سارة! طلبك رقم ١٠٤٢٨ خرج للتوصيل.")
21
+
22
+ # An approved template — works any time
23
+ wa.messages.send(
24
+ to="201234567890",
25
+ template="order_shipped",
26
+ language="ar",
27
+ components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
28
+ )
29
+ ```
30
+
31
+ Get an API key from **/app/integrations → API keys**. Keys are shown once, at creation.
32
+
33
+ ## What this covers
34
+
35
+ The API-key surface, which is the eleven operations under `/api` in the [OpenAPI document](https://whatsarab.com/public/openapi.json):
36
+
37
+ | | |
38
+ |---|---|
39
+ | `wa.messages.send()` | text · template · interactive · media |
40
+ | `wa.media.upload()` / `.download()` | multipart up, raw bytes back |
41
+ | `wa.templates.list() / .create() / .edit() / .delete()` | Meta template management |
42
+ | `wa.otp.send()` / `.verify()` | one-time codes over WhatsApp |
43
+ | `wa.events.list()` | recent webhook events |
44
+ | `verify_webhook_signature()` | HMAC check for deliveries to you |
45
+
46
+ **Not covered, deliberately:** contacts, the inbox and broadcasts are session-authenticated — they belong to a signed-in dashboard user, not to a server-side key — so there is no endpoint for this client to call. If that changes, it changes in the OpenAPI document first.
47
+
48
+ ## Sending
49
+
50
+ Exactly one of `template`, `text`, `interactive` or `media` per call — checked locally, so a mistake is a `ValueError` on your machine rather than a 400 from production.
51
+
52
+ ```python
53
+ # Media: upload once, then send by id
54
+ with open("invoice.pdf", "rb") as fh:
55
+ uploaded = wa.media.upload(fh, filename="invoice.pdf", content_type="application/pdf")
56
+
57
+ wa.messages.send(
58
+ to="201234567890",
59
+ media={"id": uploaded["media_id"], "kind": "document", "filename": "invoice.pdf"},
60
+ )
61
+
62
+ # Interactive buttons
63
+ wa.messages.send(
64
+ to="201234567890",
65
+ interactive={
66
+ "type": "button",
67
+ "body": {"text": "تحب نأكد الطلب؟"},
68
+ "action": {"buttons": [{"type": "reply", "reply": {"id": "yes", "title": "أكيد"}}]},
69
+ },
70
+ )
71
+
72
+ # Multi-number workspace: pick the sender explicitly
73
+ wa.messages.send(to="201234567890", text="hi", from_phone_number_id="113456789012345")
74
+
75
+ # Check the call without spending a message
76
+ wa.messages.send(to="201234567890", template="order_shipped", dry_run=True)
77
+ ```
78
+
79
+ `text`, `interactive` and `media` are free but only deliverable inside the recipient's 24-hour customer-service window. Outside it the call fails with `OUTSIDE_CSW` and you need an approved template.
80
+
81
+ The response is a `202`: Meta accepted the message. Delivery arrives later on the webhook bus, matched by `message_id` (the wamid).
82
+
83
+ ## One-time codes
84
+
85
+ ```python
86
+ sent = wa.otp.send(to="201234567890", template="otp_login", language="ar")
87
+ result = wa.otp.verify(request_id=sent["request_id"], code="123456")
88
+
89
+ if result["verified"]:
90
+ ...
91
+ ```
92
+
93
+ The code is generated, hashed and checked on our side — it is never returned to you.
94
+
95
+ ## Errors
96
+
97
+ Every failure — including a refused connection or a timeout — is a `WhatsArabError`, so one `except` covers the lot. Branch on `.code`, which is stable; the message is for humans and may be reworded.
98
+
99
+ ```python
100
+ from whatsarab import WhatsArabError
101
+
102
+ try:
103
+ wa.messages.send(to="201234567890", text="hi")
104
+ except WhatsArabError as err:
105
+ if err.code == "OUTSIDE_CSW":
106
+ send_template_instead()
107
+ elif err.code == "OVER_QUOTA":
108
+ queue_for_next_month()
109
+ elif err.code in ("TIMEOUT", "NETWORK_ERROR"):
110
+ retry_later()
111
+ else:
112
+ raise
113
+ ```
114
+
115
+ `err.request_id` is worth logging — quote it in a support ticket and we can find the exact request.
116
+
117
+ ## Verifying webhooks
118
+
119
+ Deliveries to your endpoint carry `X-Whatsarab-Timestamp` and `X-Whatsarab-Signature: sha256=<hex>`, computed over `f"{timestamp}.{raw_body}"` with your subscription secret.
120
+
121
+ ```python
122
+ from fastapi import FastAPI, HTTPException, Request
123
+ from whatsarab import verify_webhook_signature
124
+
125
+ app = FastAPI()
126
+
127
+ @app.post("/webhooks/whatsarab")
128
+ async def hook(request: Request):
129
+ body = await request.body() # the raw bytes
130
+ ok = verify_webhook_signature(
131
+ secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
132
+ raw_body=body,
133
+ signature_header=request.headers.get("x-whatsarab-signature"),
134
+ timestamp_header=request.headers.get("x-whatsarab-timestamp"),
135
+ )
136
+ if not ok:
137
+ raise HTTPException(status_code=401)
138
+ ...
139
+ ```
140
+
141
+ **Pass the raw body.** A body that has been parsed and re-serialized will not match — that is the single commonest way this check is got wrong. The comparison is constant-time, and deliveries more than five minutes old are rejected (`tolerance_seconds=0` turns that off).
142
+
143
+ ## Options
144
+
145
+ ```python
146
+ WhatsArab(
147
+ api_key="…", # required
148
+ base_url="https://whatsarab.com", # override for staging or self-hosted
149
+ timeout=30.0, # seconds; None disables
150
+ )
151
+ ```
152
+
153
+ The client holds an `httpx.Client`. Close it when you are done, or use it as a context manager:
154
+
155
+ ```python
156
+ with WhatsArab(api_key="…") as wa:
157
+ wa.messages.send(to="201234567890", text="hi")
158
+ ```
159
+
160
+ ## Links
161
+
162
+ - [Docs](https://whatsarab.com/docs) · [OpenAPI](https://whatsarab.com/public/openapi.json) · [Guides](https://whatsarab.com/how-to) · [Support](https://whatsarab.com/support)
163
+
164
+ MIT.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whatsarab"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the WhatsArab API — WhatsApp messages, media, templates, OTP, and signed webhooks on the official Meta Cloud API."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "WhatsArab" }]
13
+ keywords = ["whatsapp", "whatsapp-business-api", "meta-cloud-api", "messaging", "otp", "mena", "arabic"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Communications :: Chat",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["httpx>=0.27"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://whatsarab.com/docs"
31
+ Documentation = "https://whatsarab.com/docs"
32
+ Source = "https://github.com/efayek/whatsapp-api/tree/main/sdks/python"
33
+ Support = "https://whatsarab.com/support"
34
+
35
+ [project.optional-dependencies]
36
+ dev = ["pytest>=8.0"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/whatsarab"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,32 @@
1
+ """whatsarab — the official Python client for the WhatsArab API.
2
+
3
+ from whatsarab import WhatsArab
4
+
5
+ wa = WhatsArab(api_key=os.environ["WHATSARAB_API_KEY"])
6
+ wa.messages.send(to="201234567890", text="أهلًا! طلبك في الطريق.")
7
+
8
+ sent = wa.otp.send(to="201234567890", template="otp_login")
9
+ wa.otp.verify(request_id=sent["request_id"], code="123456")
10
+
11
+ SCOPE: this client covers the API-KEY surface and nothing else — the eleven
12
+ operations under /api in the published OpenAPI document. Contacts, inbox and
13
+ broadcasts are session-authenticated (they belong to the dashboard, not to a
14
+ server-side key), so an SDK cannot offer them without inventing endpoints.
15
+ """
16
+
17
+ from .client import (
18
+ DEFAULT_BASE_URL,
19
+ WhatsArab,
20
+ WhatsArabError,
21
+ )
22
+ from .webhooks import verify_webhook_signature
23
+
24
+ __all__ = [
25
+ "WhatsArab",
26
+ "WhatsArabError",
27
+ "verify_webhook_signature",
28
+ "DEFAULT_BASE_URL",
29
+ "__version__",
30
+ ]
31
+
32
+ __version__ = "0.1.0"
@@ -0,0 +1,310 @@
1
+ """HTTP client and resources.
2
+
3
+ httpx rather than requests: it gives us HTTP/2, a real timeout object, and the
4
+ same call shape for a future async client without a second implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, BinaryIO, Dict, List, Mapping, Optional, Sequence, Union
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ DEFAULT_BASE_URL = "https://whatsarab.com"
15
+ DEFAULT_TIMEOUT = 30.0
16
+
17
+ MediaKind = str # "image" | "video" | "audio" | "document" | "sticker"
18
+ TemplateCategory = str # "AUTHENTICATION" | "MARKETING" | "UTILITY" | "SERVICE"
19
+
20
+
21
+ class WhatsArabError(Exception):
22
+ """Every failure, transport included, so a caller writes one except clause.
23
+
24
+ Branch on ``code`` — it is the platform's stable error code
25
+ (``OUTSIDE_CSW``, ``OVER_QUOTA``, ``INVALID_PHONE``…). ``args[0]`` /
26
+ ``str(err)`` is the human message and may be reworded between releases.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ status: int,
34
+ code: str,
35
+ request_id: Optional[str] = None,
36
+ meta_error: Any = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.code = code
41
+ self.request_id = request_id
42
+ self.meta_error = meta_error
43
+
44
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
45
+ return f"WhatsArabError(code={self.code!r}, status={self.status}, message={str(self)!r})"
46
+
47
+
48
+ class WhatsArab:
49
+ """Client for the WhatsArab API.
50
+
51
+ Args:
52
+ api_key: your key (``whap_<prefix>_<secret>``), from /app/integrations.
53
+ base_url: backend origin. Defaults to the hosted platform.
54
+ timeout: seconds before a request is abandoned. ``None`` disables.
55
+ transport: an ``httpx`` transport, for tests or a custom proxy.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ api_key: str,
61
+ *,
62
+ base_url: str = DEFAULT_BASE_URL,
63
+ timeout: Optional[float] = DEFAULT_TIMEOUT,
64
+ transport: Optional[httpx.BaseTransport] = None,
65
+ client: Optional[httpx.Client] = None,
66
+ ) -> None:
67
+ if not api_key:
68
+ raise ValueError("api_key is required")
69
+
70
+ self.base_url = base_url.rstrip("/")
71
+ self._client = client or httpx.Client(
72
+ base_url=self.base_url,
73
+ timeout=timeout,
74
+ transport=transport,
75
+ headers={"X-API-Key": api_key},
76
+ )
77
+ # A caller-supplied client may not carry the key; set it either way so
78
+ # the header is never silently missing.
79
+ self._client.headers["X-API-Key"] = api_key
80
+
81
+ self.messages = _Messages(self)
82
+ self.media = _Media(self)
83
+ self.templates = _Templates(self)
84
+ self.otp = _Otp(self)
85
+ self.events = _Events(self)
86
+
87
+ # ── plumbing ────────────────────────────────────────────────────
88
+
89
+ def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ *,
94
+ json: Any = None,
95
+ files: Any = None,
96
+ params: Optional[Mapping[str, Any]] = None,
97
+ raw: bool = False,
98
+ ) -> Any:
99
+ try:
100
+ response = self._client.request(method, path, json=json, files=files, params=params)
101
+ except httpx.TimeoutException as exc:
102
+ raise WhatsArabError(
103
+ str(exc) or f"Timed out talking to {self.base_url}",
104
+ status=0,
105
+ code="TIMEOUT",
106
+ ) from exc
107
+ except httpx.HTTPError as exc:
108
+ raise WhatsArabError(
109
+ str(exc) or f"Could not reach {self.base_url}",
110
+ status=0,
111
+ code="NETWORK_ERROR",
112
+ ) from exc
113
+
114
+ if raw:
115
+ if response.status_code >= 400:
116
+ raise self._error_from(response)
117
+ return response.content
118
+
119
+ try:
120
+ envelope = response.json()
121
+ except ValueError:
122
+ envelope = None
123
+
124
+ # The envelope is the authority, not the status line: the platform can
125
+ # answer 200 with success=false, and a client that trusted the status
126
+ # would hand back None and call it a send.
127
+ if response.status_code >= 400 or not isinstance(envelope, dict) or not envelope.get("success"):
128
+ raise self._error_from(response, envelope)
129
+ return envelope.get("data")
130
+
131
+ @staticmethod
132
+ def _error_from(response: httpx.Response, envelope: Any = None) -> WhatsArabError:
133
+ if envelope is None:
134
+ try:
135
+ envelope = response.json()
136
+ except ValueError:
137
+ envelope = None
138
+ error = envelope.get("error") if isinstance(envelope, dict) else None
139
+ error = error if isinstance(error, dict) else {}
140
+ return WhatsArabError(
141
+ error.get("message") or response.reason_phrase or "Request failed",
142
+ status=response.status_code,
143
+ code=error.get("code") or f"HTTP_{response.status_code}",
144
+ request_id=envelope.get("request_id") if isinstance(envelope, dict) else None,
145
+ meta_error=error.get("meta_error"),
146
+ )
147
+
148
+ def close(self) -> None:
149
+ self._client.close()
150
+
151
+ def __enter__(self) -> "WhatsArab":
152
+ return self
153
+
154
+ def __exit__(self, *exc_info: Any) -> None:
155
+ self.close()
156
+
157
+
158
+ class _Messages:
159
+ def __init__(self, client: WhatsArab) -> None:
160
+ self._client = client
161
+
162
+ def send(
163
+ self,
164
+ *,
165
+ to: str,
166
+ template: Optional[str] = None,
167
+ text: Optional[str] = None,
168
+ interactive: Optional[Mapping[str, Any]] = None,
169
+ media: Optional[Mapping[str, Any]] = None,
170
+ language: Optional[str] = None,
171
+ components: Optional[Sequence[Mapping[str, Any]]] = None,
172
+ from_phone_number_id: Optional[str] = None,
173
+ dry_run: Optional[bool] = None,
174
+ ) -> Dict[str, Any]:
175
+ """Send one message. Returns the 202 body, including the wamid.
176
+
177
+ Exactly one of ``template`` / ``text`` / ``interactive`` / ``media``.
178
+ Python cannot express that in the signature the way a TypeScript union
179
+ can, so it is checked here — a local ValueError beats a round trip that
180
+ comes back 400.
181
+
182
+ ``text``, ``interactive`` and ``media`` are free but deliverable only
183
+ inside the recipient's 24-hour customer-service window; outside it the
184
+ call fails with ``OUTSIDE_CSW``.
185
+ """
186
+ kinds = {
187
+ "template": template,
188
+ "text": text,
189
+ "interactive": interactive,
190
+ "media": media,
191
+ }
192
+ present = [name for name, value in kinds.items() if value is not None]
193
+ if len(present) != 1:
194
+ raise ValueError(
195
+ "exactly one of template / text / interactive / media is required, got: "
196
+ + (", ".join(present) if present else "none")
197
+ )
198
+
199
+ payload: Dict[str, Any] = {"to": to}
200
+ payload[present[0]] = kinds[present[0]]
201
+ if language is not None:
202
+ payload["language"] = language
203
+ if components is not None:
204
+ payload["components"] = list(components)
205
+ if from_phone_number_id is not None:
206
+ payload["from_phone_number_id"] = from_phone_number_id
207
+ if dry_run is not None:
208
+ payload["dry_run"] = dry_run
209
+ return self._client.request("POST", "/api/messages", json=payload)
210
+
211
+
212
+ class _Media:
213
+ def __init__(self, client: WhatsArab) -> None:
214
+ self._client = client
215
+
216
+ def upload(
217
+ self,
218
+ file: Union[bytes, BinaryIO],
219
+ *,
220
+ filename: Optional[str] = None,
221
+ content_type: Optional[str] = None,
222
+ ) -> Dict[str, Any]:
223
+ """Upload a file and get back a ``media_id`` for ``messages.send``."""
224
+ return self._client.request(
225
+ "POST",
226
+ "/api/media",
227
+ files={"file": (filename, file, content_type)},
228
+ )
229
+
230
+ def download(self, media_id: str) -> bytes:
231
+ """Download a media file by id. Returns the raw bytes."""
232
+ return self._client.request("GET", f"/api/media/{quote(str(media_id), safe='')}", raw=True)
233
+
234
+
235
+ class _Templates:
236
+ def __init__(self, client: WhatsArab) -> None:
237
+ self._client = client
238
+
239
+ def list(self) -> List[Any]:
240
+ return self._client.request("GET", "/api/templates")
241
+
242
+ def create(
243
+ self,
244
+ *,
245
+ name: str,
246
+ language: str,
247
+ category: TemplateCategory,
248
+ components: Sequence[Mapping[str, Any]],
249
+ ) -> Any:
250
+ return self._client.request(
251
+ "POST",
252
+ "/api/templates",
253
+ json={
254
+ "name": name,
255
+ "language": language,
256
+ "category": category,
257
+ "components": list(components),
258
+ },
259
+ )
260
+
261
+ def edit(self, template_id: str, **fields: Any) -> Any:
262
+ """Edit by Meta template id — note that create/delete key off the NAME."""
263
+ return self._client.request(
264
+ "PATCH", f"/api/templates/{quote(str(template_id), safe='')}", json=fields
265
+ )
266
+
267
+ def delete(self, name: str) -> Any:
268
+ return self._client.request("DELETE", f"/api/templates/{quote(str(name), safe='')}")
269
+
270
+
271
+ class _Otp:
272
+ def __init__(self, client: WhatsArab) -> None:
273
+ self._client = client
274
+
275
+ def send(
276
+ self,
277
+ *,
278
+ to: str,
279
+ template: str,
280
+ language: Optional[str] = None,
281
+ code_length: Optional[int] = None,
282
+ ttl_seconds: Optional[int] = None,
283
+ max_attempts: Optional[int] = None,
284
+ from_phone_number_id: Optional[str] = None,
285
+ ) -> Dict[str, Any]:
286
+ payload: Dict[str, Any] = {"to": to, "template": template}
287
+ for key, value in (
288
+ ("language", language),
289
+ ("code_length", code_length),
290
+ ("ttl_seconds", ttl_seconds),
291
+ ("max_attempts", max_attempts),
292
+ ("from_phone_number_id", from_phone_number_id),
293
+ ):
294
+ if value is not None:
295
+ payload[key] = value
296
+ return self._client.request("POST", "/api/otp/send", json=payload)
297
+
298
+ def verify(self, *, request_id: str, code: str) -> Dict[str, Any]:
299
+ return self._client.request(
300
+ "POST", "/api/otp/verify", json={"request_id": request_id, "code": code}
301
+ )
302
+
303
+
304
+ class _Events:
305
+ def __init__(self, client: WhatsArab) -> None:
306
+ self._client = client
307
+
308
+ def list(self, limit: int = 50) -> List[Dict[str, Any]]:
309
+ """Recent webhook events, newest first."""
310
+ return self._client.request("GET", "/api/events", params={"limit": limit})
@@ -0,0 +1,66 @@
1
+ """Verification for outbound webhooks from WhatsArab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+ from typing import Callable, Optional, Union
9
+
10
+
11
+ def verify_webhook_signature(
12
+ *,
13
+ secret: str,
14
+ raw_body: Union[str, bytes],
15
+ signature_header: Optional[str],
16
+ timestamp_header: Optional[str],
17
+ tolerance_seconds: int = 300,
18
+ now: Optional[Callable[[], float]] = None,
19
+ ) -> bool:
20
+ """Return True if a delivery genuinely came from WhatsArab.
21
+
22
+ Deliveries carry ``X-Whatsarab-Timestamp`` and
23
+ ``X-Whatsarab-Signature: sha256=<hex>``, computed over
24
+ ``f"{timestamp}.{raw_body}"`` with the subscription's secret.
25
+
26
+ Pass the RAW body — the exact bytes received. A body that has been parsed
27
+ and re-serialized will not match, and that is the single commonest way this
28
+ check is got wrong::
29
+
30
+ @app.post("/webhooks/whatsarab")
31
+ async def hook(request: Request):
32
+ body = await request.body()
33
+ ok = verify_webhook_signature(
34
+ secret=os.environ["WHATSARAB_WEBHOOK_SECRET"],
35
+ raw_body=body,
36
+ signature_header=request.headers.get("x-whatsarab-signature"),
37
+ timestamp_header=request.headers.get("x-whatsarab-timestamp"),
38
+ )
39
+ if not ok:
40
+ raise HTTPException(401)
41
+
42
+ Set ``tolerance_seconds=0`` to skip the freshness check (replay protection
43
+ off). Returns False rather than raising on anything malformed, so a bad
44
+ header cannot turn into a 500 on your webhook route.
45
+ """
46
+ if not secret or not signature_header or not timestamp_header:
47
+ return False
48
+
49
+ if tolerance_seconds > 0:
50
+ try:
51
+ sent_at = float(timestamp_header)
52
+ except (TypeError, ValueError):
53
+ return False
54
+ current = now() if now is not None else time.time()
55
+ if abs(current - sent_at) > tolerance_seconds:
56
+ return False
57
+
58
+ provided = signature_header
59
+ if provided.startswith("sha256="):
60
+ provided = provided[len("sha256=") :]
61
+
62
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
63
+ signed = timestamp_header.encode("utf-8") + b"." + body
64
+ expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
65
+
66
+ return hmac.compare_digest(expected, provided)
@@ -0,0 +1,345 @@
1
+ """Behavioural tests for the Python client.
2
+
3
+ Every test drives a real httpx.MockTransport, so what is asserted is the request
4
+ the SDK actually built — the URL, the method, the key header, the body — plus
5
+ how it turns a response into a value or an exception. No mocking of our own
6
+ methods, which would only prove the test matches the test.
7
+ """
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ from whatsarab import DEFAULT_BASE_URL, WhatsArab, WhatsArabError, verify_webhook_signature
17
+
18
+
19
+ def make_client(handler, **kwargs):
20
+ transport = httpx.MockTransport(handler)
21
+ return WhatsArab(api_key="whap_test_secret", transport=transport, **kwargs)
22
+
23
+
24
+ def envelope(data, status=200):
25
+ return httpx.Response(status, json={"success": True, "data": data})
26
+
27
+
28
+ # ── requests ────────────────────────────────────────────────────────
29
+
30
+
31
+ def test_attaches_key_and_builds_url():
32
+ seen = {}
33
+
34
+ def handler(request):
35
+ seen["url"] = str(request.url)
36
+ seen["method"] = request.method
37
+ seen["key"] = request.headers.get("x-api-key")
38
+ seen["body"] = json.loads(request.content)
39
+ return envelope({"message_id": "wamid.X"})
40
+
41
+ client = make_client(handler)
42
+ client.messages.send(to="201234567890", text="مرحبا")
43
+
44
+ assert seen["url"] == f"{DEFAULT_BASE_URL}/api/messages"
45
+ assert seen["method"] == "POST"
46
+ assert seen["key"] == "whap_test_secret"
47
+ assert seen["body"] == {"to": "201234567890", "text": "مرحبا"}
48
+
49
+
50
+ def test_strips_trailing_slashes_from_base_url():
51
+ seen = {}
52
+
53
+ def handler(request):
54
+ seen["url"] = str(request.url)
55
+ return envelope([])
56
+
57
+ client = make_client(handler, base_url="https://staging.example.com///")
58
+ client.templates.list()
59
+
60
+ assert seen["url"] == "https://staging.example.com/api/templates"
61
+
62
+
63
+ def test_unwraps_the_envelope():
64
+ client = make_client(lambda r: envelope({"request_id": "req_1", "expires_in": 300}))
65
+ result = client.otp.send(to="201234567890", template="otp_login")
66
+ assert result["request_id"] == "req_1"
67
+ assert result["expires_in"] == 300
68
+
69
+
70
+ def test_error_envelope_becomes_whatsaraberror():
71
+ def handler(request):
72
+ return httpx.Response(
73
+ 400,
74
+ json={
75
+ "success": False,
76
+ "request_id": "req_err",
77
+ "error": {"code": "OUTSIDE_CSW", "message": "Outside the 24-hour window."},
78
+ },
79
+ )
80
+
81
+ client = make_client(handler)
82
+ with pytest.raises(WhatsArabError) as excinfo:
83
+ client.messages.send(to="201234567890", text="hi")
84
+
85
+ err = excinfo.value
86
+ assert err.code == "OUTSIDE_CSW"
87
+ assert err.status == 400
88
+ assert err.request_id == "req_err"
89
+ assert "24-hour" in str(err)
90
+
91
+
92
+ def test_200_with_success_false_is_still_a_failure():
93
+ # The envelope is the authority, not the status line. A client that trusted
94
+ # the status would return None here and call it a send.
95
+ def handler(request):
96
+ return httpx.Response(200, json={"success": False, "error": {"code": "OVER_QUOTA"}})
97
+
98
+ client = make_client(handler)
99
+ with pytest.raises(WhatsArabError) as excinfo:
100
+ client.templates.list()
101
+ assert excinfo.value.code == "OVER_QUOTA"
102
+
103
+
104
+ def test_transport_failure_is_the_same_error_type():
105
+ def handler(request):
106
+ raise httpx.ConnectError("connection refused")
107
+
108
+ client = make_client(handler)
109
+ with pytest.raises(WhatsArabError) as excinfo:
110
+ client.events.list()
111
+ assert excinfo.value.code == "NETWORK_ERROR"
112
+ assert excinfo.value.status == 0
113
+
114
+
115
+ def test_timeout_is_reported_as_timeout():
116
+ def handler(request):
117
+ raise httpx.ReadTimeout("timed out")
118
+
119
+ client = make_client(handler)
120
+ with pytest.raises(WhatsArabError) as excinfo:
121
+ client.events.list()
122
+ assert excinfo.value.code == "TIMEOUT"
123
+
124
+
125
+ def test_non_json_error_body_still_raises_with_a_code():
126
+ def handler(request):
127
+ return httpx.Response(502, text="<html>bad gateway</html>")
128
+
129
+ client = make_client(handler)
130
+ with pytest.raises(WhatsArabError) as excinfo:
131
+ client.templates.list()
132
+ assert excinfo.value.code == "HTTP_502"
133
+ assert excinfo.value.status == 502
134
+
135
+
136
+ # ── resources ───────────────────────────────────────────────────────
137
+
138
+
139
+ def test_send_requires_exactly_one_kind():
140
+ client = make_client(lambda r: envelope({}))
141
+
142
+ with pytest.raises(ValueError, match="exactly one"):
143
+ client.messages.send(to="201234567890")
144
+ with pytest.raises(ValueError, match="exactly one"):
145
+ client.messages.send(to="201234567890", text="hi", template="order_shipped")
146
+
147
+
148
+ def test_send_passes_optional_fields_through_and_omits_the_rest():
149
+ seen = {}
150
+
151
+ def handler(request):
152
+ seen["body"] = json.loads(request.content)
153
+ return envelope({})
154
+
155
+ client = make_client(handler)
156
+ client.messages.send(
157
+ to="201234567890",
158
+ template="order_shipped",
159
+ language="ar",
160
+ components=[{"type": "body", "parameters": [{"type": "text", "text": "10428"}]}],
161
+ from_phone_number_id="113456789012345",
162
+ )
163
+
164
+ assert seen["body"]["template"] == "order_shipped"
165
+ assert seen["body"]["language"] == "ar"
166
+ assert seen["body"]["from_phone_number_id"] == "113456789012345"
167
+ # Unset options must not be sent as nulls — the API rejects a null dry_run.
168
+ assert "dry_run" not in seen["body"]
169
+ assert "text" not in seen["body"]
170
+
171
+
172
+ def test_path_segments_are_url_encoded():
173
+ # The case that matters is a RESERVED character, not a space: httpx encodes
174
+ # spaces on its own, so asserting on one would pass with the encoding
175
+ # removed. A slash does not get encoded for you — it silently becomes a
176
+ # path separator and addresses a different resource.
177
+ calls = []
178
+
179
+ def handler(request):
180
+ calls.append(str(request.url))
181
+ return httpx.Response(200, content=b"x")
182
+
183
+ client = make_client(handler)
184
+ client.media.download("../../secrets")
185
+
186
+ assert "%2F" in calls[0]
187
+ assert calls[0].endswith("/api/media/..%2F..%2Fsecrets")
188
+
189
+
190
+ def test_templates_edit_is_patch_on_id_and_delete_is_on_name():
191
+ calls = []
192
+
193
+ def handler(request):
194
+ calls.append((request.method, str(request.url)))
195
+ return envelope({})
196
+
197
+ client = make_client(handler)
198
+ client.templates.edit("12345", category="UTILITY")
199
+ client.templates.delete("order_confirmation")
200
+
201
+ assert calls[0][0] == "PATCH"
202
+ assert calls[0][1].endswith("/api/templates/12345")
203
+ assert calls[1][0] == "DELETE"
204
+ assert calls[1][1].endswith("/api/templates/order_confirmation")
205
+
206
+
207
+ def test_media_download_returns_raw_bytes():
208
+ payload = b"\xff\xd8\xff\xe0jpeg-ish"
209
+
210
+ def handler(request):
211
+ return httpx.Response(200, content=payload)
212
+
213
+ client = make_client(handler)
214
+ assert client.media.download("media_1") == payload
215
+
216
+
217
+ def test_media_download_raises_on_an_error_body():
218
+ def handler(request):
219
+ return httpx.Response(404, json={"success": False, "error": {"code": "NOT_FOUND"}})
220
+
221
+ client = make_client(handler)
222
+ with pytest.raises(WhatsArabError) as excinfo:
223
+ client.media.download("nope")
224
+ assert excinfo.value.code == "NOT_FOUND"
225
+
226
+
227
+ def test_media_upload_is_multipart():
228
+ seen = {}
229
+
230
+ def handler(request):
231
+ seen["content_type"] = request.headers.get("content-type", "")
232
+ seen["body"] = request.content
233
+ return envelope({"media_id": "m_1"})
234
+
235
+ client = make_client(handler)
236
+ result = client.media.upload(b"%PDF-1.4", filename="invoice.pdf", content_type="application/pdf")
237
+
238
+ assert result["media_id"] == "m_1"
239
+ assert seen["content_type"].startswith("multipart/form-data")
240
+ assert b"invoice.pdf" in seen["body"]
241
+
242
+
243
+ def test_events_list_sends_the_limit_as_a_query_param():
244
+ seen = {}
245
+
246
+ def handler(request):
247
+ seen["url"] = str(request.url)
248
+ return envelope([])
249
+
250
+ client = make_client(handler)
251
+ client.events.list(limit=5)
252
+
253
+ assert "limit=5" in seen["url"]
254
+
255
+
256
+ def test_rejects_construction_without_a_key():
257
+ with pytest.raises(ValueError, match="api_key is required"):
258
+ WhatsArab(api_key="")
259
+
260
+
261
+ def test_works_as_a_context_manager():
262
+ with make_client(lambda r: envelope([])) as client:
263
+ assert client.templates.list() == []
264
+
265
+
266
+ # ── webhook signature ───────────────────────────────────────────────
267
+
268
+ SECRET = "whsec_test"
269
+ NOW = 1700000000.0
270
+
271
+
272
+ def sign(body, timestamp, secret=SECRET):
273
+ body_bytes = body.encode("utf-8") if isinstance(body, str) else body
274
+ signed = str(timestamp).encode("utf-8") + b"." + body_bytes
275
+ digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
276
+ return "sha256=" + digest
277
+
278
+
279
+ def test_accepts_a_correctly_signed_delivery():
280
+ body = '{"event":"message.delivered"}'
281
+ assert verify_webhook_signature(
282
+ secret=SECRET,
283
+ raw_body=body,
284
+ signature_header=sign(body, "1700000000"),
285
+ timestamp_header="1700000000",
286
+ now=lambda: NOW,
287
+ )
288
+
289
+
290
+ def test_rejects_a_body_that_changed_by_one_character():
291
+ assert not verify_webhook_signature(
292
+ secret=SECRET,
293
+ raw_body='{"amount":99}',
294
+ signature_header=sign('{"amount":10}', "1700000000"),
295
+ timestamp_header="1700000000",
296
+ now=lambda: NOW,
297
+ )
298
+
299
+
300
+ def test_rejects_a_signature_from_a_different_secret():
301
+ body = '{"a":1}'
302
+ assert not verify_webhook_signature(
303
+ secret=SECRET,
304
+ raw_body=body,
305
+ signature_header=sign(body, "1700000000", secret="other_secret"),
306
+ timestamp_header="1700000000",
307
+ now=lambda: NOW,
308
+ )
309
+
310
+
311
+ def test_rejects_a_replay_outside_the_tolerance_but_allows_it_when_disabled():
312
+ body = '{"a":1}'
313
+ args = dict(
314
+ secret=SECRET,
315
+ raw_body=body,
316
+ signature_header=sign(body, "1700000000"),
317
+ timestamp_header="1700000000",
318
+ )
319
+ # The bytes stay valid forever; only the clock moves.
320
+ assert not verify_webhook_signature(**args, now=lambda: NOW + 3600)
321
+ assert verify_webhook_signature(**args, tolerance_seconds=0, now=lambda: NOW + 3600)
322
+
323
+
324
+ def test_rejects_missing_or_malformed_headers_without_raising():
325
+ base = dict(secret=SECRET, raw_body="{}", now=lambda: NOW)
326
+ assert not verify_webhook_signature(**base, signature_header=None, timestamp_header="1700000000")
327
+ assert not verify_webhook_signature(**base, signature_header="sha256=aa", timestamp_header=None)
328
+ assert not verify_webhook_signature(**base, signature_header="sha256=aa", timestamp_header="not-a-number")
329
+ assert not verify_webhook_signature(**base, signature_header="sha256=short", timestamp_header="1700000000")
330
+
331
+
332
+ def test_accepts_the_signature_with_or_without_the_prefix():
333
+ body = '{"a":1}'
334
+ with_prefix = sign(body, "1700000000")
335
+ base = dict(secret=SECRET, raw_body=body, timestamp_header="1700000000", now=lambda: NOW)
336
+ assert verify_webhook_signature(**base, signature_header=with_prefix)
337
+ assert verify_webhook_signature(**base, signature_header=with_prefix[len("sha256=") :])
338
+
339
+
340
+ def test_bytes_and_str_bodies_agree():
341
+ body = '{"ar":"أهلًا"}'
342
+ signature = sign(body, "1700000000")
343
+ base = dict(secret=SECRET, signature_header=signature, timestamp_header="1700000000", now=lambda: NOW)
344
+ assert verify_webhook_signature(**base, raw_body=body)
345
+ assert verify_webhook_signature(**base, raw_body=body.encode("utf-8"))