replylayer 0.14.0__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,175 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Any
7
+
8
+ from .._http import AsyncHttpClient, SyncHttpClient
9
+ from ..errors import WebhookSignatureError
10
+
11
+
12
+ def verify_webhook_signature(
13
+ payload: str | bytes,
14
+ signature: str,
15
+ secret: str,
16
+ *,
17
+ tolerance: int = 300,
18
+ ) -> bool:
19
+ parts = signature.split(",")
20
+ timestamp: str | None = None
21
+ v1_hex: str | None = None
22
+
23
+ for part in parts:
24
+ trimmed = part.strip()
25
+ if trimmed.startswith("t="):
26
+ timestamp = trimmed[2:]
27
+ if trimmed.startswith("v1="):
28
+ v1_hex = trimmed[3:]
29
+
30
+ if not timestamp or not v1_hex:
31
+ raise WebhookSignatureError("Invalid signature format: missing t= or v1= component")
32
+
33
+ try:
34
+ ts = int(timestamp)
35
+ except ValueError:
36
+ raise WebhookSignatureError("Invalid signature format: timestamp is not a number")
37
+
38
+ now = int(time.time())
39
+ if abs(now - ts) > tolerance:
40
+ raise WebhookSignatureError(
41
+ f"Webhook timestamp too old ({abs(now - ts)}s exceeds {tolerance}s tolerance)"
42
+ )
43
+
44
+ body = payload if isinstance(payload, str) else payload.decode("utf-8")
45
+ expected = hmac.new(
46
+ secret.encode("utf-8"),
47
+ f"{timestamp}.{body}".encode("utf-8"),
48
+ hashlib.sha256,
49
+ ).hexdigest()
50
+
51
+ if not hmac.compare_digest(expected, v1_hex):
52
+ raise WebhookSignatureError("Webhook signature mismatch")
53
+
54
+ return True
55
+
56
+
57
+ class SyncWebhooks:
58
+ def __init__(self, http: SyncHttpClient) -> None:
59
+ self._http = http
60
+
61
+ def create(self, *, url: str, enabled_events: list[str], description: str | None = None, enabled: bool = True) -> dict[str, Any]:
62
+ """Create a new webhook subscription.
63
+
64
+ Raises ``ForbiddenError`` (HTTP 403) when the account is at its
65
+ tier cap. The error's ``details`` carries
66
+ ``{"feature": "webhook_count_extended", "current_count": int,
67
+ "max_allowed": int}`` so callers can render actionable upgrade
68
+ prompts. Cap matrix: Sandbox 1, Starter 3, Pro 25, Team 100,
69
+ Scale 250, Enterprise unbounded.
70
+ """
71
+ payload: dict[str, Any] = {"url": url, "enabled_events": enabled_events, "enabled": enabled}
72
+ if description is not None:
73
+ payload["description"] = description
74
+ return self._http.request("POST", "/v1/webhooks", body=payload)
75
+
76
+ def list(self) -> dict[str, Any]:
77
+ return self._http.request("GET", "/v1/webhooks")
78
+
79
+ def get(self, id: str) -> dict[str, Any]:
80
+ return self._http.request("GET", f"/v1/webhooks/{id}")
81
+
82
+ def update(self, id: str, **kwargs: Any) -> dict[str, Any]:
83
+ return self._http.request("PATCH", f"/v1/webhooks/{id}", body=kwargs)
84
+
85
+ def delete(self, id: str) -> dict[str, Any]:
86
+ return self._http.request("DELETE", f"/v1/webhooks/{id}")
87
+
88
+ def rotate_secret(self, id: str) -> dict[str, Any]:
89
+ return self._http.request("POST", f"/v1/webhooks/{id}/rotate-secret")
90
+
91
+ def test(self, id: str) -> dict[str, Any]:
92
+ return self._http.request("POST", f"/v1/webhooks/{id}/test")
93
+
94
+ def list_deliveries(
95
+ self,
96
+ id: str,
97
+ *,
98
+ limit: int | None = None,
99
+ before_at: str | None = None,
100
+ before_id: str | None = None,
101
+ ) -> dict[str, Any]:
102
+ # The API requires before_at + before_id together (400s otherwise).
103
+ # Enforce the pair at the SDK layer so a partial call doesn't build
104
+ # a request the server will reject.
105
+ query: dict[str, str | None] = {}
106
+ if limit is not None:
107
+ query["limit"] = str(limit)
108
+ if before_at and before_id:
109
+ query["before_at"] = before_at
110
+ query["before_id"] = before_id
111
+ return self._http.request("GET", f"/v1/webhooks/{id}/deliveries", query=query)
112
+
113
+ def retry_delivery(self, id: str, delivery_id: str) -> dict[str, Any]:
114
+ return self._http.request(
115
+ "POST", f"/v1/webhooks/{id}/deliveries/{delivery_id}/retry"
116
+ )
117
+
118
+ @staticmethod
119
+ def verify_signature(payload: str | bytes, signature: str, secret: str, *, tolerance: int = 300) -> bool:
120
+ return verify_webhook_signature(payload, signature, secret, tolerance=tolerance)
121
+
122
+
123
+ class AsyncWebhooks:
124
+ def __init__(self, http: AsyncHttpClient) -> None:
125
+ self._http = http
126
+
127
+ async def create(self, *, url: str, enabled_events: list[str], description: str | None = None, enabled: bool = True) -> dict[str, Any]:
128
+ """Create a new webhook subscription. See SyncWebhooks.create for tier-cap details."""
129
+ payload: dict[str, Any] = {"url": url, "enabled_events": enabled_events, "enabled": enabled}
130
+ if description is not None:
131
+ payload["description"] = description
132
+ return await self._http.request("POST", "/v1/webhooks", body=payload)
133
+
134
+ async def list(self) -> dict[str, Any]:
135
+ return await self._http.request("GET", "/v1/webhooks")
136
+
137
+ async def get(self, id: str) -> dict[str, Any]:
138
+ return await self._http.request("GET", f"/v1/webhooks/{id}")
139
+
140
+ async def update(self, id: str, **kwargs: Any) -> dict[str, Any]:
141
+ return await self._http.request("PATCH", f"/v1/webhooks/{id}", body=kwargs)
142
+
143
+ async def delete(self, id: str) -> dict[str, Any]:
144
+ return await self._http.request("DELETE", f"/v1/webhooks/{id}")
145
+
146
+ async def rotate_secret(self, id: str) -> dict[str, Any]:
147
+ return await self._http.request("POST", f"/v1/webhooks/{id}/rotate-secret")
148
+
149
+ async def test(self, id: str) -> dict[str, Any]:
150
+ return await self._http.request("POST", f"/v1/webhooks/{id}/test")
151
+
152
+ async def list_deliveries(
153
+ self,
154
+ id: str,
155
+ *,
156
+ limit: int | None = None,
157
+ before_at: str | None = None,
158
+ before_id: str | None = None,
159
+ ) -> dict[str, Any]:
160
+ query: dict[str, str | None] = {}
161
+ if limit is not None:
162
+ query["limit"] = str(limit)
163
+ if before_at and before_id:
164
+ query["before_at"] = before_at
165
+ query["before_id"] = before_id
166
+ return await self._http.request("GET", f"/v1/webhooks/{id}/deliveries", query=query)
167
+
168
+ async def retry_delivery(self, id: str, delivery_id: str) -> dict[str, Any]:
169
+ return await self._http.request(
170
+ "POST", f"/v1/webhooks/{id}/deliveries/{delivery_id}/retry"
171
+ )
172
+
173
+ @staticmethod
174
+ def verify_signature(payload: str | bytes, signature: str, secret: str, *, tolerance: int = 300) -> bool:
175
+ return verify_webhook_signature(payload, signature, secret, tolerance=tolerance)