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
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhooks Resource
|
|
3
|
+
|
|
4
|
+
Manage webhook endpoints for receiving real-time message status updates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from ..types import (
|
|
10
|
+
CreateWebhookOptions,
|
|
11
|
+
UpdateWebhookOptions,
|
|
12
|
+
Webhook,
|
|
13
|
+
WebhookCreatedResponse,
|
|
14
|
+
WebhookDelivery,
|
|
15
|
+
WebhookMode,
|
|
16
|
+
WebhookSecretRotation,
|
|
17
|
+
WebhookTestResult,
|
|
18
|
+
)
|
|
19
|
+
from ..utils.http import AsyncHttpClient, HttpClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _transform_webhook_response(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
"""Transform snake_case API response to camelCase for pydantic models."""
|
|
24
|
+
# Map snake_case keys to camelCase aliases
|
|
25
|
+
key_map = {
|
|
26
|
+
"is_active": "isActive",
|
|
27
|
+
"failure_count": "failureCount",
|
|
28
|
+
"last_failure_at": "lastFailureAt",
|
|
29
|
+
"circuit_state": "circuitState",
|
|
30
|
+
"circuit_opened_at": "circuitOpenedAt",
|
|
31
|
+
"api_version": "apiVersion",
|
|
32
|
+
"created_at": "createdAt",
|
|
33
|
+
"updated_at": "updatedAt",
|
|
34
|
+
"total_deliveries": "totalDeliveries",
|
|
35
|
+
"successful_deliveries": "successfulDeliveries",
|
|
36
|
+
"success_rate": "successRate",
|
|
37
|
+
"last_delivery_at": "lastDeliveryAt",
|
|
38
|
+
}
|
|
39
|
+
result = {}
|
|
40
|
+
for key, value in data.items():
|
|
41
|
+
new_key = key_map.get(key, key)
|
|
42
|
+
result[new_key] = value
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _transform_delivery_response(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
47
|
+
"""Transform snake_case API response for webhook delivery."""
|
|
48
|
+
key_map = {
|
|
49
|
+
"webhook_id": "webhookId",
|
|
50
|
+
"event_id": "eventId",
|
|
51
|
+
"event_type": "eventType",
|
|
52
|
+
"attempt_number": "attemptNumber",
|
|
53
|
+
"max_attempts": "maxAttempts",
|
|
54
|
+
"response_status_code": "responseStatusCode",
|
|
55
|
+
"response_time_ms": "responseTimeMs",
|
|
56
|
+
"error_message": "errorMessage",
|
|
57
|
+
"error_code": "errorCode",
|
|
58
|
+
"next_retry_at": "nextRetryAt",
|
|
59
|
+
"created_at": "createdAt",
|
|
60
|
+
"delivered_at": "deliveredAt",
|
|
61
|
+
}
|
|
62
|
+
result = {}
|
|
63
|
+
for key, value in data.items():
|
|
64
|
+
new_key = key_map.get(key, key)
|
|
65
|
+
result[new_key] = value
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WebhooksResource:
|
|
70
|
+
"""
|
|
71
|
+
Webhooks API resource (synchronous)
|
|
72
|
+
|
|
73
|
+
Manage webhook endpoints for receiving real-time message status updates.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> # Create a webhook
|
|
77
|
+
>>> webhook = client.webhooks.create(
|
|
78
|
+
... url='https://example.com/webhooks/sendly',
|
|
79
|
+
... events=['message.delivered', 'message.failed']
|
|
80
|
+
... )
|
|
81
|
+
>>> # IMPORTANT: Save the secret - it's only shown once!
|
|
82
|
+
>>> print(f'Secret: {webhook.secret}')
|
|
83
|
+
>>>
|
|
84
|
+
>>> # List webhooks
|
|
85
|
+
>>> webhooks = client.webhooks.list()
|
|
86
|
+
>>>
|
|
87
|
+
>>> # Test a webhook
|
|
88
|
+
>>> result = client.webhooks.test(webhook.id)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, http: HttpClient):
|
|
92
|
+
self._http = http
|
|
93
|
+
|
|
94
|
+
def create(
|
|
95
|
+
self,
|
|
96
|
+
url: str,
|
|
97
|
+
events: List[str],
|
|
98
|
+
description: Optional[str] = None,
|
|
99
|
+
mode: Optional[WebhookMode] = None,
|
|
100
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
101
|
+
) -> WebhookCreatedResponse:
|
|
102
|
+
"""
|
|
103
|
+
Create a new webhook endpoint.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
url: HTTPS endpoint URL
|
|
107
|
+
events: Event types to subscribe to
|
|
108
|
+
description: Optional description
|
|
109
|
+
mode: Event mode filter (all, test, live). Live requires verification.
|
|
110
|
+
metadata: Custom metadata
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The created webhook with signing secret (shown only once!)
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValidationError: If the URL is invalid or events are empty
|
|
117
|
+
AuthenticationError: If the API key is invalid
|
|
118
|
+
"""
|
|
119
|
+
if not url or not url.startswith("https://"):
|
|
120
|
+
raise ValueError("Webhook URL must be HTTPS")
|
|
121
|
+
|
|
122
|
+
if not events:
|
|
123
|
+
raise ValueError("At least one event type is required")
|
|
124
|
+
|
|
125
|
+
body = {"url": url, "events": events}
|
|
126
|
+
if description:
|
|
127
|
+
body["description"] = description
|
|
128
|
+
if mode:
|
|
129
|
+
body["mode"] = mode.value if isinstance(mode, WebhookMode) else mode
|
|
130
|
+
if metadata:
|
|
131
|
+
body["metadata"] = metadata
|
|
132
|
+
|
|
133
|
+
response = self._http.request("POST", "/webhooks", json=body)
|
|
134
|
+
return WebhookCreatedResponse(**_transform_webhook_response(response))
|
|
135
|
+
|
|
136
|
+
def list(self) -> List[Webhook]:
|
|
137
|
+
"""
|
|
138
|
+
List all webhooks.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Array of webhook configurations
|
|
142
|
+
"""
|
|
143
|
+
response = self._http.request("GET", "/webhooks")
|
|
144
|
+
return [Webhook(**_transform_webhook_response(w)) for w in response]
|
|
145
|
+
|
|
146
|
+
def get(self, webhook_id: str) -> Webhook:
|
|
147
|
+
"""
|
|
148
|
+
Get a specific webhook by ID.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
webhook_id: Webhook ID (whk_xxx)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The webhook details
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
NotFoundError: If the webhook doesn't exist
|
|
158
|
+
"""
|
|
159
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
160
|
+
raise ValueError("Invalid webhook ID format")
|
|
161
|
+
|
|
162
|
+
response = self._http.request("GET", f"/webhooks/{webhook_id}")
|
|
163
|
+
return Webhook(**_transform_webhook_response(response))
|
|
164
|
+
|
|
165
|
+
def update(
|
|
166
|
+
self,
|
|
167
|
+
webhook_id: str,
|
|
168
|
+
url: Optional[str] = None,
|
|
169
|
+
events: Optional[List[str]] = None,
|
|
170
|
+
description: Optional[str] = None,
|
|
171
|
+
is_active: Optional[bool] = None,
|
|
172
|
+
mode: Optional[WebhookMode] = None,
|
|
173
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
174
|
+
) -> Webhook:
|
|
175
|
+
"""
|
|
176
|
+
Update a webhook configuration.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
webhook_id: Webhook ID
|
|
180
|
+
url: New URL
|
|
181
|
+
events: New event subscriptions
|
|
182
|
+
description: New description
|
|
183
|
+
is_active: Enable/disable webhook
|
|
184
|
+
mode: Event mode filter (all, test, live)
|
|
185
|
+
metadata: Custom metadata
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The updated webhook
|
|
189
|
+
"""
|
|
190
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
191
|
+
raise ValueError("Invalid webhook ID format")
|
|
192
|
+
|
|
193
|
+
if url and not url.startswith("https://"):
|
|
194
|
+
raise ValueError("Webhook URL must be HTTPS")
|
|
195
|
+
|
|
196
|
+
body = {}
|
|
197
|
+
if url is not None:
|
|
198
|
+
body["url"] = url
|
|
199
|
+
if events is not None:
|
|
200
|
+
body["events"] = events
|
|
201
|
+
if description is not None:
|
|
202
|
+
body["description"] = description
|
|
203
|
+
if is_active is not None:
|
|
204
|
+
body["is_active"] = is_active
|
|
205
|
+
if mode is not None:
|
|
206
|
+
body["mode"] = mode.value if isinstance(mode, WebhookMode) else mode
|
|
207
|
+
if metadata is not None:
|
|
208
|
+
body["metadata"] = metadata
|
|
209
|
+
|
|
210
|
+
response = self._http.request("PATCH", f"/webhooks/{webhook_id}", json=body)
|
|
211
|
+
return Webhook(**_transform_webhook_response(response))
|
|
212
|
+
|
|
213
|
+
def delete(self, webhook_id: str) -> None:
|
|
214
|
+
"""
|
|
215
|
+
Delete a webhook.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
webhook_id: Webhook ID
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
NotFoundError: If the webhook doesn't exist
|
|
222
|
+
"""
|
|
223
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
224
|
+
raise ValueError("Invalid webhook ID format")
|
|
225
|
+
|
|
226
|
+
self._http.request("DELETE", f"/webhooks/{webhook_id}")
|
|
227
|
+
|
|
228
|
+
def test(self, webhook_id: str) -> WebhookTestResult:
|
|
229
|
+
"""
|
|
230
|
+
Send a test event to a webhook endpoint.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
webhook_id: Webhook ID
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Test result with response details
|
|
237
|
+
"""
|
|
238
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
239
|
+
raise ValueError("Invalid webhook ID format")
|
|
240
|
+
|
|
241
|
+
response = self._http.request("POST", f"/webhooks/{webhook_id}/test")
|
|
242
|
+
return WebhookTestResult(**response)
|
|
243
|
+
|
|
244
|
+
def rotate_secret(self, webhook_id: str) -> WebhookSecretRotation:
|
|
245
|
+
"""
|
|
246
|
+
Rotate the webhook signing secret.
|
|
247
|
+
|
|
248
|
+
The old secret remains valid for 24 hours to allow for graceful migration.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
webhook_id: Webhook ID
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
New secret and expiration info
|
|
255
|
+
"""
|
|
256
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
257
|
+
raise ValueError("Invalid webhook ID format")
|
|
258
|
+
|
|
259
|
+
response = self._http.request("POST", f"/webhooks/{webhook_id}/rotate-secret")
|
|
260
|
+
# Transform the nested webhook object
|
|
261
|
+
if "webhook" in response:
|
|
262
|
+
response["webhook"] = _transform_webhook_response(response["webhook"])
|
|
263
|
+
return WebhookSecretRotation(**response)
|
|
264
|
+
|
|
265
|
+
def get_deliveries(self, webhook_id: str) -> List[WebhookDelivery]:
|
|
266
|
+
"""
|
|
267
|
+
Get delivery history for a webhook.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
webhook_id: Webhook ID
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Array of delivery attempts
|
|
274
|
+
"""
|
|
275
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
276
|
+
raise ValueError("Invalid webhook ID format")
|
|
277
|
+
|
|
278
|
+
response = self._http.request("GET", f"/webhooks/{webhook_id}/deliveries")
|
|
279
|
+
return [WebhookDelivery(**_transform_delivery_response(d)) for d in response]
|
|
280
|
+
|
|
281
|
+
def retry_delivery(self, webhook_id: str, delivery_id: str) -> None:
|
|
282
|
+
"""
|
|
283
|
+
Retry a failed delivery.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
webhook_id: Webhook ID
|
|
287
|
+
delivery_id: Delivery ID
|
|
288
|
+
"""
|
|
289
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
290
|
+
raise ValueError("Invalid webhook ID format")
|
|
291
|
+
if not delivery_id or not delivery_id.startswith("del_"):
|
|
292
|
+
raise ValueError("Invalid delivery ID format")
|
|
293
|
+
|
|
294
|
+
self._http.request("POST", f"/webhooks/{webhook_id}/deliveries/{delivery_id}/retry")
|
|
295
|
+
|
|
296
|
+
def list_event_types(self) -> List[str]:
|
|
297
|
+
"""
|
|
298
|
+
List available event types.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Array of event type strings
|
|
302
|
+
"""
|
|
303
|
+
response = self._http.request("GET", "/webhooks/event-types")
|
|
304
|
+
return [e["type"] for e in response.get("events", [])]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class AsyncWebhooksResource:
|
|
308
|
+
"""
|
|
309
|
+
Webhooks API resource (asynchronous)
|
|
310
|
+
|
|
311
|
+
Async version of the webhooks resource for use with asyncio.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, http: AsyncHttpClient):
|
|
315
|
+
self._http = http
|
|
316
|
+
|
|
317
|
+
async def create(
|
|
318
|
+
self,
|
|
319
|
+
url: str,
|
|
320
|
+
events: List[str],
|
|
321
|
+
description: Optional[str] = None,
|
|
322
|
+
mode: Optional[WebhookMode] = None,
|
|
323
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
324
|
+
) -> WebhookCreatedResponse:
|
|
325
|
+
"""Create a new webhook endpoint."""
|
|
326
|
+
if not url or not url.startswith("https://"):
|
|
327
|
+
raise ValueError("Webhook URL must be HTTPS")
|
|
328
|
+
|
|
329
|
+
if not events:
|
|
330
|
+
raise ValueError("At least one event type is required")
|
|
331
|
+
|
|
332
|
+
body = {"url": url, "events": events}
|
|
333
|
+
if description:
|
|
334
|
+
body["description"] = description
|
|
335
|
+
if mode:
|
|
336
|
+
body["mode"] = mode.value if isinstance(mode, WebhookMode) else mode
|
|
337
|
+
if metadata:
|
|
338
|
+
body["metadata"] = metadata
|
|
339
|
+
|
|
340
|
+
response = await self._http.request("POST", "/webhooks", json=body)
|
|
341
|
+
return WebhookCreatedResponse(**_transform_webhook_response(response))
|
|
342
|
+
|
|
343
|
+
async def list(self) -> List[Webhook]:
|
|
344
|
+
"""List all webhooks."""
|
|
345
|
+
response = await self._http.request("GET", "/webhooks")
|
|
346
|
+
return [Webhook(**_transform_webhook_response(w)) for w in response]
|
|
347
|
+
|
|
348
|
+
async def get(self, webhook_id: str) -> Webhook:
|
|
349
|
+
"""Get a specific webhook by ID."""
|
|
350
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
351
|
+
raise ValueError("Invalid webhook ID format")
|
|
352
|
+
|
|
353
|
+
response = await self._http.request("GET", f"/webhooks/{webhook_id}")
|
|
354
|
+
return Webhook(**_transform_webhook_response(response))
|
|
355
|
+
|
|
356
|
+
async def update(
|
|
357
|
+
self,
|
|
358
|
+
webhook_id: str,
|
|
359
|
+
url: Optional[str] = None,
|
|
360
|
+
events: Optional[List[str]] = None,
|
|
361
|
+
description: Optional[str] = None,
|
|
362
|
+
is_active: Optional[bool] = None,
|
|
363
|
+
mode: Optional[WebhookMode] = None,
|
|
364
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
365
|
+
) -> Webhook:
|
|
366
|
+
"""Update a webhook configuration."""
|
|
367
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
368
|
+
raise ValueError("Invalid webhook ID format")
|
|
369
|
+
|
|
370
|
+
if url and not url.startswith("https://"):
|
|
371
|
+
raise ValueError("Webhook URL must be HTTPS")
|
|
372
|
+
|
|
373
|
+
body = {}
|
|
374
|
+
if url is not None:
|
|
375
|
+
body["url"] = url
|
|
376
|
+
if events is not None:
|
|
377
|
+
body["events"] = events
|
|
378
|
+
if description is not None:
|
|
379
|
+
body["description"] = description
|
|
380
|
+
if is_active is not None:
|
|
381
|
+
body["is_active"] = is_active
|
|
382
|
+
if mode is not None:
|
|
383
|
+
body["mode"] = mode.value if isinstance(mode, WebhookMode) else mode
|
|
384
|
+
if metadata is not None:
|
|
385
|
+
body["metadata"] = metadata
|
|
386
|
+
|
|
387
|
+
response = await self._http.request("PATCH", f"/webhooks/{webhook_id}", json=body)
|
|
388
|
+
return Webhook(**_transform_webhook_response(response))
|
|
389
|
+
|
|
390
|
+
async def delete(self, webhook_id: str) -> None:
|
|
391
|
+
"""Delete a webhook."""
|
|
392
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
393
|
+
raise ValueError("Invalid webhook ID format")
|
|
394
|
+
|
|
395
|
+
await self._http.request("DELETE", f"/webhooks/{webhook_id}")
|
|
396
|
+
|
|
397
|
+
async def test(self, webhook_id: str) -> WebhookTestResult:
|
|
398
|
+
"""Send a test event to a webhook endpoint."""
|
|
399
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
400
|
+
raise ValueError("Invalid webhook ID format")
|
|
401
|
+
|
|
402
|
+
response = await self._http.request("POST", f"/webhooks/{webhook_id}/test")
|
|
403
|
+
return WebhookTestResult(**response)
|
|
404
|
+
|
|
405
|
+
async def rotate_secret(self, webhook_id: str) -> WebhookSecretRotation:
|
|
406
|
+
"""Rotate the webhook signing secret."""
|
|
407
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
408
|
+
raise ValueError("Invalid webhook ID format")
|
|
409
|
+
|
|
410
|
+
response = await self._http.request("POST", f"/webhooks/{webhook_id}/rotate-secret")
|
|
411
|
+
if "webhook" in response:
|
|
412
|
+
response["webhook"] = _transform_webhook_response(response["webhook"])
|
|
413
|
+
return WebhookSecretRotation(**response)
|
|
414
|
+
|
|
415
|
+
async def get_deliveries(self, webhook_id: str) -> List[WebhookDelivery]:
|
|
416
|
+
"""Get delivery history for a webhook."""
|
|
417
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
418
|
+
raise ValueError("Invalid webhook ID format")
|
|
419
|
+
|
|
420
|
+
response = await self._http.request("GET", f"/webhooks/{webhook_id}/deliveries")
|
|
421
|
+
return [WebhookDelivery(**_transform_delivery_response(d)) for d in response]
|
|
422
|
+
|
|
423
|
+
async def retry_delivery(self, webhook_id: str, delivery_id: str) -> None:
|
|
424
|
+
"""Retry a failed delivery."""
|
|
425
|
+
if not webhook_id or not webhook_id.startswith("whk_"):
|
|
426
|
+
raise ValueError("Invalid webhook ID format")
|
|
427
|
+
if not delivery_id or not delivery_id.startswith("del_"):
|
|
428
|
+
raise ValueError("Invalid delivery ID format")
|
|
429
|
+
|
|
430
|
+
await self._http.request("POST", f"/webhooks/{webhook_id}/deliveries/{delivery_id}/retry")
|
|
431
|
+
|
|
432
|
+
async def list_event_types(self) -> List[str]:
|
|
433
|
+
"""List available event types."""
|
|
434
|
+
response = await self._http.request("GET", "/webhooks/event-types")
|
|
435
|
+
return [e["type"] for e in response.get("events", [])]
|