posthook-python 1.0.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.
posthook/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """Posthook Python SDK — schedule, manage, and verify webhooks."""
2
+
3
+ from ._client import AsyncPosthook, Posthook
4
+ from ._errors import (
5
+ AuthenticationError,
6
+ BadRequestError,
7
+ ForbiddenError,
8
+ InternalServerError,
9
+ NotFoundError,
10
+ PayloadTooLargeError,
11
+ PosthookConnectionError,
12
+ PosthookError,
13
+ RateLimitError,
14
+ SignatureVerificationError,
15
+ )
16
+ from ._models import (
17
+ SORT_BY_CREATED_AT,
18
+ SORT_BY_POST_AT,
19
+ SORT_ORDER_ASC,
20
+ SORT_ORDER_DESC,
21
+ STATUS_COMPLETED,
22
+ STATUS_FAILED,
23
+ STATUS_PENDING,
24
+ STATUS_RETRY,
25
+ STRATEGY_EXPONENTIAL,
26
+ STRATEGY_FIXED,
27
+ BulkActionResult,
28
+ Delivery,
29
+ Hook,
30
+ HookRetryOverride,
31
+ QuotaInfo,
32
+ )
33
+ from ._resources._signatures import SignaturesService, create_signatures
34
+ from ._version import VERSION as __version__
35
+
36
+ __all__ = [
37
+ # Clients
38
+ "Posthook",
39
+ "AsyncPosthook",
40
+ # Models
41
+ "Hook",
42
+ "HookRetryOverride",
43
+ "QuotaInfo",
44
+ "BulkActionResult",
45
+ "Delivery",
46
+ # Resources
47
+ "SignaturesService",
48
+ "create_signatures",
49
+ # Constants
50
+ "STATUS_PENDING",
51
+ "STATUS_RETRY",
52
+ "STATUS_COMPLETED",
53
+ "STATUS_FAILED",
54
+ "SORT_BY_POST_AT",
55
+ "SORT_BY_CREATED_AT",
56
+ "SORT_ORDER_ASC",
57
+ "SORT_ORDER_DESC",
58
+ "STRATEGY_FIXED",
59
+ "STRATEGY_EXPONENTIAL",
60
+ # Errors
61
+ "PosthookError",
62
+ "BadRequestError",
63
+ "AuthenticationError",
64
+ "ForbiddenError",
65
+ "NotFoundError",
66
+ "PayloadTooLargeError",
67
+ "RateLimitError",
68
+ "InternalServerError",
69
+ "PosthookConnectionError",
70
+ "SignatureVerificationError",
71
+ # Version
72
+ "__version__",
73
+ ]
posthook/_client.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from ._errors import AuthenticationError
9
+ from ._http import (
10
+ DEFAULT_BASE_URL,
11
+ DEFAULT_TIMEOUT,
12
+ AsyncHttpClient,
13
+ SyncHttpClient,
14
+ )
15
+ from ._resources._hooks import AsyncHooksService, HooksService
16
+ from ._resources._signatures import SignaturesService
17
+
18
+
19
+ class Posthook:
20
+ """Synchronous Posthook API client.
21
+
22
+ Args:
23
+ api_key: Your Posthook API key. Falls back to the ``POSTHOOK_API_KEY``
24
+ environment variable if not provided.
25
+ base_url: Override the API base URL.
26
+ timeout: Request timeout in seconds.
27
+ signing_key: Key for webhook signature verification. Falls back to
28
+ ``POSTHOOK_SIGNING_KEY`` env var.
29
+ http_client: A custom ``httpx.Client`` instance.
30
+ """
31
+
32
+ hooks: HooksService
33
+ signatures: SignaturesService
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: str | None = None,
38
+ *,
39
+ base_url: str = DEFAULT_BASE_URL,
40
+ timeout: float = DEFAULT_TIMEOUT,
41
+ signing_key: str | None = None,
42
+ http_client: httpx.Client | None = None,
43
+ ) -> None:
44
+ resolved_key = api_key or os.environ.get("POSTHOOK_API_KEY", "")
45
+ if not resolved_key:
46
+ raise AuthenticationError(
47
+ "No API key provided. Pass api_key to the Posthook constructor "
48
+ "or set the POSTHOOK_API_KEY environment variable."
49
+ )
50
+
51
+ resolved_signing_key = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY")
52
+
53
+ self._http = SyncHttpClient(
54
+ resolved_key,
55
+ base_url=base_url,
56
+ timeout=timeout,
57
+ http_client=http_client,
58
+ )
59
+ self.hooks = HooksService(self._http)
60
+ self.signatures = SignaturesService(resolved_signing_key)
61
+
62
+ def close(self) -> None:
63
+ """Close the underlying HTTP client."""
64
+ self._http.close()
65
+
66
+ def __enter__(self) -> Posthook:
67
+ return self
68
+
69
+ def __exit__(self, *args: Any) -> None:
70
+ self.close()
71
+
72
+
73
+ class AsyncPosthook:
74
+ """Asynchronous Posthook API client.
75
+
76
+ Args:
77
+ api_key: Your Posthook API key. Falls back to the ``POSTHOOK_API_KEY``
78
+ environment variable if not provided.
79
+ base_url: Override the API base URL.
80
+ timeout: Request timeout in seconds.
81
+ signing_key: Key for webhook signature verification. Falls back to
82
+ ``POSTHOOK_SIGNING_KEY`` env var.
83
+ http_client: A custom ``httpx.AsyncClient`` instance.
84
+ """
85
+
86
+ hooks: AsyncHooksService
87
+ signatures: SignaturesService
88
+
89
+ def __init__(
90
+ self,
91
+ api_key: str | None = None,
92
+ *,
93
+ base_url: str = DEFAULT_BASE_URL,
94
+ timeout: float = DEFAULT_TIMEOUT,
95
+ signing_key: str | None = None,
96
+ http_client: httpx.AsyncClient | None = None,
97
+ ) -> None:
98
+ resolved_key = api_key or os.environ.get("POSTHOOK_API_KEY", "")
99
+ if not resolved_key:
100
+ raise AuthenticationError(
101
+ "No API key provided. Pass api_key to the AsyncPosthook constructor "
102
+ "or set the POSTHOOK_API_KEY environment variable."
103
+ )
104
+
105
+ resolved_signing_key = signing_key or os.environ.get("POSTHOOK_SIGNING_KEY")
106
+
107
+ self._http = AsyncHttpClient(
108
+ resolved_key,
109
+ base_url=base_url,
110
+ timeout=timeout,
111
+ http_client=http_client,
112
+ )
113
+ self.hooks = AsyncHooksService(self._http)
114
+ self.signatures = SignaturesService(resolved_signing_key)
115
+
116
+ async def close(self) -> None:
117
+ """Close the underlying HTTP client."""
118
+ await self._http.close()
119
+
120
+ async def __aenter__(self) -> AsyncPosthook:
121
+ return self
122
+
123
+ async def __aexit__(self, *args: Any) -> None:
124
+ await self.close()
posthook/_errors.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class PosthookError(Exception):
5
+ """Base error class for all Posthook SDK errors."""
6
+
7
+ status_code: int | None
8
+ code: str
9
+ message: str
10
+ headers: dict[str, str] | None
11
+
12
+ def __init__(
13
+ self,
14
+ message: str,
15
+ *,
16
+ status_code: int | None = None,
17
+ code: str = "",
18
+ headers: dict[str, str] | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.message = message
22
+ self.status_code = status_code
23
+ self.code = code
24
+ self.headers = headers
25
+
26
+ def __repr__(self) -> str:
27
+ cls = self.__class__.__name__
28
+ return f"{cls}(message={self.message!r}, status_code={self.status_code})"
29
+
30
+
31
+ class BadRequestError(PosthookError):
32
+ """Raised for HTTP 400 responses."""
33
+
34
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
35
+ super().__init__(message, status_code=400, code="bad_request", headers=headers)
36
+
37
+
38
+ class AuthenticationError(PosthookError):
39
+ """Raised for HTTP 401 responses."""
40
+
41
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
42
+ super().__init__(message, status_code=401, code="authentication_error", headers=headers)
43
+
44
+
45
+ class ForbiddenError(PosthookError):
46
+ """Raised for HTTP 403 responses."""
47
+
48
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
49
+ super().__init__(message, status_code=403, code="forbidden", headers=headers)
50
+
51
+
52
+ class NotFoundError(PosthookError):
53
+ """Raised for HTTP 404 responses."""
54
+
55
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
56
+ super().__init__(message, status_code=404, code="not_found", headers=headers)
57
+
58
+
59
+ class PayloadTooLargeError(PosthookError):
60
+ """Raised for HTTP 413 responses."""
61
+
62
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
63
+ super().__init__(message, status_code=413, code="payload_too_large", headers=headers)
64
+
65
+
66
+ class RateLimitError(PosthookError):
67
+ """Raised for HTTP 429 responses."""
68
+
69
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
70
+ super().__init__(message, status_code=429, code="rate_limit_exceeded", headers=headers)
71
+
72
+
73
+ class InternalServerError(PosthookError):
74
+ """Raised for HTTP 5xx responses."""
75
+
76
+ def __init__(
77
+ self, message: str, status_code: int = 500, headers: dict[str, str] | None = None
78
+ ) -> None:
79
+ super().__init__(message, status_code=status_code, code="internal_error", headers=headers)
80
+
81
+
82
+ class PosthookConnectionError(PosthookError):
83
+ """Raised for network or timeout errors.
84
+
85
+ Named ``PosthookConnectionError`` to avoid shadowing the builtin
86
+ ``ConnectionError``.
87
+ """
88
+
89
+ def __init__(self, message: str) -> None:
90
+ super().__init__(message, code="connection_error")
91
+
92
+
93
+ class SignatureVerificationError(PosthookError):
94
+ """Raised when webhook signature verification fails."""
95
+
96
+ def __init__(self, message: str) -> None:
97
+ super().__init__(message, code="signature_verification_error")
98
+
99
+
100
+ def _create_error(
101
+ status_code: int,
102
+ message: str,
103
+ headers: dict[str, str] | None = None,
104
+ ) -> PosthookError:
105
+ """Create the appropriate error subclass for the given HTTP status code."""
106
+ if status_code == 400:
107
+ return BadRequestError(message, headers)
108
+ if status_code == 401:
109
+ return AuthenticationError(message, headers)
110
+ if status_code == 403:
111
+ return ForbiddenError(message, headers)
112
+ if status_code == 404:
113
+ return NotFoundError(message, headers)
114
+ if status_code == 413:
115
+ return PayloadTooLargeError(message, headers)
116
+ if status_code == 429:
117
+ return RateLimitError(message, headers)
118
+ if status_code >= 500:
119
+ return InternalServerError(message, status_code, headers)
120
+ return PosthookError(message, status_code=status_code, code="unknown_error", headers=headers)
posthook/_http.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import platform
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from ._errors import PosthookConnectionError, PosthookError, _create_error
12
+ from ._models import QuotaInfo
13
+ from ._version import VERSION
14
+
15
+ logger = logging.getLogger("posthook")
16
+
17
+ DEFAULT_BASE_URL = "https://api.posthook.io"
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ def _parse_quota(headers: httpx.Headers) -> QuotaInfo | None:
22
+ limit = headers.get("posthook-hookquota-limit")
23
+ if not limit:
24
+ return None
25
+
26
+ resets_at: datetime | None = None
27
+ raw_resets = headers.get("posthook-hookquota-resets-at", "")
28
+ if raw_resets:
29
+ try:
30
+ resets_at = datetime.fromisoformat(raw_resets.replace("Z", "+00:00"))
31
+ except ValueError:
32
+ pass
33
+
34
+ return QuotaInfo(
35
+ limit=int(limit),
36
+ usage=int(headers.get("posthook-hookquota-usage", "0")),
37
+ remaining=int(headers.get("posthook-hookquota-remaining", "0")),
38
+ resets_at=resets_at,
39
+ )
40
+
41
+
42
+ _USER_AGENT = (
43
+ f"posthook-python/{VERSION}"
44
+ f" (Python {platform.python_version()}; {platform.system()})"
45
+ )
46
+
47
+
48
+ def _headers(api_key: str) -> dict[str, str]:
49
+ return {
50
+ "X-API-Key": api_key,
51
+ "User-Agent": _USER_AGENT,
52
+ "Content-Type": "application/json",
53
+ }
54
+
55
+
56
+ def _unwrap_data(body: dict[str, Any]) -> Any:
57
+ """Extract 'data' from the API response envelope."""
58
+ return body.get("data")
59
+
60
+
61
+ def _extract_error_message(response: httpx.Response) -> str:
62
+ try:
63
+ body = response.json()
64
+ return body.get("error", f"HTTP {response.status_code}")
65
+ except Exception:
66
+ return f"HTTP {response.status_code}"
67
+
68
+
69
+ class SyncHttpClient:
70
+ """Synchronous HTTP client wrapping httpx.Client."""
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: str,
75
+ *,
76
+ base_url: str = DEFAULT_BASE_URL,
77
+ timeout: float = DEFAULT_TIMEOUT,
78
+ http_client: httpx.Client | None = None,
79
+ ) -> None:
80
+ self._api_key = api_key
81
+ self._base_url = base_url.rstrip("/")
82
+ self._owns_client = http_client is None
83
+ self._client = http_client or httpx.Client(
84
+ timeout=timeout,
85
+ headers=_headers(api_key),
86
+ )
87
+ if http_client is not None:
88
+ self._client.headers.update(_headers(api_key))
89
+
90
+ def request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ *,
95
+ json: Any = None,
96
+ params: dict[str, Any] | None = None,
97
+ timeout: float | None = None,
98
+ ) -> tuple[Any, httpx.Headers]:
99
+ url = f"{self._base_url}{path}"
100
+
101
+ # Filter out None values from params
102
+ if params:
103
+ params = {k: v for k, v in params.items() if v is not None}
104
+
105
+ start = time.monotonic()
106
+ try:
107
+ kwargs: dict[str, Any] = {"json": json, "params": params}
108
+ if timeout is not None:
109
+ kwargs["timeout"] = timeout
110
+ response = self._client.request(
111
+ method, url, **kwargs
112
+ )
113
+ elapsed = time.monotonic() - start
114
+ logger.debug("%s %s -> %d (%.3fs)", method, path, response.status_code, elapsed)
115
+
116
+ if response.status_code >= 400:
117
+ msg = _extract_error_message(response)
118
+ hdrs = dict(response.headers)
119
+ raise _create_error(response.status_code, msg, hdrs)
120
+
121
+ body = response.json()
122
+ return body, response.headers
123
+
124
+ except PosthookError:
125
+ raise
126
+ except httpx.TimeoutException as exc:
127
+ raise PosthookConnectionError(f"Request timed out: {exc}") from exc
128
+ except httpx.HTTPError as exc:
129
+ raise PosthookConnectionError(f"Network error: {exc}") from exc
130
+
131
+ def request_data(
132
+ self,
133
+ method: str,
134
+ path: str,
135
+ *,
136
+ json: Any = None,
137
+ params: dict[str, Any] | None = None,
138
+ timeout: float | None = None,
139
+ ) -> tuple[Any, httpx.Headers]:
140
+ """Make a request and return (unwrapped_data, headers)."""
141
+ body, headers = self.request(method, path, json=json, params=params, timeout=timeout)
142
+ return _unwrap_data(body), headers
143
+
144
+ def close(self) -> None:
145
+ if self._owns_client:
146
+ self._client.close()
147
+
148
+
149
+ class AsyncHttpClient:
150
+ """Asynchronous HTTP client wrapping httpx.AsyncClient."""
151
+
152
+ def __init__(
153
+ self,
154
+ api_key: str,
155
+ *,
156
+ base_url: str = DEFAULT_BASE_URL,
157
+ timeout: float = DEFAULT_TIMEOUT,
158
+ http_client: httpx.AsyncClient | None = None,
159
+ ) -> None:
160
+ self._api_key = api_key
161
+ self._base_url = base_url.rstrip("/")
162
+ self._owns_client = http_client is None
163
+ self._client = http_client or httpx.AsyncClient(
164
+ timeout=timeout,
165
+ headers=_headers(api_key),
166
+ )
167
+ if http_client is not None:
168
+ self._client.headers.update(_headers(api_key))
169
+
170
+ async def request(
171
+ self,
172
+ method: str,
173
+ path: str,
174
+ *,
175
+ json: Any = None,
176
+ params: dict[str, Any] | None = None,
177
+ timeout: float | None = None,
178
+ ) -> tuple[Any, httpx.Headers]:
179
+ url = f"{self._base_url}{path}"
180
+
181
+ if params:
182
+ params = {k: v for k, v in params.items() if v is not None}
183
+
184
+ start = time.monotonic()
185
+ try:
186
+ kwargs: dict[str, Any] = {"json": json, "params": params}
187
+ if timeout is not None:
188
+ kwargs["timeout"] = timeout
189
+ response = await self._client.request(
190
+ method, url, **kwargs
191
+ )
192
+ elapsed = time.monotonic() - start
193
+ logger.debug("%s %s -> %d (%.3fs)", method, path, response.status_code, elapsed)
194
+
195
+ if response.status_code >= 400:
196
+ msg = _extract_error_message(response)
197
+ hdrs = dict(response.headers)
198
+ raise _create_error(response.status_code, msg, hdrs)
199
+
200
+ body = response.json()
201
+ return body, response.headers
202
+
203
+ except PosthookError:
204
+ raise
205
+ except httpx.TimeoutException as exc:
206
+ raise PosthookConnectionError(f"Request timed out: {exc}") from exc
207
+ except httpx.HTTPError as exc:
208
+ raise PosthookConnectionError(f"Network error: {exc}") from exc
209
+
210
+ async def request_data(
211
+ self,
212
+ method: str,
213
+ path: str,
214
+ *,
215
+ json: Any = None,
216
+ params: dict[str, Any] | None = None,
217
+ timeout: float | None = None,
218
+ ) -> tuple[Any, httpx.Headers]:
219
+ body, headers = await self.request(method, path, json=json, params=params, timeout=timeout)
220
+ return _unwrap_data(body), headers
221
+
222
+ async def close(self) -> None:
223
+ if self._owns_client:
224
+ await self._client.aclose()
posthook/_models.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ # Hook status constants.
8
+ STATUS_PENDING = "pending"
9
+ STATUS_RETRY = "retry"
10
+ STATUS_COMPLETED = "completed"
11
+ STATUS_FAILED = "failed"
12
+
13
+ # Sort field constants.
14
+ SORT_BY_POST_AT = "postAt"
15
+ SORT_BY_CREATED_AT = "createdAt"
16
+
17
+ # Sort order constants.
18
+ SORT_ORDER_ASC = "ASC"
19
+ SORT_ORDER_DESC = "DESC"
20
+
21
+ # Retry strategy constants.
22
+ STRATEGY_FIXED = "fixed"
23
+ STRATEGY_EXPONENTIAL = "exponential"
24
+
25
+
26
+ def _parse_dt(value: str) -> datetime:
27
+ """Parse an RFC 3339 timestamp string into a datetime."""
28
+ if not value:
29
+ return datetime.min.replace(tzinfo=timezone.utc)
30
+ # Handle "Z" suffix which Python's fromisoformat doesn't support before 3.11
31
+ if value.endswith("Z"):
32
+ value = value[:-1] + "+00:00"
33
+ return datetime.fromisoformat(value)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class QuotaInfo:
38
+ """Hook quota information parsed from response headers."""
39
+
40
+ limit: int
41
+ usage: int
42
+ remaining: int
43
+ resets_at: datetime | None
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class HookRetryOverride:
48
+ """Per-hook retry configuration that overrides project defaults."""
49
+
50
+ min_retries: int
51
+ delay_secs: int
52
+ strategy: str
53
+ backoff_factor: float | None = None
54
+ max_delay_secs: int | None = None
55
+ jitter: bool = False
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, Any]) -> HookRetryOverride:
59
+ return cls(
60
+ min_retries=data["minRetries"],
61
+ delay_secs=data["delaySecs"],
62
+ strategy=data["strategy"],
63
+ backoff_factor=data.get("backoffFactor"),
64
+ max_delay_secs=data.get("maxDelaySecs"),
65
+ jitter=data.get("jitter", False),
66
+ )
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ d: dict[str, Any] = {
70
+ "minRetries": self.min_retries,
71
+ "delaySecs": self.delay_secs,
72
+ "strategy": self.strategy,
73
+ "jitter": self.jitter,
74
+ }
75
+ if self.backoff_factor is not None:
76
+ d["backoffFactor"] = self.backoff_factor
77
+ if self.max_delay_secs is not None:
78
+ d["maxDelaySecs"] = self.max_delay_secs
79
+ return d
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class HookSequenceData:
84
+ """Sequence context for a hook that is part of a sequence."""
85
+
86
+ sequence_id: str
87
+ step_name: str
88
+ sequence_last_run_at: str
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> HookSequenceData:
92
+ return cls(
93
+ sequence_id=data["sequenceID"],
94
+ step_name=data["stepName"],
95
+ sequence_last_run_at=data["sequenceLastRunAt"],
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class Hook:
101
+ """A scheduled webhook as returned by the Posthook API."""
102
+
103
+ id: str
104
+ path: str
105
+ data: Any
106
+ post_at: datetime
107
+ status: str
108
+ post_duration_seconds: float
109
+ created_at: datetime
110
+ updated_at: datetime
111
+ domain: str | None = None
112
+ attempts: int = 0
113
+ failure_error: str = ""
114
+ sequence_data: HookSequenceData | None = None
115
+ retry_override: HookRetryOverride | None = None
116
+ quota: QuotaInfo | None = field(default=None)
117
+
118
+ @classmethod
119
+ def from_dict(cls, data: dict[str, Any]) -> Hook:
120
+ seq = data.get("sequenceData")
121
+ retry = data.get("retryOverride")
122
+ return cls(
123
+ id=data["id"],
124
+ path=data["path"],
125
+ data=data.get("data"),
126
+ post_at=_parse_dt(data["postAt"]),
127
+ status=data["status"],
128
+ post_duration_seconds=data.get("postDurationSeconds", 0.0),
129
+ created_at=_parse_dt(data.get("createdAt", "")),
130
+ updated_at=_parse_dt(data.get("updatedAt", "")),
131
+ domain=data.get("domain"),
132
+ attempts=data.get("attempts", 0),
133
+ failure_error=data.get("failureError", ""),
134
+ sequence_data=HookSequenceData.from_dict(seq) if seq else None,
135
+ retry_override=HookRetryOverride.from_dict(retry) if retry else None,
136
+ )
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class BulkActionResult:
141
+ """Result of a bulk action on hooks."""
142
+
143
+ affected: int
144
+
145
+ @classmethod
146
+ def from_dict(cls, data: dict[str, Any]) -> BulkActionResult:
147
+ return cls(affected=data.get("affected", 0))
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class Delivery:
152
+ """A parsed and verified webhook delivery."""
153
+
154
+ hook_id: str
155
+ timestamp: int
156
+ path: str
157
+ data: Any
158
+ body: bytes
159
+ post_at: datetime
160
+ posted_at: datetime
161
+ created_at: datetime
162
+ updated_at: datetime