hookbase 4.0.0__tar.gz → 4.1.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 (105) hide show
  1. {hookbase-4.0.0 → hookbase-4.1.0}/.gitignore +3 -0
  2. {hookbase-4.0.0 → hookbase-4.1.0}/PKG-INFO +144 -17
  3. hookbase-4.1.0/README.md +390 -0
  4. {hookbase-4.0.0 → hookbase-4.1.0}/pyproject.toml +7 -2
  5. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/__init__.py +4 -0
  6. hookbase-4.1.0/src/hookbase/_client.py +397 -0
  7. hookbase-4.1.0/src/hookbase/_constants.py +33 -0
  8. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/_pagination.py +14 -1
  9. hookbase-4.1.0/src/hookbase/_version.py +1 -0
  10. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/client.py +19 -9
  11. hookbase-4.1.0/src/hookbase/errors.py +247 -0
  12. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/__init__.py +104 -3
  13. hookbase-4.1.0/src/hookbase/models/analytics.py +195 -0
  14. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/cron_jobs.py +13 -2
  15. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/dlq.py +1 -1
  16. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/endpoints.py +27 -2
  17. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/filters.py +3 -2
  18. hookbase-4.1.0/src/hookbase/models/organizations.py +63 -0
  19. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/portal_tokens.py +6 -0
  20. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/routes.py +4 -4
  21. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/schemas.py +10 -1
  22. hookbase-4.1.0/src/hookbase/models/tunnels.py +67 -0
  23. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/_base.py +16 -0
  24. hookbase-4.1.0/src/hookbase/resources/analytics.py +86 -0
  25. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/api_keys.py +3 -2
  26. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/cron_jobs.py +19 -2
  27. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/deliveries.py +6 -4
  28. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/destinations.py +12 -15
  29. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/dlq.py +5 -4
  30. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/filters.py +17 -8
  31. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/message_log.py +3 -2
  32. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/organizations.py +21 -14
  33. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/portal_tokens.py +3 -2
  34. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/routes.py +18 -15
  35. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/schemas.py +32 -1
  36. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/sources.py +16 -15
  37. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/subscriptions.py +7 -2
  38. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/transforms.py +10 -4
  39. hookbase-4.1.0/src/hookbase/resources/tunnels.py +103 -0
  40. hookbase-4.1.0/src/hookbase/webhook.py +337 -0
  41. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_outbound_contract.py +46 -0
  42. hookbase-4.1.0/tests/resources/test_resource_contracts.py +692 -0
  43. hookbase-4.1.0/tests/resources/test_sync_async_parity.py +418 -0
  44. {hookbase-4.0.0 → hookbase-4.1.0}/tests/test_errors.py +119 -0
  45. hookbase-4.1.0/tests/test_http_client.py +327 -0
  46. hookbase-4.1.0/tests/test_models_exports.py +44 -0
  47. {hookbase-4.0.0 → hookbase-4.1.0}/tests/test_pagination.py +50 -0
  48. hookbase-4.1.0/tests/test_retry.py +393 -0
  49. hookbase-4.1.0/tests/test_webhook.py +565 -0
  50. hookbase-4.0.0/README.md +0 -266
  51. hookbase-4.0.0/src/hookbase/_client.py +0 -347
  52. hookbase-4.0.0/src/hookbase/_constants.py +0 -4
  53. hookbase-4.0.0/src/hookbase/_version.py +0 -1
  54. hookbase-4.0.0/src/hookbase/errors.py +0 -173
  55. hookbase-4.0.0/src/hookbase/models/analytics.py +0 -22
  56. hookbase-4.0.0/src/hookbase/models/organizations.py +0 -32
  57. hookbase-4.0.0/src/hookbase/models/tunnels.py +0 -19
  58. hookbase-4.0.0/src/hookbase/resources/analytics.py +0 -56
  59. hookbase-4.0.0/src/hookbase/resources/tunnels.py +0 -51
  60. hookbase-4.0.0/src/hookbase/webhook.py +0 -182
  61. hookbase-4.0.0/tests/test_webhook.py +0 -211
  62. {hookbase-4.0.0 → hookbase-4.1.0}/.github/workflows/python-publish.yml +0 -0
  63. {hookbase-4.0.0 → hookbase-4.1.0}/LICENSE +0 -0
  64. {hookbase-4.0.0 → hookbase-4.1.0}/examples/async_usage.py +0 -0
  65. {hookbase-4.0.0 → hookbase-4.1.0}/examples/basic_inbound.py +0 -0
  66. {hookbase-4.0.0 → hookbase-4.1.0}/examples/error_handling.py +0 -0
  67. {hookbase-4.0.0 → hookbase-4.1.0}/examples/send_webhooks.py +0 -0
  68. {hookbase-4.0.0 → hookbase-4.1.0}/examples/verify_webhooks.py +0 -0
  69. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/_base.py +0 -0
  70. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/_wire.py +0 -0
  71. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/api_keys.py +0 -0
  72. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/applications.py +0 -0
  73. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/catalog.py +0 -0
  74. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/common.py +0 -0
  75. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/deliveries.py +0 -0
  76. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/destinations.py +0 -0
  77. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/event_types.py +0 -0
  78. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/events.py +0 -0
  79. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/messages.py +0 -0
  80. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/sources.py +0 -0
  81. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/subscriptions.py +0 -0
  82. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/models/transforms.py +0 -0
  83. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/py.typed +0 -0
  84. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/__init__.py +0 -0
  85. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/applications.py +0 -0
  86. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/endpoints.py +0 -0
  87. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/event_types.py +0 -0
  88. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/events.py +0 -0
  89. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/messages.py +0 -0
  90. {hookbase-4.0.0 → hookbase-4.1.0}/src/hookbase/resources/providers.py +0 -0
  91. {hookbase-4.0.0 → hookbase-4.1.0}/tests/__init__.py +0 -0
  92. {hookbase-4.0.0 → hookbase-4.1.0}/tests/conftest.py +0 -0
  93. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/__init__.py +0 -0
  94. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_applications.py +0 -0
  95. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_cron_jobs.py +0 -0
  96. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_destinations.py +0 -0
  97. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_dlq.py +0 -0
  98. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_endpoints.py +0 -0
  99. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_inbound_contract.py +0 -0
  100. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_messages.py +0 -0
  101. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_source_contract.py +0 -0
  102. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_source_fields.py +0 -0
  103. {hookbase-4.0.0 → hookbase-4.1.0}/tests/resources/test_sources.py +0 -0
  104. {hookbase-4.0.0 → hookbase-4.1.0}/tests/test_client.py +0 -0
  105. {hookbase-4.0.0 → hookbase-4.1.0}/tests/test_slug_contract.py +0 -0
@@ -11,3 +11,6 @@ build/
11
11
  *.so
12
12
  .venv/
13
13
  venv/
14
+ .coverage
15
+ .coverage.*
16
+ htmlcov/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 4.0.0
3
+ Version: 4.1.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
7
- Project-URL: Repository, https://github.com/hookbase/hookbase-python
8
- Project-URL: Issues, https://github.com/hookbase/hookbase-python/issues
7
+ Project-URL: Repository, https://github.com/HookbaseApp/hookbase-python
8
+ Project-URL: Issues, https://github.com/HookbaseApp/hookbase-python/issues
9
9
  Author-email: Hookbase <support@hookbase.app>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
@@ -26,11 +26,14 @@ Requires-Dist: eval-type-backport>=0.2.0; python_version < '3.10'
26
26
  Requires-Dist: httpx<1.0.0,>=0.25.0
27
27
  Requires-Dist: pydantic<3.0.0,>=2.0.0
28
28
  Provides-Extra: dev
29
+ Requires-Dist: cryptography>=38.0; extra == 'dev'
29
30
  Requires-Dist: mypy>=1.0; extra == 'dev'
30
31
  Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
31
32
  Requires-Dist: pytest>=7.0; extra == 'dev'
32
33
  Requires-Dist: respx>=0.21; extra == 'dev'
33
34
  Requires-Dist: ruff>=0.1; extra == 'dev'
35
+ Provides-Extra: ed25519
36
+ Requires-Dist: cryptography>=38.0; extra == 'ed25519'
34
37
  Description-Content-Type: text/markdown
35
38
 
36
39
  # Hookbase Python SDK
@@ -41,6 +44,8 @@ The official Python SDK for the [Hookbase](https://hookbase.app) webhook managem
41
44
 
42
45
  ```bash
43
46
  pip install hookbase
47
+ # To verify Ed25519 (v1a) signed webhooks, include the optional extra:
48
+ pip install 'hookbase[ed25519]'
44
49
  ```
45
50
 
46
51
  ## Quick Start
@@ -83,17 +88,38 @@ from hookbase import Hookbase
83
88
  client = Hookbase(
84
89
  api_key="whr_...", # Required
85
90
  base_url="https://...", # Default: https://api.hookbase.app
86
- timeout=30.0, # Seconds (default: 30)
91
+ timeout=30.0, # Seconds or httpx.Timeout (default: 30); None = no timeout
87
92
  max_retries=3, # Default: 3
88
93
  debug=False, # Log requests
89
94
  )
90
95
  ```
91
96
 
97
+ #### Custom `httpx` client
98
+
99
+ Pass your own `httpx.Client` (or `httpx.AsyncClient` for `AsyncHookbase`) for proxies, custom
100
+ transports or TLS settings. The SDK uses it as given and never modifies it: every request is
101
+ sent to an absolute URL built from `base_url`, with the SDK's `Authorization` (an `auth=` set on
102
+ your client is not applied to Hookbase requests) and `User-Agent` headers. If you pass `timeout`
103
+ (a number, an `httpx.Timeout`, or `None` for no timeout), it is applied to each request; if you
104
+ leave it out, the client's own timeout applies. `close()` (and the context manager) leaves a client you supplied open, so close
105
+ it yourself.
106
+
107
+ ```python
108
+ import httpx
109
+ from hookbase import Hookbase
110
+
111
+ http = httpx.Client(verify="/etc/ssl/corp-ca.pem", timeout=10.0)
112
+ client = Hookbase(api_key="whr_...", http_client=http) # uses the 10s client timeout
113
+ ...
114
+ client.close()
115
+ http.close()
116
+ ```
117
+
92
118
  ### Inbound Webhooks
93
119
 
94
120
  ```python
95
121
  # Sources
96
- client.sources.list(search="github", provider="github")
122
+ client.sources.list(page_size=50) # the API has no search/provider filter on this list
97
123
  client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
98
124
  client.sources.get("src_id")
99
125
  client.sources.update("src_id", {"name": "Updated"})
@@ -138,10 +164,14 @@ client.deliveries.bulk_replay(["del_1", "del_2"])
138
164
  # Transforms & Filters
139
165
  client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
140
166
  client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
141
- client.filters.test([{"field": "$.type", "operator": "eq", "value": "order"}], payload={...})
167
+ client.filters.test(
168
+ [{"field": "type", "operator": "equals", "value": "order"}], # dot paths: "data.status"
169
+ payload={"type": "order"},
170
+ logic="AND", # or "OR"; sent uppercase
171
+ )
142
172
 
143
173
  # Schemas
144
- client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", ...}})
174
+ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", "required": ["orderId"]}})
145
175
  ```
146
176
 
147
177
  ### Outbound Webhooks
@@ -165,7 +195,7 @@ client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_
165
195
  client.outbound.subscriptions.bulk_create("ep_id", ["et_1", "et_2"])
166
196
 
167
197
  # Send Events
168
- client.outbound.messages.send("app_id", event_type="order.created", payload={...})
198
+ client.outbound.messages.send("app_id", event_type="order.created", payload={"orderId": "ord_1"})
169
199
 
170
200
  # Message Log
171
201
  client.outbound.message_log.list(application_id="app_id")
@@ -182,20 +212,30 @@ client.outbound.dlq.retry_bulk(["dlq_1", "dlq_2"])
182
212
  ### Admin
183
213
 
184
214
  ```python
185
- # Organizations
215
+ # Organizations and creating API keys need a user session: with a whr_ API key the
216
+ # API answers 403 (ForbiddenError).
186
217
  client.organizations.list()
187
218
  client.organizations.list_members("org_id")
188
219
  client.organizations.invite("org_id", "user@example.com", "member")
189
-
190
- # API Keys
191
220
  client.api_keys.create({"name": "CI Key", "scopes": ["read", "write"]})
192
221
 
193
- # Analytics
194
- client.analytics.dashboard(range="30d")
222
+ # Analytics: range 1h, 24h, 7d or 30d (or "custom" with start_date/end_date)
223
+ stats = client.analytics.dashboard(range="30d", tz="America/New_York")
224
+ stats.overview.total_events, stats.latency.percentiles.p95, stats.errors.by_status_code
225
+
226
+ # Schemas
227
+ client.schemas.validate("sch_id", {"orderId": "ord_1"}) # .valid, .errors[].path/.message
195
228
 
196
- # Cron Jobs & Tunnels
229
+ # Cron jobs
197
230
  client.cron_jobs.list()
198
- client.tunnels.list()
231
+ client.cron_jobs.trigger("cron_id") # .status, .response_status, .error
232
+ client.cron_jobs.list_executions("cron_id", limit=10)
233
+
234
+ # Tunnels: the auth token is returned once, on create
235
+ tunnel = client.tunnels.create({"name": "dev"})
236
+ tunnel.auth_token, tunnel.tunnel_url, tunnel.ws_url
237
+ client.tunnels.regenerate_token(tunnel.id) # new token; old connections are dropped
238
+ client.tunnels.delete(tunnel.id)
199
239
  ```
200
240
 
201
241
  ### Pagination
@@ -213,9 +253,12 @@ for source in page.auto_paging_iter():
213
253
  while page.has_more:
214
254
  page = page.next_page()
215
255
 
256
+ # Schemas and cron jobs are not paginated by the API: one page holding everything
257
+ schemas = client.schemas.list()
258
+
216
259
  # Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
217
- page = client.outbound.applications.list(limit=50)
218
- for app in page.auto_paging_iter():
260
+ apps = client.outbound.applications.list(limit=50)
261
+ for app in apps.auto_paging_iter():
219
262
  print(app.name)
220
263
  for event in client.events.list(limit=100).auto_paging_iter():
221
264
  print(event.id)
@@ -242,6 +285,32 @@ except WebhookVerificationError as e:
242
285
  headers = wh.generate_test_headers('{"test": true}')
243
286
  ```
244
287
 
288
+ #### Ed25519 (`v1a`) endpoints
289
+
290
+ Endpoints created with `signature_scheme="ed25519"` are signed with Ed25519
291
+ (Standard Webhooks `v1a,<sig>`). Hookbase keeps the private key; verify with the
292
+ endpoint's `whpk_` public key. This needs `pip install 'hookbase[ed25519]'`.
293
+
294
+ ```python
295
+ from hookbase.models import CreateEndpointParams
296
+
297
+ ep = client.outbound.endpoints.create(
298
+ "app_id", CreateEndpointParams(url="https://example.com/hook", signature_scheme="ed25519")
299
+ )
300
+ assert ep.public_key is not None # set for Ed25519 endpoints
301
+ wh = Webhook(ep.public_key) # "whpk_..."; same verify() API, tolerance and errors as v1
302
+ payload = wh.verify(request_body, request_headers)
303
+
304
+ # Rotation returns the new public key (no secret). During the grace period
305
+ # deliveries carry `v1a,<old> v1a,<new>`, so either key verifies them.
306
+ rotated = client.outbound.endpoints.rotate_secret(ep.id, grace_period=3600)
307
+ rotated.public_key, rotated.previous_public_key
308
+ ```
309
+
310
+ The key decides the scheme (`wh.signature_scheme`): a `whsec_` secret checks
311
+ `v1` entries only and a `whpk_` key checks `v1a` entries only, so a header that
312
+ mixes both verifies with either.
313
+
245
314
  ### Error Handling
246
315
 
247
316
  ```python
@@ -269,6 +338,35 @@ except APIError as e:
269
338
  print(f"{e.status_code}: {e} (request_id={e.request_id})")
270
339
  ```
271
340
 
341
+ Every `APIError` carries:
342
+
343
+ - `status_code`: the HTTP status actually returned (a `ValidationError` is 400 or 422).
344
+ - `code`: the `code` from the response body, e.g. `LIMIT_EXCEEDED` or `FEATURE_DISABLED` on a
345
+ `ForbiddenError`. When the body has no `code`, it is the class default (`forbidden`,
346
+ `not_found`, `validation_error`, ...).
347
+ - `details`: the body's `details` object (for validation failures, `{"fieldErrors": ...,
348
+ "formErrors": ...}`), or any extra fields the error sent (such as `current` and `max` on
349
+ `LIMIT_EXCEEDED`); `None` when there are none. `ValidationError.validation_errors` is the
350
+ `fieldErrors` map.
351
+ - `request_id`: the `x-request-id` response header.
352
+
353
+ `TimeoutError` and `NetworkError` (connection failures, and a `base_url` that is not an
354
+ absolute http(s) URL) are raised for requests that got no response.
355
+
356
+ ### Retries
357
+
358
+ Up to `max_retries` retries (default 3):
359
+
360
+ - **429** is retried for every method after `Retry-After` (seconds or an HTTP date; 60 s
361
+ when the header is missing or unusable), as long as that wait is at most 60 s. A longer
362
+ `Retry-After` is not waited for: the `RateLimitError` is raised at once with `retry_after`
363
+ set, so you decide how to spend that time.
364
+ - **Any 5xx, read timeouts and dropped connections** are retried with exponential backoff
365
+ (1, 2, 4 ... s, capped at 10 s, plus up to 1 s jitter) for every method except POST. A POST
366
+ is retried only when it carries an idempotency key (`outbound.messages.send` always does).
367
+ - **A connection that was never made** is always retried.
368
+ - **Other 4xx** responses are never retried.
369
+
272
370
  ### Pydantic Models
273
371
 
274
372
  All responses are typed Pydantic models with camelCase alias support:
@@ -287,6 +385,35 @@ source = client.sources.create({"name": "GitHub", "slug": "github", "provider":
287
385
  # source with a provider newer than this package still parses.
288
386
  ```
289
387
 
388
+ ## Upgrading to 4.1
389
+
390
+ - `APIError.code` now carries the API's `code` from the response body (for example
391
+ `FEATURE_DISABLED` or `LIMIT_EXCEEDED` on a `ForbiddenError`, `DUPLICATE_NAME` on a
392
+ `ConflictError`) instead of a fixed string such as `"forbidden"` or `"conflict"`. The fixed
393
+ string is still used when the body has no `code`. Check code that compares `.code` to those
394
+ strings; `isinstance` checks on the error classes are unaffected.
395
+ - `ValidationError.status_code` is the status actually returned (400 or 422); it used to be
396
+ always 400.
397
+ - `APIError.details` is now populated (see [Error Handling](#error-handling)).
398
+ - A custom `http_client` no longer needs `base_url`, auth headers or a timeout configured on it,
399
+ and `close()` no longer closes it.
400
+ - `timeout=None` now means no timeout; leave `timeout` out to get the default.
401
+ - Every 5xx (not only 500/502/503/504) is retried for retry-safe requests, and `Retry-After`
402
+ may be an HTTP date. A 429 whose `Retry-After` is longer than 60 s is no longer slept
403
+ through: `RateLimitError` is raised at once, `retry_after` set. An empty 2xx body returns
404
+ `None`; a 2xx body that is not valid JSON raises `APIError` (`code="invalid_response"`).
405
+ - `Webhook("whsec_...")` raises `ValueError` for a secret that is not valid base64, at
406
+ construction rather than on the first `verify()`.
407
+ - `analytics.dashboard()` returns the API's real shape (`overview`, `latency`, `errors`, ...).
408
+ The old top-level fields (`events_received`, `delivery_success_rate`, ...) are now
409
+ read-only properties derived from `overview`; before, they were always 0.
410
+ - `sources.list(search=, provider=, is_active=)`, `destinations.list(search=, is_active=)` and
411
+ `routes.list(source_id=, destination_id=, is_active=)` warn (`DeprecationWarning`) and do
412
+ not send those filters, which the API never applied.
413
+ - `transforms.update()` and `organizations.update()` make a second request to return the
414
+ stored object (they used to raise on the API's `{"success": true}` reply).
415
+ - `SchemaValidationResult.errors` is a list of `SchemaValidationError` (`path`, `message`).
416
+
290
417
  ## Development
291
418
 
292
419
  ```bash
@@ -0,0 +1,390 @@
1
+ # Hookbase Python SDK
2
+
3
+ The official Python SDK for the [Hookbase](https://hookbase.app) webhook management platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hookbase
9
+ # To verify Ed25519 (v1a) signed webhooks, include the optional extra:
10
+ pip install 'hookbase[ed25519]'
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from hookbase import Hookbase
17
+
18
+ client = Hookbase(api_key="whr_your_api_key")
19
+
20
+ # List webhook sources
21
+ sources = client.sources.list()
22
+ for source in sources.data:
23
+ print(f"{source.name} ({source.provider})")
24
+
25
+ # Send an outbound webhook
26
+ result = client.outbound.messages.send(
27
+ "app_123",
28
+ event_type="order.created",
29
+ payload={"orderId": "456", "amount": 99.99},
30
+ )
31
+ print(f"Sent: {result.event_id}")
32
+ ```
33
+
34
+ ## Async Support
35
+
36
+ ```python
37
+ from hookbase import AsyncHookbase
38
+
39
+ async with AsyncHookbase(api_key="whr_...") as client:
40
+ sources = await client.sources.list()
41
+ ```
42
+
43
+ ## API Reference
44
+
45
+ ### Client Initialization
46
+
47
+ ```python
48
+ from hookbase import Hookbase
49
+
50
+ client = Hookbase(
51
+ api_key="whr_...", # Required
52
+ base_url="https://...", # Default: https://api.hookbase.app
53
+ timeout=30.0, # Seconds or httpx.Timeout (default: 30); None = no timeout
54
+ max_retries=3, # Default: 3
55
+ debug=False, # Log requests
56
+ )
57
+ ```
58
+
59
+ #### Custom `httpx` client
60
+
61
+ Pass your own `httpx.Client` (or `httpx.AsyncClient` for `AsyncHookbase`) for proxies, custom
62
+ transports or TLS settings. The SDK uses it as given and never modifies it: every request is
63
+ sent to an absolute URL built from `base_url`, with the SDK's `Authorization` (an `auth=` set on
64
+ your client is not applied to Hookbase requests) and `User-Agent` headers. If you pass `timeout`
65
+ (a number, an `httpx.Timeout`, or `None` for no timeout), it is applied to each request; if you
66
+ leave it out, the client's own timeout applies. `close()` (and the context manager) leaves a client you supplied open, so close
67
+ it yourself.
68
+
69
+ ```python
70
+ import httpx
71
+ from hookbase import Hookbase
72
+
73
+ http = httpx.Client(verify="/etc/ssl/corp-ca.pem", timeout=10.0)
74
+ client = Hookbase(api_key="whr_...", http_client=http) # uses the 10s client timeout
75
+ ...
76
+ client.close()
77
+ http.close()
78
+ ```
79
+
80
+ ### Inbound Webhooks
81
+
82
+ ```python
83
+ # Sources
84
+ client.sources.list(page_size=50) # the API has no search/provider filter on this list
85
+ client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
86
+ client.sources.get("src_id")
87
+ client.sources.update("src_id", {"name": "Updated"})
88
+ client.sources.delete("src_id")
89
+ client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
90
+ client.sources.reveal_secret("src_id")
91
+
92
+ # A provider that verifies with no secret: signing_secret comes back None
93
+ wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
94
+ assert wise.verifies_without_secret and wise.signing_secret is None
95
+
96
+ # WhatsApp (Meta) sources also carry a verify token
97
+ wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
98
+ print(wa.verify_token)
99
+ client.sources.rotate_verify_token(wa.id)
100
+
101
+ # Notion parks its verification token until you adopt it
102
+ pending = client.sources.get_pending_verification("notion")
103
+ client.sources.adopt_pending_verification("notion")
104
+
105
+ # Provider catalog: which secret each provider needs (public, no auth needed)
106
+ for p in client.providers.list(category="payments").providers:
107
+ print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
108
+ client.providers.get("stripe").signature_verification
109
+
110
+ # Destinations
111
+ client.destinations.list()
112
+ client.destinations.create({"name": "Backend", "url": "https://..."})
113
+ client.destinations.test("dst_id")
114
+
115
+ # Routes
116
+ client.routes.create({"name": "Route", "sourceId": "src_id", "destinationId": "dst_id"})
117
+ client.routes.get_circuit_status("route_id")
118
+ client.routes.reset_circuit("route_id")
119
+
120
+ # Events & Deliveries
121
+ client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
122
+ client.events.get("evt_id") # Includes payload and deliveries
123
+ client.deliveries.replay("del_id")
124
+ client.deliveries.bulk_replay(["del_1", "del_2"])
125
+
126
+ # Transforms & Filters
127
+ client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
128
+ client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
129
+ client.filters.test(
130
+ [{"field": "type", "operator": "equals", "value": "order"}], # dot paths: "data.status"
131
+ payload={"type": "order"},
132
+ logic="AND", # or "OR"; sent uppercase
133
+ )
134
+
135
+ # Schemas
136
+ client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", "required": ["orderId"]}})
137
+ ```
138
+
139
+ ### Outbound Webhooks
140
+
141
+ ```python
142
+ # Applications
143
+ client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
144
+ client.outbound.applications.get_by_external_id("cust_123")
145
+
146
+ # Endpoints
147
+ client.outbound.endpoints.create("app_id", {"url": "https://..."})
148
+ client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
149
+
150
+ # An unset field is left alone by an update, so `None` cannot also mean "reset
151
+ # this". Name the setting in `clear` to send an explicit null for it:
152
+ client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
153
+
154
+ # Event Types & Subscriptions
155
+ client.outbound.event_types.create({"name": "order.created", "category": "orders"})
156
+ client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
157
+ client.outbound.subscriptions.bulk_create("ep_id", ["et_1", "et_2"])
158
+
159
+ # Send Events
160
+ client.outbound.messages.send("app_id", event_type="order.created", payload={"orderId": "ord_1"})
161
+
162
+ # Message Log
163
+ client.outbound.message_log.list(application_id="app_id")
164
+ client.outbound.message_log.list_attempts("msg_id")
165
+ client.outbound.message_log.stats()
166
+
167
+ # Dead Letter Queue
168
+ client.outbound.dlq.list()
169
+ client.outbound.dlq.stats()
170
+ client.outbound.dlq.retry("dlq_id")
171
+ client.outbound.dlq.retry_bulk(["dlq_1", "dlq_2"])
172
+ ```
173
+
174
+ ### Admin
175
+
176
+ ```python
177
+ # Organizations and creating API keys need a user session: with a whr_ API key the
178
+ # API answers 403 (ForbiddenError).
179
+ client.organizations.list()
180
+ client.organizations.list_members("org_id")
181
+ client.organizations.invite("org_id", "user@example.com", "member")
182
+ client.api_keys.create({"name": "CI Key", "scopes": ["read", "write"]})
183
+
184
+ # Analytics: range 1h, 24h, 7d or 30d (or "custom" with start_date/end_date)
185
+ stats = client.analytics.dashboard(range="30d", tz="America/New_York")
186
+ stats.overview.total_events, stats.latency.percentiles.p95, stats.errors.by_status_code
187
+
188
+ # Schemas
189
+ client.schemas.validate("sch_id", {"orderId": "ord_1"}) # .valid, .errors[].path/.message
190
+
191
+ # Cron jobs
192
+ client.cron_jobs.list()
193
+ client.cron_jobs.trigger("cron_id") # .status, .response_status, .error
194
+ client.cron_jobs.list_executions("cron_id", limit=10)
195
+
196
+ # Tunnels: the auth token is returned once, on create
197
+ tunnel = client.tunnels.create({"name": "dev"})
198
+ tunnel.auth_token, tunnel.tunnel_url, tunnel.ws_url
199
+ client.tunnels.regenerate_token(tunnel.id) # new token; old connections are dropped
200
+ client.tunnels.delete(tunnel.id)
201
+ ```
202
+
203
+ ### Pagination
204
+
205
+ ```python
206
+ # Offset-based (sources, destinations, routes, etc.)
207
+ page = client.sources.list(page_size=10)
208
+ print(f"Total: {page.total}, Page: {page.page}")
209
+
210
+ # Auto-paginate through all items
211
+ for source in page.auto_paging_iter():
212
+ print(source.name)
213
+
214
+ # Manual pagination
215
+ while page.has_more:
216
+ page = page.next_page()
217
+
218
+ # Schemas and cron jobs are not paginated by the API: one page holding everything
219
+ schemas = client.schemas.list()
220
+
221
+ # Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
222
+ apps = client.outbound.applications.list(limit=50)
223
+ for app in apps.auto_paging_iter():
224
+ print(app.name)
225
+ for event in client.events.list(limit=100).auto_paging_iter():
226
+ print(event.id)
227
+
228
+ # Limit/offset with no total (deliveries): pages until one comes back short
229
+ for delivery in client.deliveries.list(status="failed", limit=100).auto_paging_iter():
230
+ print(delivery.id, delivery.response_status)
231
+ ```
232
+
233
+ ### Webhook Verification
234
+
235
+ ```python
236
+ from hookbase import Webhook, WebhookVerificationError
237
+
238
+ wh = Webhook("whsec_your_secret")
239
+
240
+ try:
241
+ payload = wh.verify(request_body, request_headers)
242
+ print(f"Verified: {payload}")
243
+ except WebhookVerificationError as e:
244
+ print(f"Invalid: {e}")
245
+
246
+ # Generate test headers
247
+ headers = wh.generate_test_headers('{"test": true}')
248
+ ```
249
+
250
+ #### Ed25519 (`v1a`) endpoints
251
+
252
+ Endpoints created with `signature_scheme="ed25519"` are signed with Ed25519
253
+ (Standard Webhooks `v1a,<sig>`). Hookbase keeps the private key; verify with the
254
+ endpoint's `whpk_` public key. This needs `pip install 'hookbase[ed25519]'`.
255
+
256
+ ```python
257
+ from hookbase.models import CreateEndpointParams
258
+
259
+ ep = client.outbound.endpoints.create(
260
+ "app_id", CreateEndpointParams(url="https://example.com/hook", signature_scheme="ed25519")
261
+ )
262
+ assert ep.public_key is not None # set for Ed25519 endpoints
263
+ wh = Webhook(ep.public_key) # "whpk_..."; same verify() API, tolerance and errors as v1
264
+ payload = wh.verify(request_body, request_headers)
265
+
266
+ # Rotation returns the new public key (no secret). During the grace period
267
+ # deliveries carry `v1a,<old> v1a,<new>`, so either key verifies them.
268
+ rotated = client.outbound.endpoints.rotate_secret(ep.id, grace_period=3600)
269
+ rotated.public_key, rotated.previous_public_key
270
+ ```
271
+
272
+ The key decides the scheme (`wh.signature_scheme`): a `whsec_` secret checks
273
+ `v1` entries only and a `whpk_` key checks `v1a` entries only, so a header that
274
+ mixes both verifies with either.
275
+
276
+ ### Error Handling
277
+
278
+ ```python
279
+ from hookbase import (
280
+ HookbaseError, # Base error
281
+ APIError, # API returned an error
282
+ AuthenticationError, # 401
283
+ ForbiddenError, # 403
284
+ NotFoundError, # 404
285
+ ValidationError, # 400/422
286
+ ConflictError, # 409
287
+ RateLimitError, # 429 (has retry_after)
288
+ TimeoutError, # Request timed out
289
+ NetworkError, # Connection failed
290
+ WebhookVerificationError,
291
+ )
292
+
293
+ try:
294
+ client.sources.get("src_id")
295
+ except NotFoundError:
296
+ print("Not found")
297
+ except RateLimitError as e:
298
+ print(f"Retry after {e.retry_after}s")
299
+ except APIError as e:
300
+ print(f"{e.status_code}: {e} (request_id={e.request_id})")
301
+ ```
302
+
303
+ Every `APIError` carries:
304
+
305
+ - `status_code`: the HTTP status actually returned (a `ValidationError` is 400 or 422).
306
+ - `code`: the `code` from the response body, e.g. `LIMIT_EXCEEDED` or `FEATURE_DISABLED` on a
307
+ `ForbiddenError`. When the body has no `code`, it is the class default (`forbidden`,
308
+ `not_found`, `validation_error`, ...).
309
+ - `details`: the body's `details` object (for validation failures, `{"fieldErrors": ...,
310
+ "formErrors": ...}`), or any extra fields the error sent (such as `current` and `max` on
311
+ `LIMIT_EXCEEDED`); `None` when there are none. `ValidationError.validation_errors` is the
312
+ `fieldErrors` map.
313
+ - `request_id`: the `x-request-id` response header.
314
+
315
+ `TimeoutError` and `NetworkError` (connection failures, and a `base_url` that is not an
316
+ absolute http(s) URL) are raised for requests that got no response.
317
+
318
+ ### Retries
319
+
320
+ Up to `max_retries` retries (default 3):
321
+
322
+ - **429** is retried for every method after `Retry-After` (seconds or an HTTP date; 60 s
323
+ when the header is missing or unusable), as long as that wait is at most 60 s. A longer
324
+ `Retry-After` is not waited for: the `RateLimitError` is raised at once with `retry_after`
325
+ set, so you decide how to spend that time.
326
+ - **Any 5xx, read timeouts and dropped connections** are retried with exponential backoff
327
+ (1, 2, 4 ... s, capped at 10 s, plus up to 1 s jitter) for every method except POST. A POST
328
+ is retried only when it carries an idempotency key (`outbound.messages.send` always does).
329
+ - **A connection that was never made** is always retried.
330
+ - **Other 4xx** responses are never retried.
331
+
332
+ ### Pydantic Models
333
+
334
+ All responses are typed Pydantic models with camelCase alias support:
335
+
336
+ ```python
337
+ from hookbase.models import Source, CreateSourceParams
338
+
339
+ # Use Pydantic models for type safety
340
+ params = CreateSourceParams(name="GitHub", slug="github", provider="github")
341
+ source = client.sources.create(params)
342
+
343
+ # Or use plain dicts
344
+ source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
345
+
346
+ # `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
347
+ # source with a provider newer than this package still parses.
348
+ ```
349
+
350
+ ## Upgrading to 4.1
351
+
352
+ - `APIError.code` now carries the API's `code` from the response body (for example
353
+ `FEATURE_DISABLED` or `LIMIT_EXCEEDED` on a `ForbiddenError`, `DUPLICATE_NAME` on a
354
+ `ConflictError`) instead of a fixed string such as `"forbidden"` or `"conflict"`. The fixed
355
+ string is still used when the body has no `code`. Check code that compares `.code` to those
356
+ strings; `isinstance` checks on the error classes are unaffected.
357
+ - `ValidationError.status_code` is the status actually returned (400 or 422); it used to be
358
+ always 400.
359
+ - `APIError.details` is now populated (see [Error Handling](#error-handling)).
360
+ - A custom `http_client` no longer needs `base_url`, auth headers or a timeout configured on it,
361
+ and `close()` no longer closes it.
362
+ - `timeout=None` now means no timeout; leave `timeout` out to get the default.
363
+ - Every 5xx (not only 500/502/503/504) is retried for retry-safe requests, and `Retry-After`
364
+ may be an HTTP date. A 429 whose `Retry-After` is longer than 60 s is no longer slept
365
+ through: `RateLimitError` is raised at once, `retry_after` set. An empty 2xx body returns
366
+ `None`; a 2xx body that is not valid JSON raises `APIError` (`code="invalid_response"`).
367
+ - `Webhook("whsec_...")` raises `ValueError` for a secret that is not valid base64, at
368
+ construction rather than on the first `verify()`.
369
+ - `analytics.dashboard()` returns the API's real shape (`overview`, `latency`, `errors`, ...).
370
+ The old top-level fields (`events_received`, `delivery_success_rate`, ...) are now
371
+ read-only properties derived from `overview`; before, they were always 0.
372
+ - `sources.list(search=, provider=, is_active=)`, `destinations.list(search=, is_active=)` and
373
+ `routes.list(source_id=, destination_id=, is_active=)` warn (`DeprecationWarning`) and do
374
+ not send those filters, which the API never applied.
375
+ - `transforms.update()` and `organizations.update()` make a second request to return the
376
+ stored object (they used to raise on the API's `{"success": true}` reply).
377
+ - `SchemaValidationResult.errors` is a list of `SchemaValidationError` (`path`, `message`).
378
+
379
+ ## Development
380
+
381
+ ```bash
382
+ pip install -e ".[dev]"
383
+ pytest tests/ -v
384
+ mypy src/hookbase
385
+ ruff check src/ tests/
386
+ ```
387
+
388
+ ## License
389
+
390
+ MIT
@@ -33,7 +33,12 @@ dependencies = [
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
36
+ # Verifying Ed25519 (Standard Webhooks `v1a`) signatures: Webhook("whpk_...").
37
+ ed25519 = [
38
+ "cryptography>=38.0",
39
+ ]
36
40
  dev = [
41
+ "cryptography>=38.0",
37
42
  "pytest>=7.0",
38
43
  "pytest-asyncio>=0.21",
39
44
  "respx>=0.21",
@@ -44,8 +49,8 @@ dev = [
44
49
  [project.urls]
45
50
  Homepage = "https://hookbase.app"
46
51
  Documentation = "https://www.hookbase.app/docs/send/sdk"
47
- Repository = "https://github.com/hookbase/hookbase-python"
48
- Issues = "https://github.com/hookbase/hookbase-python/issues"
52
+ Repository = "https://github.com/HookbaseApp/hookbase-python"
53
+ Issues = "https://github.com/HookbaseApp/hookbase-python/issues"
49
54
 
50
55
  [tool.hatch.version]
51
56
  path = "src/hookbase/_version.py"