hookbase 3.2.0__tar.gz → 3.3.0__tar.gz

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.
Files changed (93) hide show
  1. {hookbase-3.2.0 → hookbase-3.3.0}/PKG-INFO +1 -1
  2. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_client.py +39 -7
  3. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_pagination.py +2 -2
  4. hookbase-3.3.0/src/hookbase/_version.py +1 -0
  5. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/applications.py +21 -0
  6. hookbase-3.3.0/src/hookbase/models/dlq.py +142 -0
  7. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/endpoints.py +17 -0
  8. hookbase-3.3.0/src/hookbase/models/event_types.py +103 -0
  9. hookbase-3.3.0/src/hookbase/models/messages.py +168 -0
  10. hookbase-3.3.0/src/hookbase/models/subscriptions.py +78 -0
  11. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/dlq.py +29 -10
  12. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/endpoints.py +53 -4
  13. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/event_types.py +6 -5
  14. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/message_log.py +63 -12
  15. hookbase-3.3.0/src/hookbase/resources/messages.py +115 -0
  16. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/subscriptions.py +14 -4
  17. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/webhook.py +18 -1
  18. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_dlq.py +20 -7
  19. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_messages.py +6 -4
  20. hookbase-3.3.0/tests/resources/test_outbound_contract.py +736 -0
  21. {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_webhook.py +60 -0
  22. hookbase-3.2.0/src/hookbase/_version.py +0 -1
  23. hookbase-3.2.0/src/hookbase/models/dlq.py +0 -60
  24. hookbase-3.2.0/src/hookbase/models/event_types.py +0 -37
  25. hookbase-3.2.0/src/hookbase/models/messages.py +0 -73
  26. hookbase-3.2.0/src/hookbase/models/subscriptions.py +0 -22
  27. hookbase-3.2.0/src/hookbase/resources/messages.py +0 -70
  28. {hookbase-3.2.0 → hookbase-3.3.0}/.github/workflows/python-publish.yml +0 -0
  29. {hookbase-3.2.0 → hookbase-3.3.0}/.gitignore +0 -0
  30. {hookbase-3.2.0 → hookbase-3.3.0}/LICENSE +0 -0
  31. {hookbase-3.2.0 → hookbase-3.3.0}/README.md +0 -0
  32. {hookbase-3.2.0 → hookbase-3.3.0}/examples/async_usage.py +0 -0
  33. {hookbase-3.2.0 → hookbase-3.3.0}/examples/basic_inbound.py +0 -0
  34. {hookbase-3.2.0 → hookbase-3.3.0}/examples/error_handling.py +0 -0
  35. {hookbase-3.2.0 → hookbase-3.3.0}/examples/send_webhooks.py +0 -0
  36. {hookbase-3.2.0 → hookbase-3.3.0}/examples/verify_webhooks.py +0 -0
  37. {hookbase-3.2.0 → hookbase-3.3.0}/pyproject.toml +0 -0
  38. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/__init__.py +0 -0
  39. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/_constants.py +0 -0
  40. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/client.py +0 -0
  41. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/errors.py +0 -0
  42. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/__init__.py +0 -0
  43. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/_base.py +0 -0
  44. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/_wire.py +0 -0
  45. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/analytics.py +0 -0
  46. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/api_keys.py +0 -0
  47. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/catalog.py +0 -0
  48. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/common.py +0 -0
  49. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/cron_jobs.py +0 -0
  50. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/deliveries.py +0 -0
  51. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/destinations.py +0 -0
  52. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/events.py +0 -0
  53. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/filters.py +0 -0
  54. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/organizations.py +0 -0
  55. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/portal_tokens.py +0 -0
  56. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/routes.py +0 -0
  57. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/schemas.py +0 -0
  58. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/sources.py +0 -0
  59. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/transforms.py +0 -0
  60. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/models/tunnels.py +0 -0
  61. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/py.typed +0 -0
  62. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/__init__.py +0 -0
  63. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/_base.py +0 -0
  64. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/analytics.py +0 -0
  65. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/api_keys.py +0 -0
  66. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/applications.py +0 -0
  67. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/cron_jobs.py +0 -0
  68. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/deliveries.py +0 -0
  69. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/destinations.py +0 -0
  70. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/events.py +0 -0
  71. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/filters.py +0 -0
  72. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/organizations.py +0 -0
  73. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/portal_tokens.py +0 -0
  74. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/providers.py +0 -0
  75. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/routes.py +0 -0
  76. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/schemas.py +0 -0
  77. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/sources.py +0 -0
  78. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/transforms.py +0 -0
  79. {hookbase-3.2.0 → hookbase-3.3.0}/src/hookbase/resources/tunnels.py +0 -0
  80. {hookbase-3.2.0 → hookbase-3.3.0}/tests/__init__.py +0 -0
  81. {hookbase-3.2.0 → hookbase-3.3.0}/tests/conftest.py +0 -0
  82. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/__init__.py +0 -0
  83. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_applications.py +0 -0
  84. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_cron_jobs.py +0 -0
  85. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_destinations.py +0 -0
  86. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_endpoints.py +0 -0
  87. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_source_contract.py +0 -0
  88. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_source_fields.py +0 -0
  89. {hookbase-3.2.0 → hookbase-3.3.0}/tests/resources/test_sources.py +0 -0
  90. {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_client.py +0 -0
  91. {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_errors.py +0 -0
  92. {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_pagination.py +0 -0
  93. {hookbase-3.2.0 → hookbase-3.3.0}/tests/test_slug_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Official Python SDK for the Hookbase webhook management platform
5
5
  Project-URL: Homepage, https://hookbase.app
6
6
  Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
@@ -76,6 +76,31 @@ def _should_retry(status_code: int) -> bool:
76
76
  return status_code in RETRY_STATUS_CODES
77
77
 
78
78
 
79
+ # A POST creates something or triggers work, so repeating one that may already have reached the
80
+ # server (a 5xx, a read timeout, a dropped connection) can do it twice. Only retry those when the
81
+ # request carries an idempotency key the server can deduplicate on. A 429 was rejected before any
82
+ # work was done, and a connect failure never reached the server, so those stay retryable.
83
+ _NON_IDEMPOTENT_METHODS = frozenset({"POST"})
84
+
85
+
86
+ def _may_repeat(method: str, idempotency_key: str | None) -> bool:
87
+ """Whether a request that may already have been processed is safe to send again."""
88
+ return method.upper() not in _NON_IDEMPOTENT_METHODS or bool(idempotency_key)
89
+
90
+
91
+ def _never_sent(exc: httpx.HTTPError) -> bool:
92
+ """Whether the transport failed before the request reached the server."""
93
+ return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
94
+
95
+
96
+ def _decode_body(resp: httpx.Response) -> Any:
97
+ """JSON for JSON responses; the text for anything else (e.g. a CSV export)."""
98
+ content_type = resp.headers.get("content-type", "")
99
+ if content_type and "json" not in content_type.lower():
100
+ return resp.text
101
+ return resp.json()
102
+
103
+
79
104
  def _backoff(attempt: int) -> float:
80
105
  base = min(1.0 * (2**attempt), 10.0)
81
106
  jitter = random.random() # noqa: S311
@@ -126,6 +151,7 @@ class SyncTransport:
126
151
  headers: dict[str, str] = {}
127
152
  if idempotency_key:
128
153
  headers["Idempotency-Key"] = idempotency_key
154
+ repeatable = _may_repeat(method, idempotency_key)
129
155
 
130
156
  if self._debug:
131
157
  logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
@@ -143,13 +169,13 @@ class SyncTransport:
143
169
  except httpx.TimeoutException as exc:
144
170
  last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
145
171
  last_exc.__cause__ = exc
146
- if attempt < self._max_retries:
172
+ if attempt < self._max_retries and (repeatable or _never_sent(exc)):
147
173
  time.sleep(_backoff(attempt))
148
174
  continue
149
175
  raise last_exc from exc
150
176
  except httpx.HTTPError as exc:
151
177
  last_exc = NetworkError(str(exc), cause=exc)
152
- if attempt < self._max_retries:
178
+ if attempt < self._max_retries and (repeatable or _never_sent(exc)):
153
179
  time.sleep(_backoff(attempt))
154
180
  continue
155
181
  raise last_exc from exc
@@ -160,7 +186,7 @@ class SyncTransport:
160
186
  return None
161
187
 
162
188
  if resp.is_success:
163
- return resp.json()
189
+ return _decode_body(resp)
164
190
 
165
191
  # Parse error body
166
192
  try:
@@ -179,9 +205,12 @@ class SyncTransport:
179
205
  except ValueError:
180
206
  pass
181
207
 
182
- # Don't retry client errors (except 429 and 409)
208
+ # Don't retry client errors (except 429), nor a 5xx on a request
209
+ # that is not safe to repeat (see `_may_repeat`).
183
210
  if not _should_retry(resp.status_code):
184
211
  raise error
212
+ if not repeatable and not isinstance(error, RateLimitError):
213
+ raise error
185
214
 
186
215
  last_exc = error
187
216
 
@@ -242,6 +271,7 @@ class AsyncTransport:
242
271
  headers: dict[str, str] = {}
243
272
  if idempotency_key:
244
273
  headers["Idempotency-Key"] = idempotency_key
274
+ repeatable = _may_repeat(method, idempotency_key)
245
275
 
246
276
  if self._debug:
247
277
  logger.debug("[Hookbase] %s %s params=%s", method, path, cleaned)
@@ -259,13 +289,13 @@ class AsyncTransport:
259
289
  except httpx.TimeoutException as exc:
260
290
  last_exc = TimeoutError(f"Request timed out after {self._timeout}s")
261
291
  last_exc.__cause__ = exc
262
- if attempt < self._max_retries:
292
+ if attempt < self._max_retries and (repeatable or _never_sent(exc)):
263
293
  await asyncio.sleep(_backoff(attempt))
264
294
  continue
265
295
  raise last_exc from exc
266
296
  except httpx.HTTPError as exc:
267
297
  last_exc = NetworkError(str(exc), cause=exc)
268
- if attempt < self._max_retries:
298
+ if attempt < self._max_retries and (repeatable or _never_sent(exc)):
269
299
  await asyncio.sleep(_backoff(attempt))
270
300
  continue
271
301
  raise last_exc from exc
@@ -276,7 +306,7 @@ class AsyncTransport:
276
306
  return None
277
307
 
278
308
  if resp.is_success:
279
- return resp.json()
309
+ return _decode_body(resp)
280
310
 
281
311
  try:
282
312
  body = resp.json()
@@ -295,6 +325,8 @@ class AsyncTransport:
295
325
 
296
326
  if not _should_retry(resp.status_code):
297
327
  raise error
328
+ if not repeatable and not isinstance(error, RateLimitError):
329
+ raise error
298
330
 
299
331
  last_exc = error
300
332
 
@@ -161,7 +161,7 @@ class SyncCursorPage(Generic[T]):
161
161
  page = self
162
162
  while True:
163
163
  yield from page.data
164
- if not page.has_more:
164
+ if not page.has_more or not page.next_cursor:
165
165
  break
166
166
  page = page.next_page()
167
167
 
@@ -211,7 +211,7 @@ class AsyncCursorPage(Generic[T]):
211
211
  while True:
212
212
  for item in page.data:
213
213
  yield item
214
- if not page.has_more:
214
+ if not page.has_more or not page.next_cursor:
215
215
  break
216
216
  page = await page.next_page()
217
217
 
@@ -0,0 +1 @@
1
+ __version__ = "3.3.0"
@@ -14,6 +14,19 @@ class Application(HookbaseModel):
14
14
  organization_id: str
15
15
  external_id: str = ""
16
16
  metadata: dict[str, Any] | None = None
17
+ rate_limit_per_second: int | None = None
18
+ rate_limit_per_minute: int | None = None
19
+ rate_limit_per_hour: int | None = None
20
+ is_disabled: bool = False
21
+ disabled_at: str | None = None
22
+ disabled_reason: str | None = None
23
+ total_endpoints: int | None = None
24
+ total_messages_sent: int | None = None
25
+ total_messages_failed: int | None = None
26
+ last_event_at: str | None = None
27
+ send_event_url: str | None = None
28
+ created_by: str | None = None
29
+ api_key_id: str | None = None
17
30
  created_at: str = ""
18
31
  updated_at: str = ""
19
32
 
@@ -45,6 +58,9 @@ class CreateApplicationParams(HookbaseModel):
45
58
  name: str
46
59
  external_id: str | None = None
47
60
  metadata: dict[str, Any] | None = None
61
+ rate_limit_per_second: int | None = None
62
+ rate_limit_per_minute: int | None = None
63
+ rate_limit_per_hour: int | None = None
48
64
 
49
65
  uid: str | None = None
50
66
  """Deprecated: renamed to `external_id`, which is the only name the API
@@ -55,6 +71,11 @@ class CreateApplicationParams(HookbaseModel):
55
71
  class UpdateApplicationParams(HookbaseModel):
56
72
  name: str | None = None
57
73
  metadata: dict[str, Any] | None = None
74
+ rate_limit_per_second: int | None = None
75
+ rate_limit_per_minute: int | None = None
76
+ rate_limit_per_hour: int | None = None
77
+ is_disabled: bool | None = None
78
+ disabled_reason: str | None = None
58
79
 
59
80
 
60
81
  def application_body(params: CreateApplicationParams | dict[str, Any]) -> dict[str, Any]:
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import model_validator
6
+
7
+ from ._base import HookbaseModel
8
+ from .messages import _mirror
9
+
10
+
11
+ class DlqMessage(HookbaseModel):
12
+ """A dead-lettered message, as `GET /api/outbound-messages/dlq/messages` returns it."""
13
+
14
+ id: str
15
+ event_id: str = ""
16
+ endpoint_id: str = ""
17
+ endpoint_url: str | None = None
18
+ application_id: str = ""
19
+ application_name: str | None = None
20
+ event_type: str = ""
21
+ status: str = ""
22
+ dlq_reason: str | None = None
23
+ dlq_moved_at: str | None = None
24
+ dlq_retry_count: int | None = None
25
+ dlq_last_retry_at: str | None = None
26
+ attempts: int = 0
27
+ max_attempts: int = 5
28
+ last_response_status: int | None = None
29
+ last_error_type: str | None = None
30
+ last_error_message: str | None = None
31
+ created_at: str = ""
32
+ completed_at: str | None = None
33
+
34
+ # Deprecated: names the API never sent.
35
+ message_id: str = ""
36
+ """Deprecated: read `event_id`."""
37
+ last_error: str | None = None
38
+ """Deprecated: read `last_error_message`."""
39
+ last_attempt_at: str | None = None
40
+ """Deprecated: not returned by the API; always `None`."""
41
+ updated_at: str = ""
42
+ """Deprecated: not returned by the API; always empty."""
43
+
44
+ @model_validator(mode="before")
45
+ @classmethod
46
+ def _fill_deprecated(cls, data: Any) -> Any:
47
+ return _mirror(data, {"messageId": "eventId", "lastError": "lastErrorMessage"})
48
+
49
+
50
+ class DlqEndpointStat(HookbaseModel):
51
+ endpoint_id: str
52
+ endpoint_url: str | None = ""
53
+ count: int = 0
54
+
55
+
56
+ class DlqEventTypeStat(HookbaseModel):
57
+ event_type: str
58
+ count: int = 0
59
+
60
+
61
+ class DlqStats(HookbaseModel):
62
+ total: int = 0
63
+ by_reason: dict[str, int] = {}
64
+ by_endpoint: list[DlqEndpointStat] = []
65
+ """The ten endpoints with the most dead-lettered messages."""
66
+ by_event_type: list[DlqEventTypeStat] = []
67
+ """The ten event types with the most dead-lettered messages."""
68
+
69
+ top_failing_endpoints: list[DlqEndpointStat] = []
70
+ """Deprecated: read `by_endpoint`; this mirrors it."""
71
+
72
+ @model_validator(mode="before")
73
+ @classmethod
74
+ def _fill_deprecated(cls, data: Any) -> Any:
75
+ return _mirror(data, {"topFailingEndpoints": "byEndpoint"})
76
+
77
+
78
+ class DlqRetryResult(HookbaseModel):
79
+ original_message_id: str = ""
80
+ new_message_id: str = ""
81
+ status: str = ""
82
+
83
+
84
+ class DlqBulkRetryResultItem(HookbaseModel):
85
+ original_message_id: str = ""
86
+ new_message_id: str | None = None
87
+
88
+ message_id: str = ""
89
+ """Deprecated: read `original_message_id`; this mirrors it."""
90
+ status: str = ""
91
+ """Deprecated: the API returns only the messages it queued, so this is always `"queued"`."""
92
+ error: str | None = None
93
+ """Deprecated: not returned by the API; always `None`."""
94
+
95
+ @model_validator(mode="before")
96
+ @classmethod
97
+ def _fill_deprecated(cls, data: Any) -> Any:
98
+ data = _mirror(data, {"messageId": "originalMessageId"})
99
+ if isinstance(data, dict) and not data.get("status"):
100
+ data["status"] = "queued"
101
+ return data
102
+
103
+
104
+ class DlqBulkRetryResult(HookbaseModel):
105
+ """The result of `POST /api/outbound-messages/dlq/retry-bulk`."""
106
+
107
+ queued: int = 0
108
+ """Messages re-queued for delivery."""
109
+ skipped: int = 0
110
+ """Requested IDs that were not in the DLQ (or not found)."""
111
+ messages: list[DlqBulkRetryResultItem] = []
112
+
113
+ # Deprecated: names the API never sent, derived from the fields above.
114
+ total: int = 0
115
+ """Deprecated: `queued + skipped`."""
116
+ retried: int = 0
117
+ """Deprecated: read `queued`."""
118
+ failed: int = 0
119
+ """Deprecated: read `skipped`."""
120
+ results: list[DlqBulkRetryResultItem] = []
121
+ """Deprecated: read `messages`."""
122
+
123
+ @model_validator(mode="before")
124
+ @classmethod
125
+ def _fill_deprecated(cls, data: Any) -> Any:
126
+ data = _mirror(data, {"retried": "queued", "failed": "skipped", "results": "messages"})
127
+ if isinstance(data, dict) and data.get("total") is None:
128
+ data["total"] = int(data.get("queued") or 0) + int(data.get("skipped") or 0)
129
+ return data
130
+
131
+
132
+ class DlqBulkDeleteResult(HookbaseModel):
133
+ deleted: int = 0
134
+ """How many IDs were submitted; the API does not report how many were actually in the DLQ."""
135
+
136
+ total: int = 0
137
+ """Deprecated: mirrors `deleted`."""
138
+
139
+ @model_validator(mode="before")
140
+ @classmethod
141
+ def _fill_deprecated(cls, data: Any) -> Any:
142
+ return _mirror(data, {"total": "deleted"})
@@ -271,5 +271,22 @@ class EndpointStats(HookbaseModel):
271
271
 
272
272
 
273
273
  class RotateSecretResult(HookbaseModel):
274
+ """The result of `POST /api/webhook-endpoints/:id/rotate-secret`."""
275
+
274
276
  secret: str
277
+ """The new signing secret. Save it now; no later response repeats it."""
278
+ previous_secret_expires_at: str | None = None
279
+ """Until when deliveries are also signed with the previous secret."""
280
+ secret_version: int | None = None
281
+
275
282
  previous_secret_valid_until: str | None = None
283
+ """Deprecated: read `previous_secret_expires_at`; this mirrors it."""
284
+
285
+ @model_validator(mode="before")
286
+ @classmethod
287
+ def _mirror_expiry(cls, data: Any) -> Any:
288
+ if isinstance(data, dict) and data.get("previousSecretValidUntil") is None:
289
+ expires = data.get("previousSecretExpiresAt")
290
+ if expires is not None:
291
+ return {**data, "previousSecretValidUntil": expires}
292
+ return data
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import Field, model_validator
7
+
8
+ from . import _wire
9
+ from ._base import HookbaseModel
10
+
11
+
12
+ class EventType(HookbaseModel):
13
+ id: str
14
+ organization_id: str
15
+ name: str
16
+ display_name: str | None = None
17
+ description: str | None = None
18
+ category: str | None = None
19
+ schema_: Any = Field(default=None, alias="schema")
20
+ """The JSON Schema, decoded. The API stores it as JSON, so this is usually a dict."""
21
+ schema_version: int | None = None
22
+ example_payload: Any = None
23
+ documentation_url: str | None = None
24
+ default_priority: int | None = None
25
+ is_enabled: bool = True
26
+ is_deprecated: bool = False
27
+ deprecated_at: str | None = None
28
+ deprecated_message: str | None = None
29
+ is_public: bool = False
30
+ published_at: str | None = None
31
+ subscription_count: int | None = None
32
+ """Set on `list` responses only."""
33
+ created_at: str = ""
34
+ updated_at: str = ""
35
+
36
+ is_archived: bool = False
37
+ """Deprecated: not returned by the API; always false. See `is_deprecated`."""
38
+
39
+ @model_validator(mode="before")
40
+ @classmethod
41
+ def _decode_json_strings(cls, data: Any) -> Any:
42
+ """Decode `schema`/`examplePayload` if the API returned them as JSON text."""
43
+ if not isinstance(data, dict):
44
+ return data
45
+ out = dict(data)
46
+ for key in ("schema", "examplePayload"):
47
+ value = out.get(key)
48
+ if isinstance(value, str):
49
+ try:
50
+ out[key] = json.loads(value)
51
+ except ValueError:
52
+ pass
53
+ return out
54
+
55
+
56
+ class CreateEventTypeParams(HookbaseModel):
57
+ name: str
58
+ display_name: str | None = None
59
+ description: str | None = None
60
+ category: str | None = None
61
+ schema_: dict[str, Any] | str | None = Field(default=None, alias="schema")
62
+ """A JSON Schema, as a dict or as JSON text."""
63
+ example_payload: Any = None
64
+ """An example payload, as a value or as JSON text."""
65
+ documentation_url: str | None = None
66
+ is_enabled: bool | None = None
67
+ default_priority: int | None = None
68
+
69
+
70
+ class UpdateEventTypeParams(HookbaseModel):
71
+ display_name: str | None = None
72
+ description: str | None = None
73
+ category: str | None = None
74
+ schema_: dict[str, Any] | str | None = Field(default=None, alias="schema")
75
+ """A JSON Schema, as a dict or as JSON text."""
76
+ example_payload: Any = None
77
+ """An example payload, as a value or as JSON text."""
78
+ documentation_url: str | None = None
79
+ is_enabled: bool | None = None
80
+ is_deprecated: bool | None = None
81
+ deprecated_message: str | None = None
82
+ is_public: bool | None = None
83
+ default_priority: int | None = None
84
+
85
+
86
+ def event_type_body(
87
+ params: CreateEventTypeParams | UpdateEventTypeParams | dict[str, Any],
88
+ ) -> dict[str, Any]:
89
+ """Build the body for `POST /api/event-types` or `PATCH /api/event-types/:id`.
90
+
91
+ The API validates `schema` and `examplePayload` as JSON *strings*
92
+ (`z.string()` in event-types.ts) and its schemas are `.strict()`, so a dict
93
+ there is a 400. Serialize anything that is not already a string.
94
+ """
95
+ body = _wire.dump(params)
96
+ _wire.canonicalize(body, "examplePayload")
97
+ if "schema_" in body:
98
+ body.setdefault("schema", body.pop("schema_"))
99
+ for key in ("schema", "examplePayload"):
100
+ value = body.get(key)
101
+ if value is not None and not isinstance(value, str):
102
+ body[key] = json.dumps(value)
103
+ return body
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Union
4
+
5
+ from pydantic import model_validator
6
+
7
+ from ._base import HookbaseModel
8
+
9
+ # Every status `outbound_messages.status` takes. The field accepts any string so a status the
10
+ # API adds later does not make a whole list page fail to decode, which a closed Literal did for
11
+ # `processing`, `awaiting_retry` and `dlq`.
12
+ MessageStatus = Union[
13
+ Literal[
14
+ "pending", "processing", "success", "failed", "awaiting_retry", "exhausted", "dlq",
15
+ ],
16
+ str,
17
+ ]
18
+
19
+
20
+ def _mirror(data: Any, pairs: dict[str, str]) -> Any:
21
+ """Fill each deprecated field (key) from the API field it was a misspelling of (value)."""
22
+ if not isinstance(data, dict):
23
+ return data
24
+ out = dict(data)
25
+ for old, new in pairs.items():
26
+ if out.get(new) is not None and out.get(old) is None:
27
+ out[old] = out[new]
28
+ return out
29
+
30
+
31
+ class SendEventParams(HookbaseModel):
32
+ event_type: str
33
+ payload: Any
34
+ event_id: str | None = None
35
+ metadata: dict[str, Any] | None = None
36
+ endpoint_ids: list[str] | None = None
37
+
38
+
39
+ class SendEventEndpoint(HookbaseModel):
40
+ id: str
41
+ url: str = ""
42
+
43
+
44
+ class SendEventResponse(HookbaseModel):
45
+ event_id: str = ""
46
+ messages_queued: int = 0
47
+ endpoints: list[SendEventEndpoint] = []
48
+
49
+
50
+ class OutboundMessage(HookbaseModel):
51
+ """An outbound message as `GET /api/outbound-messages[/:id]` returns it.
52
+
53
+ `list` returns the summary fields; `get` adds the subscription, payload and
54
+ error-type fields, which are `None` on list items.
55
+ """
56
+
57
+ id: str
58
+ event_id: str = ""
59
+ event_type: str = ""
60
+ application_id: str = ""
61
+ application_name: str | None = None
62
+ endpoint_id: str = ""
63
+ endpoint_url: str | None = ""
64
+ status: MessageStatus = "pending"
65
+ attempts: int = 0
66
+ max_attempts: int = 5
67
+ last_response_status: int | None = None
68
+ last_error_message: str | None = None
69
+ created_at: str = ""
70
+ completed_at: str | None = None
71
+
72
+ # `get` only.
73
+ subscription_id: str | None = None
74
+ event_type_id: str | None = None
75
+ last_error_type: str | None = None
76
+ payload_key: str | None = None
77
+ payload_size_bytes: int | None = None
78
+ payload_hash: str | None = None
79
+ metadata: Any = None
80
+ priority: int | None = None
81
+ endpoint_description: str | None = None
82
+ payload: Any = None
83
+
84
+ # Deprecated: names the API never sent. The three with an API equivalent mirror it.
85
+ message_id: str = ""
86
+ """Deprecated: read `event_id`."""
87
+ last_error: str | None = None
88
+ """Deprecated: read `last_error_message`."""
89
+ delivered_at: str | None = None
90
+ """Deprecated: read `completed_at`."""
91
+ last_attempt_at: str | None = None
92
+ """Deprecated: not returned by the API; always `None`."""
93
+ next_attempt_at: str | None = None
94
+ """Deprecated: not returned by the API; always `None`."""
95
+ last_response_body: str | None = None
96
+ """Deprecated: not returned by the API; read the attempts instead."""
97
+ updated_at: str = ""
98
+ """Deprecated: not returned by the API; always empty."""
99
+
100
+ @model_validator(mode="before")
101
+ @classmethod
102
+ def _fill_deprecated(cls, data: Any) -> Any:
103
+ return _mirror(data, {
104
+ "messageId": "eventId",
105
+ "lastError": "lastErrorMessage",
106
+ "deliveredAt": "completedAt",
107
+ })
108
+
109
+
110
+ class OutboundAttempt(HookbaseModel):
111
+ """One delivery attempt, as `GET /api/outbound-messages/:id/attempts` returns it."""
112
+
113
+ id: str
114
+ message_id: str = ""
115
+ attempt_number: int = 0
116
+ status: str = ""
117
+ response_status: int | None = None
118
+ response_headers: dict[str, Any] | None = None
119
+ response_body: str | None = None
120
+ response_time_ms: int | None = None
121
+ total_time_ms: int | None = None
122
+ error_type: str | None = None
123
+ error_message: str | None = None
124
+ error_code: str | None = None
125
+ request_url: str | None = None
126
+ request_headers: dict[str, Any] | None = None
127
+ request_body_size: int | None = None
128
+ triggered_by: str | None = None
129
+ triggered_by_user: str | None = None
130
+ created_at: str = ""
131
+ completed_at: str | None = None
132
+
133
+ # Deprecated: names the API never sent, each mirroring its API equivalent.
134
+ outbound_message_id: str = ""
135
+ """Deprecated: read `message_id`."""
136
+ error: str | None = None
137
+ """Deprecated: read `error_message`."""
138
+ latency_ms: float | None = None
139
+ """Deprecated: read `response_time_ms`."""
140
+ attempted_at: str = ""
141
+ """Deprecated: read `created_at`."""
142
+
143
+ @model_validator(mode="before")
144
+ @classmethod
145
+ def _fill_deprecated(cls, data: Any) -> Any:
146
+ return _mirror(data, {
147
+ "outboundMessageId": "messageId",
148
+ "error": "errorMessage",
149
+ "latencyMs": "responseTimeMs",
150
+ "attemptedAt": "createdAt",
151
+ })
152
+
153
+
154
+ class StatsSummary(HookbaseModel):
155
+ pending: int = 0
156
+ processing: int = 0
157
+ success: int = 0
158
+ failed: int = 0
159
+ awaiting_retry: int = 0
160
+ exhausted: int = 0
161
+ dlq: int = 0
162
+ total: int = 0
163
+
164
+
165
+ class ReplayResult(HookbaseModel):
166
+ original_message_id: str = ""
167
+ new_message_id: str = ""
168
+ status: str = ""