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.
- hey_pingr-0.1.0/PKG-INFO +146 -0
- hey_pingr-0.1.0/README.md +117 -0
- hey_pingr-0.1.0/hey_pingr.egg-info/PKG-INFO +146 -0
- hey_pingr-0.1.0/hey_pingr.egg-info/SOURCES.txt +11 -0
- hey_pingr-0.1.0/hey_pingr.egg-info/dependency_links.txt +1 -0
- hey_pingr-0.1.0/hey_pingr.egg-info/requires.txt +8 -0
- hey_pingr-0.1.0/hey_pingr.egg-info/top_level.txt +1 -0
- hey_pingr-0.1.0/pingr/__init__.py +24 -0
- hey_pingr-0.1.0/pingr/client.py +275 -0
- hey_pingr-0.1.0/pingr/errors.py +40 -0
- hey_pingr-0.1.0/pingr/webhook.py +76 -0
- hey_pingr-0.1.0/pyproject.toml +39 -0
- hey_pingr-0.1.0/setup.cfg +4 -0
hey_pingr-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|