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.
@@ -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", [])]