sendly 3.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sendly/__init__.py +165 -0
- sendly/client.py +248 -0
- sendly/errors.py +169 -0
- sendly/resources/__init__.py +5 -0
- sendly/resources/account.py +264 -0
- sendly/resources/messages.py +1087 -0
- sendly/resources/webhooks.py +435 -0
- sendly/types.py +748 -0
- sendly/utils/__init__.py +26 -0
- sendly/utils/http.py +358 -0
- sendly/utils/validation.py +248 -0
- sendly/webhooks.py +245 -0
- sendly-3.8.1.dist-info/METADATA +589 -0
- sendly-3.8.1.dist-info/RECORD +15 -0
- sendly-3.8.1.dist-info/WHEEL +4 -0
sendly/webhooks.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sendly Webhook Helpers
|
|
3
|
+
|
|
4
|
+
Utilities for verifying and parsing webhook events from Sendly.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from sendly import Webhooks
|
|
8
|
+
>>>
|
|
9
|
+
>>> # In your webhook handler (e.g., Flask)
|
|
10
|
+
>>> @app.route('/webhooks/sendly', methods=['POST'])
|
|
11
|
+
>>> def handle_webhook():
|
|
12
|
+
... signature = request.headers.get('X-Sendly-Signature')
|
|
13
|
+
... payload = request.get_data(as_text=True)
|
|
14
|
+
...
|
|
15
|
+
... try:
|
|
16
|
+
... event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET)
|
|
17
|
+
... print(f'Received event: {event.type}')
|
|
18
|
+
...
|
|
19
|
+
... if event.type == 'message.delivered':
|
|
20
|
+
... print(f'Message {event.data.message_id} delivered!')
|
|
21
|
+
... elif event.type == 'message.failed':
|
|
22
|
+
... print(f'Message {event.data.message_id} failed: {event.data.error}')
|
|
23
|
+
...
|
|
24
|
+
... return 'OK', 200
|
|
25
|
+
... except WebhookSignatureError:
|
|
26
|
+
... return 'Invalid signature', 401
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import hmac
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Literal, Optional
|
|
34
|
+
|
|
35
|
+
# Webhook event types
|
|
36
|
+
WebhookEventType = Literal[
|
|
37
|
+
"message.queued",
|
|
38
|
+
"message.sent",
|
|
39
|
+
"message.delivered",
|
|
40
|
+
"message.failed",
|
|
41
|
+
"message.undelivered",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Message status in webhook events
|
|
45
|
+
WebhookMessageStatus = Literal[
|
|
46
|
+
"queued",
|
|
47
|
+
"sent",
|
|
48
|
+
"delivered",
|
|
49
|
+
"failed",
|
|
50
|
+
"undelivered",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class WebhookMessageData:
|
|
56
|
+
"""Data payload for message webhook events."""
|
|
57
|
+
|
|
58
|
+
message_id: str
|
|
59
|
+
"""The message ID."""
|
|
60
|
+
|
|
61
|
+
status: WebhookMessageStatus
|
|
62
|
+
"""Current message status."""
|
|
63
|
+
|
|
64
|
+
to: str
|
|
65
|
+
"""Recipient phone number."""
|
|
66
|
+
|
|
67
|
+
from_: str
|
|
68
|
+
"""Sender ID or phone number."""
|
|
69
|
+
|
|
70
|
+
segments: int
|
|
71
|
+
"""Number of SMS segments."""
|
|
72
|
+
|
|
73
|
+
credits_used: int
|
|
74
|
+
"""Credits charged."""
|
|
75
|
+
|
|
76
|
+
error: Optional[str] = None
|
|
77
|
+
"""Error message if status is 'failed' or 'undelivered'."""
|
|
78
|
+
|
|
79
|
+
error_code: Optional[str] = None
|
|
80
|
+
"""Error code if available."""
|
|
81
|
+
|
|
82
|
+
delivered_at: Optional[str] = None
|
|
83
|
+
"""When the message was delivered (ISO 8601)."""
|
|
84
|
+
|
|
85
|
+
failed_at: Optional[str] = None
|
|
86
|
+
"""When the message failed (ISO 8601)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class WebhookEvent:
|
|
91
|
+
"""Webhook event from Sendly."""
|
|
92
|
+
|
|
93
|
+
id: str
|
|
94
|
+
"""Unique event ID."""
|
|
95
|
+
|
|
96
|
+
type: WebhookEventType
|
|
97
|
+
"""Event type."""
|
|
98
|
+
|
|
99
|
+
data: WebhookMessageData
|
|
100
|
+
"""Event data."""
|
|
101
|
+
|
|
102
|
+
created_at: str
|
|
103
|
+
"""When the event was created (ISO 8601)."""
|
|
104
|
+
|
|
105
|
+
api_version: str = "2024-01-01"
|
|
106
|
+
"""API version."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class WebhookSignatureError(Exception):
|
|
110
|
+
"""Error thrown when webhook signature verification fails."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str = "Invalid webhook signature"):
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
self.message = message
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Webhooks:
|
|
118
|
+
"""Webhook utilities for verifying and parsing Sendly webhook events."""
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def verify_signature(payload: str, signature: str, secret: str) -> bool:
|
|
122
|
+
"""
|
|
123
|
+
Verify webhook signature from Sendly.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
payload: Raw request body as string.
|
|
127
|
+
signature: X-Sendly-Signature header value.
|
|
128
|
+
secret: Your webhook secret from dashboard.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if signature is valid, False otherwise.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> is_valid = Webhooks.verify_signature(
|
|
135
|
+
... raw_body,
|
|
136
|
+
... request.headers['X-Sendly-Signature'],
|
|
137
|
+
... WEBHOOK_SECRET
|
|
138
|
+
... )
|
|
139
|
+
"""
|
|
140
|
+
if not payload or not signature or not secret:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
expected = hmac.new(
|
|
145
|
+
secret.encode("utf-8"),
|
|
146
|
+
payload.encode("utf-8"),
|
|
147
|
+
hashlib.sha256,
|
|
148
|
+
).hexdigest()
|
|
149
|
+
|
|
150
|
+
expected_signature = f"sha256={expected}"
|
|
151
|
+
|
|
152
|
+
# Use timing-safe comparison to prevent timing attacks
|
|
153
|
+
return hmac.compare_digest(signature, expected_signature)
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def parse_event(payload: str, signature: str, secret: str) -> WebhookEvent:
|
|
159
|
+
"""
|
|
160
|
+
Parse and validate a webhook event.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
payload: Raw request body as string.
|
|
164
|
+
signature: X-Sendly-Signature header value.
|
|
165
|
+
secret: Your webhook secret from dashboard.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Parsed and validated WebhookEvent.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
WebhookSignatureError: If signature is invalid or payload is malformed.
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> try:
|
|
175
|
+
... event = Webhooks.parse_event(raw_body, signature, secret)
|
|
176
|
+
... print(f'Event type: {event.type}')
|
|
177
|
+
... print(f'Message ID: {event.data.message_id}')
|
|
178
|
+
... except WebhookSignatureError:
|
|
179
|
+
... print('Invalid signature')
|
|
180
|
+
"""
|
|
181
|
+
if not Webhooks.verify_signature(payload, signature, secret):
|
|
182
|
+
raise WebhookSignatureError()
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
raw_event = json.loads(payload)
|
|
186
|
+
|
|
187
|
+
# Basic validation
|
|
188
|
+
if not all(key in raw_event for key in ("id", "type", "data", "created_at")):
|
|
189
|
+
raise ValueError("Invalid event structure")
|
|
190
|
+
|
|
191
|
+
# Parse data
|
|
192
|
+
raw_data = raw_event["data"]
|
|
193
|
+
data = WebhookMessageData(
|
|
194
|
+
message_id=raw_data["message_id"],
|
|
195
|
+
status=raw_data["status"],
|
|
196
|
+
to=raw_data["to"],
|
|
197
|
+
from_=raw_data.get("from", ""),
|
|
198
|
+
segments=raw_data.get("segments", 1),
|
|
199
|
+
credits_used=raw_data.get("credits_used", 0),
|
|
200
|
+
error=raw_data.get("error"),
|
|
201
|
+
error_code=raw_data.get("error_code"),
|
|
202
|
+
delivered_at=raw_data.get("delivered_at"),
|
|
203
|
+
failed_at=raw_data.get("failed_at"),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return WebhookEvent(
|
|
207
|
+
id=raw_event["id"],
|
|
208
|
+
type=raw_event["type"],
|
|
209
|
+
data=data,
|
|
210
|
+
created_at=raw_event["created_at"],
|
|
211
|
+
api_version=raw_event.get("api_version", "2024-01-01"),
|
|
212
|
+
)
|
|
213
|
+
except WebhookSignatureError:
|
|
214
|
+
raise
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise WebhookSignatureError(f"Failed to parse webhook payload: {e}")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def generate_signature(payload: str, secret: str) -> str:
|
|
220
|
+
"""
|
|
221
|
+
Generate a webhook signature for testing purposes.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
payload: The payload to sign.
|
|
225
|
+
secret: The secret to use for signing.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The signature in the format "sha256=...".
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> # For testing your webhook handler
|
|
232
|
+
>>> test_payload = json.dumps({
|
|
233
|
+
... 'id': 'evt_test',
|
|
234
|
+
... 'type': 'message.delivered',
|
|
235
|
+
... 'data': {'message_id': 'msg_test', 'status': 'delivered'},
|
|
236
|
+
... 'created_at': datetime.now().isoformat()
|
|
237
|
+
... })
|
|
238
|
+
>>> signature = Webhooks.generate_signature(test_payload, 'test_secret')
|
|
239
|
+
"""
|
|
240
|
+
hash_value = hmac.new(
|
|
241
|
+
secret.encode("utf-8"),
|
|
242
|
+
payload.encode("utf-8"),
|
|
243
|
+
hashlib.sha256,
|
|
244
|
+
).hexdigest()
|
|
245
|
+
return f"sha256={hash_value}"
|