hookbase 3.1.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 (94) hide show
  1. {hookbase-3.1.0 → hookbase-3.3.0}/PKG-INFO +27 -5
  2. {hookbase-3.1.0 → hookbase-3.3.0}/README.md +26 -4
  3. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_client.py +39 -7
  4. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_pagination.py +2 -2
  5. hookbase-3.3.0/src/hookbase/_version.py +1 -0
  6. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/client.py +8 -0
  7. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/__init__.py +27 -0
  8. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/applications.py +21 -0
  9. hookbase-3.3.0/src/hookbase/models/catalog.py +120 -0
  10. hookbase-3.3.0/src/hookbase/models/dlq.py +142 -0
  11. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/endpoints.py +17 -0
  12. hookbase-3.3.0/src/hookbase/models/event_types.py +103 -0
  13. hookbase-3.3.0/src/hookbase/models/messages.py +168 -0
  14. hookbase-3.3.0/src/hookbase/models/sources.py +283 -0
  15. hookbase-3.3.0/src/hookbase/models/subscriptions.py +78 -0
  16. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/__init__.py +2 -0
  17. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/dlq.py +29 -10
  18. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/endpoints.py +53 -4
  19. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/event_types.py +6 -5
  20. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/events.py +20 -0
  21. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/message_log.py +63 -12
  22. hookbase-3.3.0/src/hookbase/resources/messages.py +115 -0
  23. hookbase-3.3.0/src/hookbase/resources/providers.py +54 -0
  24. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/sources.py +82 -2
  25. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/subscriptions.py +14 -4
  26. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/webhook.py +18 -1
  27. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_dlq.py +20 -7
  28. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_messages.py +6 -4
  29. hookbase-3.3.0/tests/resources/test_outbound_contract.py +736 -0
  30. hookbase-3.3.0/tests/resources/test_source_contract.py +302 -0
  31. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_source_fields.py +55 -2
  32. {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_webhook.py +60 -0
  33. hookbase-3.1.0/src/hookbase/_version.py +0 -1
  34. hookbase-3.1.0/src/hookbase/models/dlq.py +0 -60
  35. hookbase-3.1.0/src/hookbase/models/event_types.py +0 -37
  36. hookbase-3.1.0/src/hookbase/models/messages.py +0 -73
  37. hookbase-3.1.0/src/hookbase/models/sources.py +0 -180
  38. hookbase-3.1.0/src/hookbase/models/subscriptions.py +0 -22
  39. hookbase-3.1.0/src/hookbase/resources/messages.py +0 -70
  40. {hookbase-3.1.0 → hookbase-3.3.0}/.github/workflows/python-publish.yml +0 -0
  41. {hookbase-3.1.0 → hookbase-3.3.0}/.gitignore +0 -0
  42. {hookbase-3.1.0 → hookbase-3.3.0}/LICENSE +0 -0
  43. {hookbase-3.1.0 → hookbase-3.3.0}/examples/async_usage.py +0 -0
  44. {hookbase-3.1.0 → hookbase-3.3.0}/examples/basic_inbound.py +0 -0
  45. {hookbase-3.1.0 → hookbase-3.3.0}/examples/error_handling.py +0 -0
  46. {hookbase-3.1.0 → hookbase-3.3.0}/examples/send_webhooks.py +0 -0
  47. {hookbase-3.1.0 → hookbase-3.3.0}/examples/verify_webhooks.py +0 -0
  48. {hookbase-3.1.0 → hookbase-3.3.0}/pyproject.toml +0 -0
  49. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/__init__.py +0 -0
  50. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/_constants.py +0 -0
  51. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/errors.py +0 -0
  52. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/_base.py +0 -0
  53. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/_wire.py +0 -0
  54. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/analytics.py +0 -0
  55. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/api_keys.py +0 -0
  56. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/common.py +0 -0
  57. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/cron_jobs.py +0 -0
  58. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/deliveries.py +0 -0
  59. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/destinations.py +0 -0
  60. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/events.py +0 -0
  61. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/filters.py +0 -0
  62. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/organizations.py +0 -0
  63. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/portal_tokens.py +0 -0
  64. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/routes.py +0 -0
  65. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/schemas.py +0 -0
  66. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/transforms.py +0 -0
  67. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/models/tunnels.py +0 -0
  68. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/py.typed +0 -0
  69. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/_base.py +0 -0
  70. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/analytics.py +0 -0
  71. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/api_keys.py +0 -0
  72. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/applications.py +0 -0
  73. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/cron_jobs.py +0 -0
  74. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/deliveries.py +0 -0
  75. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/destinations.py +0 -0
  76. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/filters.py +0 -0
  77. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/organizations.py +0 -0
  78. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/portal_tokens.py +0 -0
  79. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/routes.py +0 -0
  80. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/schemas.py +0 -0
  81. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/transforms.py +0 -0
  82. {hookbase-3.1.0 → hookbase-3.3.0}/src/hookbase/resources/tunnels.py +0 -0
  83. {hookbase-3.1.0 → hookbase-3.3.0}/tests/__init__.py +0 -0
  84. {hookbase-3.1.0 → hookbase-3.3.0}/tests/conftest.py +0 -0
  85. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/__init__.py +0 -0
  86. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_applications.py +0 -0
  87. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_cron_jobs.py +0 -0
  88. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_destinations.py +0 -0
  89. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_endpoints.py +0 -0
  90. {hookbase-3.1.0 → hookbase-3.3.0}/tests/resources/test_sources.py +0 -0
  91. {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_client.py +0 -0
  92. {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_errors.py +0 -0
  93. {hookbase-3.1.0 → hookbase-3.3.0}/tests/test_pagination.py +0 -0
  94. {hookbase-3.1.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.1.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
@@ -98,7 +98,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
98
98
  client.sources.get("src_id")
99
99
  client.sources.update("src_id", {"name": "Updated"})
100
100
  client.sources.delete("src_id")
101
- client.sources.rotate_secret("src_id")
101
+ client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
102
+ client.sources.reveal_secret("src_id")
103
+
104
+ # A provider that verifies with no secret: signing_secret comes back None
105
+ wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
106
+ assert wise.verifies_without_secret and wise.signing_secret is None
107
+
108
+ # WhatsApp (Meta) sources also carry a verify token
109
+ wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
110
+ print(wa.verify_token)
111
+ client.sources.rotate_verify_token(wa.id)
112
+
113
+ # Notion parks its verification token until you adopt it
114
+ pending = client.sources.get_pending_verification("notion")
115
+ client.sources.adopt_pending_verification("notion")
116
+
117
+ # Provider catalog: which secret each provider needs (public, no auth needed)
118
+ for p in client.providers.list(category="payments").providers:
119
+ print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
120
+ client.providers.get("stripe").signature_verification
102
121
 
103
122
  # Destinations
104
123
  client.destinations.list()
@@ -111,7 +130,7 @@ client.routes.get_circuit_status("route_id")
111
130
  client.routes.reset_circuit("route_id")
112
131
 
113
132
  # Events & Deliveries
114
- client.events.list(source_id="src_id", from_date="2024-01-01")
133
+ client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
115
134
  client.events.get("evt_id") # Includes payload and deliveries
116
135
  client.deliveries.replay("del_id")
117
136
  client.deliveries.bulk_replay(["del_1", "del_2"])
@@ -252,11 +271,14 @@ All responses are typed Pydantic models with camelCase alias support:
252
271
  from hookbase.models import Source, CreateSourceParams
253
272
 
254
273
  # Use Pydantic models for type safety
255
- params = CreateSourceParams(name="GitHub", provider="github")
274
+ params = CreateSourceParams(name="GitHub", slug="github", provider="github")
256
275
  source = client.sources.create(params)
257
276
 
258
277
  # Or use plain dicts
259
- source = client.sources.create({"name": "GitHub", "provider": "github"})
278
+ source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
279
+
280
+ # `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
281
+ # source with a provider newer than this package still parses.
260
282
  ```
261
283
 
262
284
  ## Development
@@ -63,7 +63,26 @@ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"}
63
63
  client.sources.get("src_id")
64
64
  client.sources.update("src_id", {"name": "Updated"})
65
65
  client.sources.delete("src_id")
66
- client.sources.rotate_secret("src_id")
66
+ client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
67
+ client.sources.reveal_secret("src_id")
68
+
69
+ # A provider that verifies with no secret: signing_secret comes back None
70
+ wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
71
+ assert wise.verifies_without_secret and wise.signing_secret is None
72
+
73
+ # WhatsApp (Meta) sources also carry a verify token
74
+ wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
75
+ print(wa.verify_token)
76
+ client.sources.rotate_verify_token(wa.id)
77
+
78
+ # Notion parks its verification token until you adopt it
79
+ pending = client.sources.get_pending_verification("notion")
80
+ client.sources.adopt_pending_verification("notion")
81
+
82
+ # Provider catalog: which secret each provider needs (public, no auth needed)
83
+ for p in client.providers.list(category="payments").providers:
84
+ print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
85
+ client.providers.get("stripe").signature_verification
67
86
 
68
87
  # Destinations
69
88
  client.destinations.list()
@@ -76,7 +95,7 @@ client.routes.get_circuit_status("route_id")
76
95
  client.routes.reset_circuit("route_id")
77
96
 
78
97
  # Events & Deliveries
79
- client.events.list(source_id="src_id", from_date="2024-01-01")
98
+ client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
80
99
  client.events.get("evt_id") # Includes payload and deliveries
81
100
  client.deliveries.replay("del_id")
82
101
  client.deliveries.bulk_replay(["del_1", "del_2"])
@@ -217,11 +236,14 @@ All responses are typed Pydantic models with camelCase alias support:
217
236
  from hookbase.models import Source, CreateSourceParams
218
237
 
219
238
  # Use Pydantic models for type safety
220
- params = CreateSourceParams(name="GitHub", provider="github")
239
+ params = CreateSourceParams(name="GitHub", slug="github", provider="github")
221
240
  source = client.sources.create(params)
222
241
 
223
242
  # Or use plain dicts
224
- source = client.sources.create({"name": "GitHub", "provider": "github"})
243
+ source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
244
+
245
+ # `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
246
+ # source with a provider newer than this package still parses.
225
247
  ```
226
248
 
227
249
  ## Development
@@ -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"
@@ -26,6 +26,7 @@ from .resources import (
26
26
  AsyncMessages,
27
27
  AsyncOrganizations,
28
28
  AsyncPortalTokens,
29
+ AsyncProviders,
29
30
  AsyncRoutes,
30
31
  AsyncSchemas,
31
32
  AsyncSources,
@@ -43,6 +44,7 @@ from .resources import (
43
44
  Messages,
44
45
  Organizations,
45
46
  PortalTokens,
47
+ Providers,
46
48
  Routes,
47
49
  Schemas,
48
50
  Sources,
@@ -118,6 +120,8 @@ class Hookbase:
118
120
  transforms: Transforms
119
121
  filters: Filters
120
122
  schemas: Schemas
123
+ #: The public provider catalog (what each provider signs with, which secret it needs).
124
+ providers: Providers
121
125
 
122
126
  # Outbound
123
127
  outbound: _OutboundNamespace
@@ -160,6 +164,7 @@ class Hookbase:
160
164
  self.transforms = Transforms(self._transport)
161
165
  self.filters = Filters(self._transport)
162
166
  self.schemas = Schemas(self._transport)
167
+ self.providers = Providers(self._transport)
163
168
 
164
169
  # Outbound namespace
165
170
  self.outbound = _OutboundNamespace(self._transport)
@@ -210,6 +215,8 @@ class AsyncHookbase:
210
215
  transforms: AsyncTransforms
211
216
  filters: AsyncFilters
212
217
  schemas: AsyncSchemas
218
+ #: The public provider catalog (what each provider signs with, which secret it needs).
219
+ providers: AsyncProviders
213
220
 
214
221
  # Outbound
215
222
  outbound: _AsyncOutboundNamespace
@@ -252,6 +259,7 @@ class AsyncHookbase:
252
259
  self.transforms = AsyncTransforms(self._transport)
253
260
  self.filters = AsyncFilters(self._transport)
254
261
  self.schemas = AsyncSchemas(self._transport)
262
+ self.providers = AsyncProviders(self._transport)
255
263
 
256
264
  # Outbound namespace
257
265
  self.outbound = _AsyncOutboundNamespace(self._transport)
@@ -1,6 +1,16 @@
1
1
  from .analytics import AnalyticsTimeline, DashboardData
2
2
  from .api_keys import ApiKey, ApiKeyWithSecret, CreateApiKeyParams
3
3
  from .applications import Application, CreateApplicationParams, UpdateApplicationParams
4
+ from .catalog import (
5
+ CatalogProvider,
6
+ CatalogProviderDetail,
7
+ ProviderCatalogList,
8
+ ProviderEvents,
9
+ ProviderEventType,
10
+ ProviderSignatureVerification,
11
+ ProviderSignatureVerificationSummary,
12
+ ProviderVerifyTokenInfo,
13
+ )
4
14
  from .common import BulkDeleteResult, ImportResult, ImportResultItem
5
15
  from .cron_jobs import (
6
16
  CreateCronGroupParams,
@@ -89,9 +99,13 @@ from .routes import (
89
99
  )
90
100
  from .schemas import CreateSchemaParams, Schema, SchemaValidationResult, UpdateSchemaParams
91
101
  from .sources import (
102
+ CLEARABLE_SOURCE_FIELDS,
103
+ KNOWN_SOURCE_PROVIDERS,
104
+ ClearableSourceField,
92
105
  CreateSourceParams,
93
106
  DedupStrategy,
94
107
  IpFilterMode,
108
+ PendingVerification,
95
109
  Source,
96
110
  SourceProvider,
97
111
  SourceWithSecret,
@@ -119,6 +133,15 @@ __all__ = [
119
133
  "Application",
120
134
  "CreateApplicationParams",
121
135
  "UpdateApplicationParams",
136
+ # Catalog
137
+ "CatalogProvider",
138
+ "CatalogProviderDetail",
139
+ "ProviderCatalogList",
140
+ "ProviderEventType",
141
+ "ProviderEvents",
142
+ "ProviderSignatureVerification",
143
+ "ProviderSignatureVerificationSummary",
144
+ "ProviderVerifyTokenInfo",
122
145
  # Common
123
146
  "BulkDeleteResult",
124
147
  "ImportResult",
@@ -211,9 +234,13 @@ __all__ = [
211
234
  "SchemaValidationResult",
212
235
  "UpdateSchemaParams",
213
236
  # Sources
237
+ "CLEARABLE_SOURCE_FIELDS",
238
+ "KNOWN_SOURCE_PROVIDERS",
239
+ "ClearableSourceField",
214
240
  "CreateSourceParams",
215
241
  "DedupStrategy",
216
242
  "IpFilterMode",
243
+ "PendingVerification",
217
244
  "Source",
218
245
  "SourceProvider",
219
246
  "SourceWithSecret",
@@ -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,120 @@
1
+ """The public provider catalog: GET /api/catalog/providers (no authentication needed).
2
+
3
+ Every enumerated value here (category, kind, secret_source, secret_format) is a plain string
4
+ rather than a Literal, for the reason ``SourceProvider`` is: the catalog grows server-side, and a
5
+ value this package has not heard of must not make the whole response fail to parse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import field_validator
13
+
14
+ from ._base import HookbaseModel
15
+
16
+
17
+ class ProviderEventType(HookbaseModel):
18
+ type: str
19
+ description: str = ""
20
+
21
+
22
+ class ProviderVerifyTokenInfo(HookbaseModel):
23
+ """Meta's second, Hookbase-generated credential (see SourceWithSecret.verify_token)."""
24
+
25
+ label: str
26
+ instruction: str = ""
27
+
28
+
29
+ class ProviderSignatureVerificationSummary(HookbaseModel):
30
+ """The secret fields a create flow needs, as the catalog list carries them.
31
+
32
+ ``secret_source`` says who produces the credential: "provider" (the vendor issues it; paste
33
+ it as signing_secret), "operator" (Hookbase generates it when omitted; paste it into the
34
+ vendor), "public_key" (paste the vendor's public key), "built_in" (nothing to enter; events
35
+ are still verified) or "none" (the vendor does not sign).
36
+ """
37
+
38
+ supported: bool = False
39
+ #: The scheme kind: "hmac", "shared_secret", "bespoke" or "asymmetric". None when no scheme.
40
+ kind: str | None = None
41
+ secret_source: str | None = None
42
+ secret_label: str | None = None
43
+ secret_placeholder: str | None = None
44
+ #: The vendor issues the secret only after the source's URL is registered with it.
45
+ secret_after_registration: bool = False
46
+
47
+
48
+ class ProviderSignatureVerification(ProviderSignatureVerificationSummary):
49
+ """The full block from the provider detail call."""
50
+
51
+ algorithm: str | None = None
52
+ header_name: str | None = None
53
+ secret_location_hint: str | None = None
54
+ #: One sentence on where the value comes from or goes, in the vendor's own UI wording.
55
+ secret_instruction: str | None = None
56
+ #: "whsec" or "basic_auth" when the value has a required shape.
57
+ secret_format: str | None = None
58
+ #: Present for Meta providers, which also use a verify token.
59
+ verify_token: ProviderVerifyTokenInfo | None = None
60
+
61
+
62
+ class CatalogProvider(HookbaseModel):
63
+ """A provider as the catalog list returns it.
64
+
65
+ ``id`` is the catalog's id; ``hookbase_provider`` is the value to use as a source's
66
+ ``provider``. They differ for some entries (catalog "mollie-nextgen" is provider "mollie",
67
+ and several catalog entries map to "custom").
68
+ """
69
+
70
+ id: str
71
+ name: str
72
+ category: str = ""
73
+ description: str = ""
74
+ icon: str | None = None
75
+ event_type_count: int = 0
76
+ hookbase_provider: str = ""
77
+ has_signature_verification: bool = False
78
+ has_ip_ranges: bool = False
79
+ #: None when the provider has no verification block, and also from API versions that
80
+ #: predate this summary on list items: fall back to client.providers.get(id) for it.
81
+ signature_verification: ProviderSignatureVerificationSummary | None = None
82
+
83
+
84
+ class ProviderCatalogList(HookbaseModel):
85
+ providers: list[CatalogProvider] = []
86
+ categories: list[str] = []
87
+ total: int = 0
88
+
89
+
90
+ class CatalogProviderDetail(HookbaseModel):
91
+ """One provider in full, from GET /api/catalog/providers/:id."""
92
+
93
+ id: str
94
+ name: str
95
+ category: str = ""
96
+ description: str = ""
97
+ website: str | None = None
98
+ docs_url: str | None = None
99
+ icon: str | None = None
100
+ hookbase_provider: str = ""
101
+ signature_verification: ProviderSignatureVerification | None = None
102
+ event_types: list[ProviderEventType] = []
103
+ #: Example payloads keyed by event type.
104
+ sample_payloads: dict[str, Any] = {}
105
+ ip_ranges: list[str] | None = None
106
+ setup_guide: list[str] = []
107
+
108
+ @field_validator("event_types", "setup_guide", mode="before")
109
+ @classmethod
110
+ def _none_to_list(cls, v: Any) -> Any:
111
+ return [] if v is None else v
112
+
113
+
114
+ class ProviderEvents(HookbaseModel):
115
+ """GET /api/catalog/providers/:id/events."""
116
+
117
+ provider_id: str
118
+ provider_name: str = ""
119
+ event_types: list[ProviderEventType] = []
120
+ sample_payloads: 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"})