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/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}"