hookbase 3.3.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.
- {hookbase-3.3.0 → hookbase-4.1.0}/.github/workflows/python-publish.yml +3 -1
- {hookbase-3.3.0 → hookbase-4.1.0}/.gitignore +3 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/PKG-INFO +152 -19
- hookbase-4.1.0/README.md +390 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/pyproject.toml +7 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/__init__.py +4 -0
- hookbase-4.1.0/src/hookbase/_client.py +397 -0
- hookbase-4.1.0/src/hookbase/_constants.py +33 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/_pagination.py +223 -8
- hookbase-4.1.0/src/hookbase/_version.py +1 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/client.py +19 -9
- hookbase-4.1.0/src/hookbase/errors.py +247 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/__init__.py +117 -4
- hookbase-4.1.0/src/hookbase/models/analytics.py +195 -0
- hookbase-4.1.0/src/hookbase/models/common.py +107 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/cron_jobs.py +13 -2
- hookbase-4.1.0/src/hookbase/models/deliveries.py +136 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/destinations.py +24 -6
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/dlq.py +1 -1
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/endpoints.py +27 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/events.py +33 -10
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/filters.py +3 -2
- hookbase-4.1.0/src/hookbase/models/organizations.py +63 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/portal_tokens.py +6 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/routes.py +4 -4
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/schemas.py +10 -1
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/transforms.py +20 -1
- hookbase-4.1.0/src/hookbase/models/tunnels.py +67 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/_base.py +16 -0
- hookbase-4.1.0/src/hookbase/resources/analytics.py +86 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/api_keys.py +3 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/cron_jobs.py +19 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/deliveries.py +34 -16
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/destinations.py +12 -15
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/dlq.py +5 -4
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/events.py +25 -17
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/filters.py +17 -8
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/message_log.py +3 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/organizations.py +21 -14
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/portal_tokens.py +3 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/routes.py +18 -15
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/schemas.py +32 -1
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/sources.py +16 -15
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/subscriptions.py +7 -2
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/transforms.py +10 -4
- hookbase-4.1.0/src/hookbase/resources/tunnels.py +103 -0
- hookbase-4.1.0/src/hookbase/webhook.py +337 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_destinations.py +9 -5
- hookbase-4.1.0/tests/resources/test_inbound_contract.py +464 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_outbound_contract.py +46 -0
- hookbase-4.1.0/tests/resources/test_resource_contracts.py +692 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_sources.py +42 -4
- hookbase-4.1.0/tests/resources/test_sync_async_parity.py +418 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/test_errors.py +119 -0
- hookbase-4.1.0/tests/test_http_client.py +327 -0
- hookbase-4.1.0/tests/test_models_exports.py +44 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/test_pagination.py +50 -0
- hookbase-4.1.0/tests/test_retry.py +393 -0
- hookbase-4.1.0/tests/test_webhook.py +565 -0
- hookbase-3.3.0/README.md +0 -260
- hookbase-3.3.0/src/hookbase/_client.py +0 -347
- hookbase-3.3.0/src/hookbase/_constants.py +0 -4
- hookbase-3.3.0/src/hookbase/_version.py +0 -1
- hookbase-3.3.0/src/hookbase/errors.py +0 -173
- hookbase-3.3.0/src/hookbase/models/analytics.py +0 -22
- hookbase-3.3.0/src/hookbase/models/common.py +0 -42
- hookbase-3.3.0/src/hookbase/models/deliveries.py +0 -55
- hookbase-3.3.0/src/hookbase/models/organizations.py +0 -32
- hookbase-3.3.0/src/hookbase/models/tunnels.py +0 -19
- hookbase-3.3.0/src/hookbase/resources/analytics.py +0 -56
- hookbase-3.3.0/src/hookbase/resources/tunnels.py +0 -51
- hookbase-3.3.0/src/hookbase/webhook.py +0 -182
- hookbase-3.3.0/tests/test_webhook.py +0 -211
- {hookbase-3.3.0 → hookbase-4.1.0}/LICENSE +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/examples/async_usage.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/examples/basic_inbound.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/examples/error_handling.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/examples/send_webhooks.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/examples/verify_webhooks.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/_wire.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/applications.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/catalog.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/messages.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/sources.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/py.typed +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/endpoints.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/src/hookbase/resources/providers.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/__init__.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/conftest.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/__init__.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_applications.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_endpoints.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_messages.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_source_contract.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/resources/test_source_fields.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/test_client.py +0 -0
- {hookbase-3.3.0 → hookbase-4.1.0}/tests/test_slug_contract.py +0 -0
|
@@ -13,7 +13,9 @@ permissions:
|
|
|
13
13
|
|
|
14
14
|
jobs:
|
|
15
15
|
test:
|
|
16
|
-
|
|
16
|
+
# ubuntu-latest (26.04) has no Python 3.9 build for setup-python; 24.04 does, and 3.9 is
|
|
17
|
+
# still in requires-python.
|
|
18
|
+
runs-on: ubuntu-24.04
|
|
17
19
|
strategy:
|
|
18
20
|
matrix:
|
|
19
21
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookbase
|
|
3
|
-
Version:
|
|
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/
|
|
8
|
-
Project-URL: Issues, https://github.com/
|
|
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(
|
|
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"})
|
|
@@ -137,11 +163,15 @@ client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
|
137
163
|
|
|
138
164
|
# Transforms & Filters
|
|
139
165
|
client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
|
|
140
|
-
client.transforms.test("
|
|
141
|
-
client.filters.test(
|
|
166
|
+
client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
|
|
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
|
|
229
|
+
# Cron jobs
|
|
197
230
|
client.cron_jobs.list()
|
|
198
|
-
client.
|
|
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,10 +253,19 @@ for source in page.auto_paging_iter():
|
|
|
213
253
|
while page.has_more:
|
|
214
254
|
page = page.next_page()
|
|
215
255
|
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
256
|
+
# Schemas and cron jobs are not paginated by the API: one page holding everything
|
|
257
|
+
schemas = client.schemas.list()
|
|
258
|
+
|
|
259
|
+
# Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
|
|
260
|
+
apps = client.outbound.applications.list(limit=50)
|
|
261
|
+
for app in apps.auto_paging_iter():
|
|
219
262
|
print(app.name)
|
|
263
|
+
for event in client.events.list(limit=100).auto_paging_iter():
|
|
264
|
+
print(event.id)
|
|
265
|
+
|
|
266
|
+
# Limit/offset with no total (deliveries): pages until one comes back short
|
|
267
|
+
for delivery in client.deliveries.list(status="failed", limit=100).auto_paging_iter():
|
|
268
|
+
print(delivery.id, delivery.response_status)
|
|
220
269
|
```
|
|
221
270
|
|
|
222
271
|
### Webhook Verification
|
|
@@ -236,6 +285,32 @@ except WebhookVerificationError as e:
|
|
|
236
285
|
headers = wh.generate_test_headers('{"test": true}')
|
|
237
286
|
```
|
|
238
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
|
+
|
|
239
314
|
### Error Handling
|
|
240
315
|
|
|
241
316
|
```python
|
|
@@ -263,6 +338,35 @@ except APIError as e:
|
|
|
263
338
|
print(f"{e.status_code}: {e} (request_id={e.request_id})")
|
|
264
339
|
```
|
|
265
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
|
+
|
|
266
370
|
### Pydantic Models
|
|
267
371
|
|
|
268
372
|
All responses are typed Pydantic models with camelCase alias support:
|
|
@@ -281,6 +385,35 @@ source = client.sources.create({"name": "GitHub", "slug": "github", "provider":
|
|
|
281
385
|
# source with a provider newer than this package still parses.
|
|
282
386
|
```
|
|
283
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
|
+
|
|
284
417
|
## Development
|
|
285
418
|
|
|
286
419
|
```bash
|
hookbase-4.1.0/README.md
ADDED
|
@@ -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
|