hey-pingr 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,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: hey-pingr
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Pingr WhatsApp messaging API
5
+ Author-email: Pingr <support@heypingr.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://heypingr.com
8
+ Project-URL: Docs, https://heypingr.com/docs
9
+ Project-URL: Repository, https://github.com/heypingr/pingr-python
10
+ Keywords: pingr,whatsapp,messaging,otp,api,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: async
24
+ Requires-Dist: httpx>=0.25; extra == "async"
25
+ Provides-Extra: dev
26
+ Requires-Dist: httpx>=0.25; extra == "dev"
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
29
+
30
+ # pingr
31
+
32
+ Official Python SDK for the [Pingr](https://heypingr.com) WhatsApp messaging API.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install hey-pingr
38
+ ```
39
+
40
+ For async support (uses `httpx`):
41
+
42
+ ```bash
43
+ pip install hey-pingr[async]
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ import pingr
50
+
51
+ client = pingr.Pingr("pk_live_your_key_here")
52
+
53
+ result = client.messages.send(
54
+ to="919876543210", # digits only, with country code
55
+ message="Your OTP is 482910. Valid for 10 minutes.",
56
+ )
57
+
58
+ print(result["message_id"]) # msg_abc123
59
+ print(result["rate_limit_remaining"]) # 499
60
+ ```
61
+
62
+ ## Async usage
63
+
64
+ ```python
65
+ import asyncio
66
+ import pingr
67
+
68
+ async def main():
69
+ async with pingr.AsyncPingr("pk_live_your_key_here") as client:
70
+ result = await client.messages.send(
71
+ to="919876543210",
72
+ message="Your OTP is 482910",
73
+ )
74
+ print(result["message_id"])
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+ ## Verifying webhooks
80
+
81
+ ```python
82
+ from flask import Flask, request
83
+ from pingr import verify_webhook, PingrWebhookError
84
+ import os
85
+
86
+ app = Flask(__name__)
87
+
88
+ @app.route("/webhook", methods=["POST"])
89
+ def webhook():
90
+ try:
91
+ payload = verify_webhook(
92
+ request.get_data(),
93
+ request.headers["x-pingr-signature"],
94
+ os.environ["PINGR_WEBHOOK_SECRET"],
95
+ )
96
+ print(payload["event"], payload["session_id"])
97
+ return "", 200
98
+ except PingrWebhookError as e:
99
+ print("Invalid webhook:", e)
100
+ return "", 400
101
+ ```
102
+
103
+ ## Error handling
104
+
105
+ ```python
106
+ from pingr import Pingr, PingrRateLimitError, PingrAuthError, PingrError
107
+
108
+ client = Pingr("pk_live_...")
109
+
110
+ try:
111
+ client.messages.send(to="919...", message="Hello")
112
+ except PingrRateLimitError as e:
113
+ print(f"Rate limited. Retry in {e.retry_after}s")
114
+ except PingrAuthError:
115
+ print("Invalid API key")
116
+ except PingrError as e:
117
+ print(f"Error {e.code}: {e.message}")
118
+ ```
119
+
120
+ ## Test mode
121
+
122
+ Use a `pk_test_` key to exercise the API without sending real messages.
123
+ The response is identical to live mode — ideal for unit tests and CI.
124
+
125
+ ## API reference
126
+
127
+ ### `Pingr(api_key, *, base_url=..., timeout=15.0)`
128
+
129
+ ### `client.messages.send(*, to, message, session_id=None)`
130
+
131
+ | Param | Type | Required | Description |
132
+ |--------------|------|----------|---------------------------------------------------|
133
+ | `to` | str | ✓ | Recipient phone — digits + country code |
134
+ | `message` | str | ✓ | Text to send |
135
+ | `session_id` | str | | Specific session (auto-picks connected if omitted)|
136
+
137
+ Returns a dict with: `success`, `message_id`, `to`, `session_id`, `rate_limit_remaining`.
138
+
139
+ ### `verify_webhook(raw_body, signature, secret, *, check_timestamp=True)`
140
+
141
+ Verifies `x-pingr-signature` and returns the parsed payload dict.
142
+ Raises `PingrWebhookError` on failure.
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,117 @@
1
+ # pingr
2
+
3
+ Official Python SDK for the [Pingr](https://heypingr.com) WhatsApp messaging API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hey-pingr
9
+ ```
10
+
11
+ For async support (uses `httpx`):
12
+
13
+ ```bash
14
+ pip install hey-pingr[async]
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ import pingr
21
+
22
+ client = pingr.Pingr("pk_live_your_key_here")
23
+
24
+ result = client.messages.send(
25
+ to="919876543210", # digits only, with country code
26
+ message="Your OTP is 482910. Valid for 10 minutes.",
27
+ )
28
+
29
+ print(result["message_id"]) # msg_abc123
30
+ print(result["rate_limit_remaining"]) # 499
31
+ ```
32
+
33
+ ## Async usage
34
+
35
+ ```python
36
+ import asyncio
37
+ import pingr
38
+
39
+ async def main():
40
+ async with pingr.AsyncPingr("pk_live_your_key_here") as client:
41
+ result = await client.messages.send(
42
+ to="919876543210",
43
+ message="Your OTP is 482910",
44
+ )
45
+ print(result["message_id"])
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## Verifying webhooks
51
+
52
+ ```python
53
+ from flask import Flask, request
54
+ from pingr import verify_webhook, PingrWebhookError
55
+ import os
56
+
57
+ app = Flask(__name__)
58
+
59
+ @app.route("/webhook", methods=["POST"])
60
+ def webhook():
61
+ try:
62
+ payload = verify_webhook(
63
+ request.get_data(),
64
+ request.headers["x-pingr-signature"],
65
+ os.environ["PINGR_WEBHOOK_SECRET"],
66
+ )
67
+ print(payload["event"], payload["session_id"])
68
+ return "", 200
69
+ except PingrWebhookError as e:
70
+ print("Invalid webhook:", e)
71
+ return "", 400
72
+ ```
73
+
74
+ ## Error handling
75
+
76
+ ```python
77
+ from pingr import Pingr, PingrRateLimitError, PingrAuthError, PingrError
78
+
79
+ client = Pingr("pk_live_...")
80
+
81
+ try:
82
+ client.messages.send(to="919...", message="Hello")
83
+ except PingrRateLimitError as e:
84
+ print(f"Rate limited. Retry in {e.retry_after}s")
85
+ except PingrAuthError:
86
+ print("Invalid API key")
87
+ except PingrError as e:
88
+ print(f"Error {e.code}: {e.message}")
89
+ ```
90
+
91
+ ## Test mode
92
+
93
+ Use a `pk_test_` key to exercise the API without sending real messages.
94
+ The response is identical to live mode — ideal for unit tests and CI.
95
+
96
+ ## API reference
97
+
98
+ ### `Pingr(api_key, *, base_url=..., timeout=15.0)`
99
+
100
+ ### `client.messages.send(*, to, message, session_id=None)`
101
+
102
+ | Param | Type | Required | Description |
103
+ |--------------|------|----------|---------------------------------------------------|
104
+ | `to` | str | ✓ | Recipient phone — digits + country code |
105
+ | `message` | str | ✓ | Text to send |
106
+ | `session_id` | str | | Specific session (auto-picks connected if omitted)|
107
+
108
+ Returns a dict with: `success`, `message_id`, `to`, `session_id`, `rate_limit_remaining`.
109
+
110
+ ### `verify_webhook(raw_body, signature, secret, *, check_timestamp=True)`
111
+
112
+ Verifies `x-pingr-signature` and returns the parsed payload dict.
113
+ Raises `PingrWebhookError` on failure.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: hey-pingr
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Pingr WhatsApp messaging API
5
+ Author-email: Pingr <support@heypingr.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://heypingr.com
8
+ Project-URL: Docs, https://heypingr.com/docs
9
+ Project-URL: Repository, https://github.com/heypingr/pingr-python
10
+ Keywords: pingr,whatsapp,messaging,otp,api,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Provides-Extra: async
24
+ Requires-Dist: httpx>=0.25; extra == "async"
25
+ Provides-Extra: dev
26
+ Requires-Dist: httpx>=0.25; extra == "dev"
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pytest-asyncio; extra == "dev"
29
+
30
+ # pingr
31
+
32
+ Official Python SDK for the [Pingr](https://heypingr.com) WhatsApp messaging API.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install hey-pingr
38
+ ```
39
+
40
+ For async support (uses `httpx`):
41
+
42
+ ```bash
43
+ pip install hey-pingr[async]
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ ```python
49
+ import pingr
50
+
51
+ client = pingr.Pingr("pk_live_your_key_here")
52
+
53
+ result = client.messages.send(
54
+ to="919876543210", # digits only, with country code
55
+ message="Your OTP is 482910. Valid for 10 minutes.",
56
+ )
57
+
58
+ print(result["message_id"]) # msg_abc123
59
+ print(result["rate_limit_remaining"]) # 499
60
+ ```
61
+
62
+ ## Async usage
63
+
64
+ ```python
65
+ import asyncio
66
+ import pingr
67
+
68
+ async def main():
69
+ async with pingr.AsyncPingr("pk_live_your_key_here") as client:
70
+ result = await client.messages.send(
71
+ to="919876543210",
72
+ message="Your OTP is 482910",
73
+ )
74
+ print(result["message_id"])
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+ ## Verifying webhooks
80
+
81
+ ```python
82
+ from flask import Flask, request
83
+ from pingr import verify_webhook, PingrWebhookError
84
+ import os
85
+
86
+ app = Flask(__name__)
87
+
88
+ @app.route("/webhook", methods=["POST"])
89
+ def webhook():
90
+ try:
91
+ payload = verify_webhook(
92
+ request.get_data(),
93
+ request.headers["x-pingr-signature"],
94
+ os.environ["PINGR_WEBHOOK_SECRET"],
95
+ )
96
+ print(payload["event"], payload["session_id"])
97
+ return "", 200
98
+ except PingrWebhookError as e:
99
+ print("Invalid webhook:", e)
100
+ return "", 400
101
+ ```
102
+
103
+ ## Error handling
104
+
105
+ ```python
106
+ from pingr import Pingr, PingrRateLimitError, PingrAuthError, PingrError
107
+
108
+ client = Pingr("pk_live_...")
109
+
110
+ try:
111
+ client.messages.send(to="919...", message="Hello")
112
+ except PingrRateLimitError as e:
113
+ print(f"Rate limited. Retry in {e.retry_after}s")
114
+ except PingrAuthError:
115
+ print("Invalid API key")
116
+ except PingrError as e:
117
+ print(f"Error {e.code}: {e.message}")
118
+ ```
119
+
120
+ ## Test mode
121
+
122
+ Use a `pk_test_` key to exercise the API without sending real messages.
123
+ The response is identical to live mode — ideal for unit tests and CI.
124
+
125
+ ## API reference
126
+
127
+ ### `Pingr(api_key, *, base_url=..., timeout=15.0)`
128
+
129
+ ### `client.messages.send(*, to, message, session_id=None)`
130
+
131
+ | Param | Type | Required | Description |
132
+ |--------------|------|----------|---------------------------------------------------|
133
+ | `to` | str | ✓ | Recipient phone — digits + country code |
134
+ | `message` | str | ✓ | Text to send |
135
+ | `session_id` | str | | Specific session (auto-picks connected if omitted)|
136
+
137
+ Returns a dict with: `success`, `message_id`, `to`, `session_id`, `rate_limit_remaining`.
138
+
139
+ ### `verify_webhook(raw_body, signature, secret, *, check_timestamp=True)`
140
+
141
+ Verifies `x-pingr-signature` and returns the parsed payload dict.
142
+ Raises `PingrWebhookError` on failure.
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ hey_pingr.egg-info/PKG-INFO
4
+ hey_pingr.egg-info/SOURCES.txt
5
+ hey_pingr.egg-info/dependency_links.txt
6
+ hey_pingr.egg-info/requires.txt
7
+ hey_pingr.egg-info/top_level.txt
8
+ pingr/__init__.py
9
+ pingr/client.py
10
+ pingr/errors.py
11
+ pingr/webhook.py
@@ -0,0 +1,8 @@
1
+
2
+ [async]
3
+ httpx>=0.25
4
+
5
+ [dev]
6
+ httpx>=0.25
7
+ pytest
8
+ pytest-asyncio
@@ -0,0 +1 @@
1
+ pingr
@@ -0,0 +1,24 @@
1
+ """Pingr — Official Python SDK for the Pingr WhatsApp messaging API."""
2
+
3
+ from .client import AsyncPingr, Messages, Pingr
4
+ from .errors import (
5
+ PingrAuthError,
6
+ PingrError,
7
+ PingrRateLimitError,
8
+ PingrValidationError,
9
+ PingrWebhookError,
10
+ )
11
+ from .webhook import verify_webhook
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "Pingr",
16
+ "AsyncPingr",
17
+ "Messages",
18
+ "verify_webhook",
19
+ "PingrError",
20
+ "PingrAuthError",
21
+ "PingrRateLimitError",
22
+ "PingrValidationError",
23
+ "PingrWebhookError",
24
+ ]
@@ -0,0 +1,275 @@
1
+ """Pingr API client — sync and async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from .errors import PingrAuthError, PingrError, PingrRateLimitError, PingrValidationError
12
+ from .webhook import verify_webhook
13
+
14
+ _DEFAULT_BASE_URL = "https://pingr-0bj5.onrender.com"
15
+ _SDK_VERSION = "0.1.0"
16
+
17
+
18
+ def _parse_error(status: int, body: bytes, headers: Any) -> PingrError:
19
+ try:
20
+ data = json.loads(body)
21
+ detail = data.get("detail", {})
22
+ message = detail if isinstance(detail, str) else detail.get("message", "Unknown error")
23
+ code = None if isinstance(detail, str) else detail.get("error")
24
+ except Exception:
25
+ message = body.decode(errors="replace") or "Unknown error"
26
+ code = None
27
+
28
+ if status == 401:
29
+ return PingrAuthError(message)
30
+ if status == 422:
31
+ return PingrValidationError(message)
32
+ if status == 429:
33
+ retry_after = None
34
+ try:
35
+ retry_after = int(headers.get("Retry-After") or 0) or None
36
+ except (TypeError, ValueError):
37
+ pass
38
+ return PingrRateLimitError(message, code or "rate_limit", retry_after)
39
+ return PingrError(message, code or "unknown_error", status)
40
+
41
+
42
+ class Messages:
43
+ def __init__(self, client: "Pingr") -> None:
44
+ self._client = client
45
+
46
+ def send(self, *, to: str, message: str, session_id: str | None = None) -> dict[str, Any]:
47
+ """Send a WhatsApp message.
48
+
49
+ Args:
50
+ to: Recipient phone number — digits only with country code.
51
+ e.g. ``"919876543210"``
52
+ message: Text to send.
53
+ session_id: Specific session to use. Auto-picks any connected session if omitted.
54
+
55
+ Returns:
56
+ dict with keys: ``success``, ``message_id``, ``to``,
57
+ ``session_id``, ``rate_limit_remaining``.
58
+
59
+ Raises:
60
+ PingrValidationError: Missing or invalid parameters.
61
+ PingrAuthError: Invalid API key.
62
+ PingrRateLimitError: Quota exceeded or number in cooldown.
63
+ PingrError: Any other API error.
64
+
65
+ Example::
66
+
67
+ result = client.messages.send(
68
+ to="919876543210",
69
+ message="Your OTP is 482910",
70
+ )
71
+ print(result["message_id"])
72
+ """
73
+ if not to:
74
+ raise PingrValidationError('"to" is required')
75
+ if not message:
76
+ raise PingrValidationError('"message" is required')
77
+
78
+ digits = re.sub(r"\D", "", str(to))
79
+ if not digits:
80
+ raise PingrValidationError('"to" must contain at least one digit')
81
+
82
+ body: dict[str, Any] = {"to": digits, "message": message}
83
+ if session_id:
84
+ body["session_id"] = session_id
85
+
86
+ return self._client._request("POST", "/v1/send", body)
87
+
88
+
89
+ class Pingr:
90
+ """Synchronous Pingr client.
91
+
92
+ Args:
93
+ api_key: Your Pingr API key (``pk_live_...`` or ``pk_test_...``).
94
+ base_url: Override the API base URL (useful for self-hosted deployments).
95
+ timeout: Request timeout in seconds. Default: 15.
96
+
97
+ Example::
98
+
99
+ import pingr
100
+
101
+ client = pingr.Pingr("pk_live_your_key_here")
102
+ result = client.messages.send(to="919876543210", message="Hello!")
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ api_key: str,
108
+ *,
109
+ base_url: str = _DEFAULT_BASE_URL,
110
+ timeout: float = 15.0,
111
+ ) -> None:
112
+ if not api_key or not isinstance(api_key, str):
113
+ raise PingrError("API key is required", "missing_api_key")
114
+ if not (api_key.startswith("pk_live_") or api_key.startswith("pk_test_")):
115
+ raise PingrError(
116
+ "Invalid API key format. Keys must start with pk_live_ or pk_test_",
117
+ "invalid_api_key",
118
+ )
119
+ self._api_key = api_key
120
+ self._base_url = base_url.rstrip("/")
121
+ self._timeout = timeout
122
+ self._is_test = api_key.startswith("pk_test_")
123
+
124
+ self.messages = Messages(self)
125
+
126
+ def verify_webhook(
127
+ self,
128
+ raw_body: bytes | str,
129
+ signature: str,
130
+ secret: str,
131
+ *,
132
+ check_timestamp: bool = True,
133
+ ) -> dict[str, Any]:
134
+ """Verify a webhook signature. Delegates to :func:`pingr.verify_webhook`."""
135
+ return verify_webhook(raw_body, signature, secret, check_timestamp=check_timestamp)
136
+
137
+ def _request(self, method: str, path: str, body: dict | None = None) -> dict[str, Any]:
138
+ url = self._base_url + path
139
+ payload = json.dumps(body).encode() if body else None
140
+
141
+ req = urllib.request.Request(
142
+ url,
143
+ data=payload,
144
+ method=method,
145
+ headers={
146
+ "X-API-Key" : self._api_key,
147
+ "Content-Type" : "application/json",
148
+ "User-Agent" : f"pingr-python/{_SDK_VERSION}",
149
+ },
150
+ )
151
+
152
+ try:
153
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
154
+ return json.loads(resp.read())
155
+ except urllib.error.HTTPError as exc:
156
+ raise _parse_error(exc.code, exc.read(), exc.headers) from exc
157
+ except OSError as exc:
158
+ raise PingrError(f"Network error: {exc}", "network_error") from exc
159
+
160
+
161
+ # ── Async client ──────────────────────────────────────────────────────────────
162
+
163
+ try:
164
+ import asyncio # noqa: F401 — only imported to confirm Python 3.7+
165
+
166
+ class AsyncMessages:
167
+ def __init__(self, client: "AsyncPingr") -> None:
168
+ self._client = client
169
+
170
+ async def send(
171
+ self,
172
+ *,
173
+ to: str,
174
+ message: str,
175
+ session_id: str | None = None,
176
+ ) -> dict[str, Any]:
177
+ """Async version of :meth:`Messages.send`."""
178
+ if not to:
179
+ raise PingrValidationError('"to" is required')
180
+ if not message:
181
+ raise PingrValidationError('"message" is required')
182
+ digits = re.sub(r"\D", "", str(to))
183
+ if not digits:
184
+ raise PingrValidationError('"to" must contain at least one digit')
185
+
186
+ body: dict[str, Any] = {"to": digits, "message": message}
187
+ if session_id:
188
+ body["session_id"] = session_id
189
+
190
+ return await self._client._request("POST", "/v1/send", body)
191
+
192
+ class AsyncPingr:
193
+ """Async Pingr client (requires ``httpx`` — install with ``pip install pingr[async]``).
194
+
195
+ Example::
196
+
197
+ async with pingr.AsyncPingr("pk_live_...") as client:
198
+ result = await client.messages.send(to="919...", message="Hello!")
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ api_key: str,
204
+ *,
205
+ base_url: str = _DEFAULT_BASE_URL,
206
+ timeout: float = 15.0,
207
+ ) -> None:
208
+ if not api_key or not isinstance(api_key, str):
209
+ raise PingrError("API key is required", "missing_api_key")
210
+ if not (api_key.startswith("pk_live_") or api_key.startswith("pk_test_")):
211
+ raise PingrError(
212
+ "Invalid API key format. Keys must start with pk_live_ or pk_test_",
213
+ "invalid_api_key",
214
+ )
215
+ self._api_key = api_key
216
+ self._base_url = base_url.rstrip("/")
217
+ self._timeout = timeout
218
+ self._httpx = None
219
+ self.messages = AsyncMessages(self)
220
+
221
+ async def __aenter__(self) -> "AsyncPingr":
222
+ try:
223
+ import httpx
224
+ except ImportError as exc:
225
+ raise ImportError(
226
+ "httpx is required for AsyncPingr. Install with: pip install pingr[async]"
227
+ ) from exc
228
+ self._httpx = httpx.AsyncClient(timeout=self._timeout)
229
+ return self
230
+
231
+ async def __aexit__(self, *_: Any) -> None:
232
+ if self._httpx:
233
+ await self._httpx.aclose()
234
+
235
+ def verify_webhook(
236
+ self,
237
+ raw_body: bytes | str,
238
+ signature: str,
239
+ secret: str,
240
+ *,
241
+ check_timestamp: bool = True,
242
+ ) -> dict[str, Any]:
243
+ return verify_webhook(raw_body, signature, secret, check_timestamp=check_timestamp)
244
+
245
+ async def _request(self, method: str, path: str, body: dict | None = None) -> dict[str, Any]:
246
+ if not self._httpx:
247
+ raise PingrError(
248
+ "Use AsyncPingr as a context manager: async with AsyncPingr(...) as client:",
249
+ "not_initialized",
250
+ )
251
+ try:
252
+ import httpx
253
+ except ImportError as exc:
254
+ raise ImportError("httpx is required for AsyncPingr. Install with: pip install pingr[async]") from exc
255
+
256
+ try:
257
+ resp = await self._httpx.request(
258
+ method,
259
+ self._base_url + path,
260
+ json=body,
261
+ headers={
262
+ "X-API-Key" : self._api_key,
263
+ "User-Agent" : f"pingr-python/{_SDK_VERSION}",
264
+ },
265
+ )
266
+ except httpx.RequestError as exc:
267
+ raise PingrError(f"Network error: {exc}", "network_error") from exc
268
+
269
+ if resp.is_success:
270
+ return resp.json()
271
+
272
+ raise _parse_error(resp.status_code, resp.content, resp.headers)
273
+
274
+ except ImportError:
275
+ pass
@@ -0,0 +1,40 @@
1
+ class PingrError(Exception):
2
+ """Base class for all Pingr SDK errors."""
3
+
4
+ def __init__(self, message: str, code: str = "unknown_error", status: int | None = None, retry_after: int | None = None):
5
+ super().__init__(message)
6
+ self.message = message
7
+ self.code = code
8
+ self.status = status
9
+ self.retry_after = retry_after
10
+
11
+ def __repr__(self) -> str:
12
+ return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
13
+
14
+
15
+ class PingrAuthError(PingrError):
16
+ """Raised when the API key is missing, malformed, or inactive."""
17
+
18
+ def __init__(self, message: str = "Invalid or inactive API key"):
19
+ super().__init__(message, code="auth_error", status=401)
20
+
21
+
22
+ class PingrRateLimitError(PingrError):
23
+ """Raised when the monthly quota or per-number cooldown is exceeded."""
24
+
25
+ def __init__(self, message: str, code: str = "rate_limit", retry_after: int | None = None):
26
+ super().__init__(message, code=code, status=429, retry_after=retry_after)
27
+
28
+
29
+ class PingrValidationError(PingrError):
30
+ """Raised when request parameters are invalid."""
31
+
32
+ def __init__(self, message: str):
33
+ super().__init__(message, code="validation_error", status=422)
34
+
35
+
36
+ class PingrWebhookError(PingrError):
37
+ """Raised when webhook signature verification fails."""
38
+
39
+ def __init__(self, message: str):
40
+ super().__init__(message, code="webhook_error")
@@ -0,0 +1,76 @@
1
+ """Webhook signature verification for Pingr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from typing import Any
10
+
11
+ from .errors import PingrWebhookError
12
+
13
+ _TOLERANCE_SECONDS = 5 * 60 # 5 minutes
14
+
15
+
16
+ def verify_webhook(
17
+ raw_body: bytes | str,
18
+ signature: str,
19
+ secret: str,
20
+ *,
21
+ check_timestamp: bool = True,
22
+ ) -> dict[str, Any]:
23
+ """Verify a Pingr webhook signature and return the parsed payload.
24
+
25
+ Pingr signs every webhook POST with HMAC-SHA256.
26
+ The signature arrives in the ``x-pingr-signature`` header as ``sha256=<hex>``.
27
+
28
+ Args:
29
+ raw_body: Raw request body bytes (do **not** JSON-decode first).
30
+ signature: Value of the ``x-pingr-signature`` header.
31
+ secret: Your webhook signing secret (``whsec_...``).
32
+ check_timestamp: If True (default), reject payloads older than 5 minutes.
33
+
34
+ Returns:
35
+ Parsed webhook payload as a dict.
36
+
37
+ Raises:
38
+ PingrWebhookError: If the signature is invalid or the timestamp is stale.
39
+
40
+ Example::
41
+
42
+ # Flask
43
+ @app.route('/webhook', methods=['POST'])
44
+ def webhook():
45
+ payload = verify_webhook(
46
+ request.get_data(),
47
+ request.headers['x-pingr-signature'],
48
+ os.environ['PINGR_WEBHOOK_SECRET'],
49
+ )
50
+ print(payload['event'])
51
+ return '', 200
52
+ """
53
+ if not signature or not signature.startswith("sha256="):
54
+ raise PingrWebhookError("Missing or malformed x-pingr-signature header")
55
+
56
+ body = raw_body if isinstance(raw_body, bytes) else raw_body.encode()
57
+ expected = "sha256=" + hmac.new(
58
+ secret.encode(), body, hashlib.sha256
59
+ ).hexdigest()
60
+
61
+ if not hmac.compare_digest(signature.encode(), expected.encode()):
62
+ raise PingrWebhookError("Webhook signature verification failed")
63
+
64
+ try:
65
+ payload = json.loads(body)
66
+ except json.JSONDecodeError as exc:
67
+ raise PingrWebhookError(f"Webhook body is not valid JSON: {exc}") from exc
68
+
69
+ if check_timestamp:
70
+ ts = payload.get("timestamp")
71
+ if ts is None or abs(time.time() * 1000 - ts) > _TOLERANCE_SECONDS * 1000:
72
+ raise PingrWebhookError(
73
+ "Webhook timestamp is too old or missing — possible replay attack"
74
+ )
75
+
76
+ return payload
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hey-pingr"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Pingr WhatsApp messaging API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Pingr", email = "support@heypingr.com" }]
12
+ requires-python = ">=3.9"
13
+ keywords = ["pingr", "whatsapp", "messaging", "otp", "api", "sdk"]
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
+ "Topic :: Communications",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ async = ["httpx>=0.25"]
30
+ dev = ["httpx>=0.25", "pytest", "pytest-asyncio"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://heypingr.com"
34
+ Docs = "https://heypingr.com/docs"
35
+ Repository = "https://github.com/heypingr/pingr-python"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["."]
39
+ include = ["pingr*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+