hookbase 4.0.0__tar.gz → 4.2.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-4.0.0 → hookbase-4.2.0}/.gitignore +3 -0
- hookbase-4.2.0/PKG-INFO +530 -0
- hookbase-4.2.0/README.md +492 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/examples/basic_inbound.py +3 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/examples/send_webhooks.py +3 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/pyproject.toml +7 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/__init__.py +4 -0
- hookbase-4.2.0/src/hookbase/_client.py +397 -0
- hookbase-4.2.0/src/hookbase/_constants.py +33 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/_pagination.py +20 -3
- hookbase-4.2.0/src/hookbase/_version.py +1 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/client.py +19 -9
- hookbase-4.2.0/src/hookbase/errors.py +247 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/__init__.py +126 -3
- hookbase-4.2.0/src/hookbase/models/_base.py +20 -0
- hookbase-4.2.0/src/hookbase/models/analytics.py +195 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/api_keys.py +5 -3
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/cron_jobs.py +26 -7
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/deliveries.py +22 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/destinations.py +128 -15
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/dlq.py +1 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/endpoints.py +63 -10
- hookbase-4.2.0/src/hookbase/models/events.py +239 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/filters.py +3 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/messages.py +26 -0
- hookbase-4.2.0/src/hookbase/models/organizations.py +63 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/portal_tokens.py +13 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/routes.py +22 -4
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/schemas.py +10 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/sources.py +27 -9
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/transforms.py +49 -3
- hookbase-4.2.0/src/hookbase/models/tunnels.py +71 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/_base.py +19 -4
- hookbase-4.2.0/src/hookbase/resources/analytics.py +86 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/api_keys.py +3 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/cron_jobs.py +19 -2
- hookbase-4.2.0/src/hookbase/resources/deliveries.py +186 -0
- hookbase-4.2.0/src/hookbase/resources/destinations.py +211 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/dlq.py +5 -4
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/filters.py +17 -8
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/message_log.py +3 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/organizations.py +21 -14
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/portal_tokens.py +3 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/routes.py +18 -15
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/schemas.py +32 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/sources.py +16 -15
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/subscriptions.py +7 -2
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/transforms.py +25 -4
- hookbase-4.2.0/src/hookbase/resources/tunnels.py +103 -0
- hookbase-4.2.0/src/hookbase/webhook.py +337 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_destinations.py +229 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_endpoints.py +73 -1
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_inbound_contract.py +244 -0
- hookbase-4.2.0/tests/resources/test_open_types.py +94 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_outbound_contract.py +135 -1
- hookbase-4.2.0/tests/resources/test_resource_contracts.py +833 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_source_fields.py +12 -3
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_sources.py +50 -1
- hookbase-4.2.0/tests/resources/test_sync_async_parity.py +562 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_errors.py +119 -0
- hookbase-4.2.0/tests/test_http_client.py +327 -0
- hookbase-4.2.0/tests/test_models_exports.py +56 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_pagination.py +50 -0
- hookbase-4.2.0/tests/test_retry.py +393 -0
- hookbase-4.2.0/tests/test_secret_redaction.py +218 -0
- hookbase-4.2.0/tests/test_webhook.py +565 -0
- hookbase-4.0.0/PKG-INFO +0 -301
- hookbase-4.0.0/README.md +0 -266
- hookbase-4.0.0/src/hookbase/_client.py +0 -347
- hookbase-4.0.0/src/hookbase/_constants.py +0 -4
- hookbase-4.0.0/src/hookbase/_version.py +0 -1
- hookbase-4.0.0/src/hookbase/errors.py +0 -173
- hookbase-4.0.0/src/hookbase/models/_base.py +0 -14
- hookbase-4.0.0/src/hookbase/models/analytics.py +0 -22
- hookbase-4.0.0/src/hookbase/models/events.py +0 -116
- hookbase-4.0.0/src/hookbase/models/organizations.py +0 -32
- hookbase-4.0.0/src/hookbase/models/tunnels.py +0 -19
- hookbase-4.0.0/src/hookbase/resources/analytics.py +0 -56
- hookbase-4.0.0/src/hookbase/resources/deliveries.py +0 -97
- hookbase-4.0.0/src/hookbase/resources/destinations.py +0 -140
- hookbase-4.0.0/src/hookbase/resources/tunnels.py +0 -51
- hookbase-4.0.0/src/hookbase/webhook.py +0 -182
- hookbase-4.0.0/tests/test_webhook.py +0 -211
- {hookbase-4.0.0 → hookbase-4.2.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/LICENSE +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/examples/async_usage.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/examples/error_handling.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/examples/verify_webhooks.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/_wire.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/applications.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/catalog.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/common.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/py.typed +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/endpoints.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/providers.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/__init__.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/conftest.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/__init__.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_applications.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_messages.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_source_contract.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_client.py +0 -0
- {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_slug_contract.py +0 -0
hookbase-4.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hookbase
|
|
3
|
+
Version: 4.2.0
|
|
4
|
+
Summary: Official Python SDK for the Hookbase webhook management platform
|
|
5
|
+
Project-URL: Homepage, https://hookbase.app
|
|
6
|
+
Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/HookbaseApp/hookbase-python
|
|
8
|
+
Project-URL: Issues, https://github.com/HookbaseApp/hookbase-python/issues
|
|
9
|
+
Author-email: Hookbase <support@hookbase.app>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: eval-type-backport>=0.2.0; python_version < '3.10'
|
|
26
|
+
Requires-Dist: httpx<1.0.0,>=0.25.0
|
|
27
|
+
Requires-Dist: pydantic<3.0.0,>=2.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: cryptography>=38.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
35
|
+
Provides-Extra: ed25519
|
|
36
|
+
Requires-Dist: cryptography>=38.0; extra == 'ed25519'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# Hookbase Python SDK
|
|
40
|
+
|
|
41
|
+
The official Python SDK for the [Hookbase](https://hookbase.app) webhook management platform.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install hookbase
|
|
47
|
+
# To verify Ed25519 (v1a) signed webhooks, include the optional extra:
|
|
48
|
+
pip install 'hookbase[ed25519]'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from hookbase import Hookbase
|
|
55
|
+
|
|
56
|
+
client = Hookbase(api_key="whr_your_api_key")
|
|
57
|
+
|
|
58
|
+
# List webhook sources
|
|
59
|
+
sources = client.sources.list()
|
|
60
|
+
for source in sources.data:
|
|
61
|
+
print(f"{source.name} ({source.provider})")
|
|
62
|
+
|
|
63
|
+
# Send an outbound webhook
|
|
64
|
+
result = client.outbound.messages.send(
|
|
65
|
+
"app_123",
|
|
66
|
+
event_type="order.created",
|
|
67
|
+
payload={"orderId": "456", "amount": 99.99},
|
|
68
|
+
)
|
|
69
|
+
print(f"Sent: {result.event_id}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Async Support
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from hookbase import AsyncHookbase
|
|
76
|
+
|
|
77
|
+
async with AsyncHookbase(api_key="whr_...") as client:
|
|
78
|
+
sources = await client.sources.list()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## API Reference
|
|
82
|
+
|
|
83
|
+
### Client Initialization
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from hookbase import Hookbase
|
|
87
|
+
|
|
88
|
+
client = Hookbase(
|
|
89
|
+
api_key="whr_...", # Required
|
|
90
|
+
base_url="https://...", # Default: https://api.hookbase.app
|
|
91
|
+
timeout=30.0, # Seconds or httpx.Timeout (default: 30); None = no timeout
|
|
92
|
+
max_retries=3, # Default: 3
|
|
93
|
+
debug=False, # Log requests
|
|
94
|
+
)
|
|
95
|
+
```
|
|
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
|
+
|
|
118
|
+
### Inbound Webhooks
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# Sources
|
|
122
|
+
client.sources.list(page_size=50) # the API has no search/provider filter on this list
|
|
123
|
+
client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
|
|
124
|
+
client.sources.get("src_id")
|
|
125
|
+
client.sources.update("src_id", {"name": "Updated"})
|
|
126
|
+
client.sources.delete("src_id")
|
|
127
|
+
client.sources.rotate_secret("src_id") # 400 for vendor-issued/public-key/built-in secrets
|
|
128
|
+
client.sources.reveal_secret("src_id")
|
|
129
|
+
|
|
130
|
+
# A provider that verifies with no secret: signing_secret comes back None
|
|
131
|
+
wise = client.sources.create({"name": "Wise", "slug": "wise", "provider": "wise"})
|
|
132
|
+
assert wise.verifies_without_secret and wise.signing_secret is None
|
|
133
|
+
|
|
134
|
+
# WhatsApp (Meta) sources also carry a verify token
|
|
135
|
+
wa = client.sources.create({"name": "WhatsApp", "slug": "whatsapp", "provider": "whatsapp"})
|
|
136
|
+
print(wa.verify_token)
|
|
137
|
+
client.sources.rotate_verify_token(wa.id)
|
|
138
|
+
|
|
139
|
+
# Notion parks its verification token until you adopt it
|
|
140
|
+
pending = client.sources.get_pending_verification("notion")
|
|
141
|
+
client.sources.adopt_pending_verification("notion")
|
|
142
|
+
|
|
143
|
+
# Provider catalog: which secret each provider needs (public, no auth needed)
|
|
144
|
+
for p in client.providers.list(category="payments").providers:
|
|
145
|
+
print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
|
|
146
|
+
client.providers.get("stripe").signature_verification
|
|
147
|
+
|
|
148
|
+
# Record the catalog entry a source is made from (create only). Entries with no verifier of
|
|
149
|
+
# their own, such as Salesforce, save as Custom HMAC:
|
|
150
|
+
client.sources.create({"name": "Salesforce", "slug": "salesforce", "provider": "custom",
|
|
151
|
+
"catalogProvider": "salesforce"})
|
|
152
|
+
|
|
153
|
+
# Destinations
|
|
154
|
+
client.destinations.list()
|
|
155
|
+
dest = client.destinations.create({"name": "Backend", "url": "https://..."})
|
|
156
|
+
client.destinations.test("dst_id")
|
|
157
|
+
|
|
158
|
+
# HTTP destinations sign every delivery (Standard Webhooks headers), so the receiver can check
|
|
159
|
+
# it with Webhook(secret).verify(). Create returns the secret once: put it in your secret store.
|
|
160
|
+
# repr() and str() leave it out, so logging the destination doesn't leak it.
|
|
161
|
+
signing_secret = dest.signing_secret # whsec_...; dest.has_signing_secret is True
|
|
162
|
+
client.destinations.reveal_secret("dst_id").signing_secret
|
|
163
|
+
rotated = client.destinations.rotate_secret("dst_id", grace_period_seconds=3600)
|
|
164
|
+
rotated.previous_secret_expires_at # until then the old secret signs too
|
|
165
|
+
|
|
166
|
+
# Bridge destinations publish each delivery they receive as an outbound event
|
|
167
|
+
from hookbase.models import BridgeConfig, CreateDestinationParams
|
|
168
|
+
|
|
169
|
+
bridge = client.destinations.create(CreateDestinationParams(
|
|
170
|
+
name="Orders to customers",
|
|
171
|
+
type="bridge",
|
|
172
|
+
config=BridgeConfig(event_type_source="field", event_type_field="type",
|
|
173
|
+
event_type_prefix="order."),
|
|
174
|
+
))
|
|
175
|
+
# A bridge's test is a dry run: nothing is published
|
|
176
|
+
result = client.destinations.test(bridge.id, payload={"type": "created"})
|
|
177
|
+
result.event_type, result.event_type_exists, result.matching_endpoints
|
|
178
|
+
|
|
179
|
+
# Routes
|
|
180
|
+
client.routes.create({"name": "Route", "sourceId": "src_id", "destinationId": "dst_id"})
|
|
181
|
+
client.routes.get_circuit_status("route_id")
|
|
182
|
+
client.routes.reset_circuit("route_id")
|
|
183
|
+
|
|
184
|
+
# While a route's circuit is open its deliveries are held (status "held", hold_expires_at set)
|
|
185
|
+
# and go out when it closes. hold_on_circuit_open=False dead-letters them as soon as the circuit
|
|
186
|
+
# opens instead.
|
|
187
|
+
client.routes.update("route_id", {"holdOnCircuitOpen": False})
|
|
188
|
+
client.routes.get("route_id").held_count # get only; held_count_capped past 10,000
|
|
189
|
+
|
|
190
|
+
# Events & Deliveries
|
|
191
|
+
client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
|
|
192
|
+
client.events.get("evt_id") # Includes payload and deliveries
|
|
193
|
+
client.events.debug("evt_id").curl_command # plus typed deliveries, pattern hints, circuit state
|
|
194
|
+
client.deliveries.replay("del_id")
|
|
195
|
+
client.deliveries.bulk_replay(["del_1", "del_2"])
|
|
196
|
+
|
|
197
|
+
# Replay with edits. payload_patch is an RFC 7386 merge patch on the stored payload (None
|
|
198
|
+
# removes a key), so it works on sources that mask fields; modified_payload replaces it. An
|
|
199
|
+
# argument left as None is not sent.
|
|
200
|
+
client.deliveries.replay(
|
|
201
|
+
"del_id",
|
|
202
|
+
payload_patch={"amount": 10, "coupon": None},
|
|
203
|
+
destination_override="backup-destination", # id or slug
|
|
204
|
+
headers_override={"X-Replay": "1"},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Transforms & Filters
|
|
208
|
+
client.transforms.engines() # languages this deployment runs, and which are available
|
|
209
|
+
client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
|
|
210
|
+
client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
|
|
211
|
+
client.filters.test(
|
|
212
|
+
[{"field": "type", "operator": "equals", "value": "order"}], # dot paths: "data.status"
|
|
213
|
+
payload={"type": "order"},
|
|
214
|
+
logic="AND", # or "OR"; sent uppercase
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Schemas
|
|
218
|
+
client.schemas.create({"name": "OrderSchema", "jsonSchema": {"type": "object", "required": ["orderId"]}})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Outbound Webhooks
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
# Applications
|
|
225
|
+
client.outbound.applications.create({"name": "Acme", "externalId": "cust_123"})
|
|
226
|
+
client.outbound.applications.get_by_external_id("cust_123")
|
|
227
|
+
|
|
228
|
+
# Endpoints
|
|
229
|
+
client.outbound.endpoints.create("app_id", {"url": "https://..."})
|
|
230
|
+
client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
|
|
231
|
+
|
|
232
|
+
# An unset field is left alone by an update, so `None` cannot also mean "reset
|
|
233
|
+
# this". Name the setting in `clear` to send an explicit null for it:
|
|
234
|
+
client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
|
|
235
|
+
|
|
236
|
+
# While an endpoint's circuit is open its messages are held (status "held") and go out when it
|
|
237
|
+
# recovers; hold_on_circuit_open=False dead-letters them instead. FIFO endpoints never hold.
|
|
238
|
+
client.outbound.endpoints.update("ep_id", {"holdOnCircuitOpen": False})
|
|
239
|
+
client.outbound.endpoints.get("ep_id").held_count
|
|
240
|
+
|
|
241
|
+
# Event Types & Subscriptions
|
|
242
|
+
client.outbound.event_types.create({"name": "order.created", "category": "orders"})
|
|
243
|
+
client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
|
|
244
|
+
client.outbound.subscriptions.bulk_create("ep_id", ["et_1", "et_2"])
|
|
245
|
+
|
|
246
|
+
# Send Events
|
|
247
|
+
sent = client.outbound.messages.send("app_id", event_type="order.created",
|
|
248
|
+
payload={"orderId": "ord_1"})
|
|
249
|
+
sent.messages_held # of messages_queued, those held behind an open circuit (None if none)
|
|
250
|
+
|
|
251
|
+
# Message Log
|
|
252
|
+
client.outbound.message_log.list(application_id="app_id", status="held") # .hold_expires_at
|
|
253
|
+
client.outbound.message_log.get("msg_id").bridge # set when a bridge destination published it
|
|
254
|
+
client.outbound.message_log.list_attempts("msg_id")
|
|
255
|
+
client.outbound.message_log.stats() # .held among the counts
|
|
256
|
+
|
|
257
|
+
# Dead Letter Queue
|
|
258
|
+
client.outbound.dlq.list()
|
|
259
|
+
client.outbound.dlq.stats()
|
|
260
|
+
client.outbound.dlq.retry("dlq_id")
|
|
261
|
+
client.outbound.dlq.retry_bulk(["dlq_1", "dlq_2"])
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Admin
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
# Organizations and creating API keys need a user session: with a whr_ API key the
|
|
268
|
+
# API answers 403 (ForbiddenError).
|
|
269
|
+
client.organizations.list()
|
|
270
|
+
client.organizations.list_members("org_id")
|
|
271
|
+
client.organizations.invite("org_id", "user@example.com", "member")
|
|
272
|
+
client.api_keys.create({"name": "CI Key", "scopes": ["read", "write"]})
|
|
273
|
+
|
|
274
|
+
# Analytics: range 1h, 24h, 7d or 30d (or "custom" with start_date/end_date)
|
|
275
|
+
stats = client.analytics.dashboard(range="30d", tz="America/New_York")
|
|
276
|
+
stats.overview.total_events, stats.latency.percentiles.p95, stats.errors.by_status_code
|
|
277
|
+
|
|
278
|
+
# Schemas
|
|
279
|
+
client.schemas.validate("sch_id", {"orderId": "ord_1"}) # .valid, .errors[].path/.message
|
|
280
|
+
|
|
281
|
+
# Cron jobs
|
|
282
|
+
client.cron_jobs.list()
|
|
283
|
+
client.cron_jobs.trigger("cron_id") # .status, .response_status, .error
|
|
284
|
+
client.cron_jobs.list_executions("cron_id", limit=10)
|
|
285
|
+
|
|
286
|
+
# Tunnels: the auth token is returned once, on create
|
|
287
|
+
tunnel = client.tunnels.create({"name": "dev"})
|
|
288
|
+
tunnel.auth_token, tunnel.tunnel_url, tunnel.ws_url
|
|
289
|
+
client.tunnels.regenerate_token(tunnel.id) # new token; old connections are dropped
|
|
290
|
+
client.tunnels.delete(tunnel.id)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Pagination
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# Offset-based (sources, destinations, routes, etc.)
|
|
297
|
+
page = client.sources.list(page_size=10)
|
|
298
|
+
print(f"Total: {page.total}, Page: {page.page}")
|
|
299
|
+
|
|
300
|
+
# Auto-paginate through all items
|
|
301
|
+
for source in page.auto_paging_iter():
|
|
302
|
+
print(source.name)
|
|
303
|
+
|
|
304
|
+
# Manual pagination
|
|
305
|
+
while page.has_more:
|
|
306
|
+
page = page.next_page()
|
|
307
|
+
|
|
308
|
+
# Schemas and cron jobs are not paginated by the API: one page holding everything
|
|
309
|
+
schemas = client.schemas.list()
|
|
310
|
+
|
|
311
|
+
# Cursor-based (inbound events, applications, endpoints, subscriptions, etc.)
|
|
312
|
+
apps = client.outbound.applications.list(limit=50)
|
|
313
|
+
for app in apps.auto_paging_iter():
|
|
314
|
+
print(app.name)
|
|
315
|
+
for event in client.events.list(limit=100).auto_paging_iter():
|
|
316
|
+
print(event.id)
|
|
317
|
+
|
|
318
|
+
# Limit/offset with no total (deliveries): pages until one comes back short
|
|
319
|
+
for delivery in client.deliveries.list(status="failed", limit=100).auto_paging_iter():
|
|
320
|
+
print(delivery.id, delivery.response_status)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Webhook Verification
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from hookbase import Webhook, WebhookVerificationError
|
|
327
|
+
|
|
328
|
+
wh = Webhook("whsec_your_secret")
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
payload = wh.verify(request_body, request_headers)
|
|
332
|
+
print(f"Verified: {payload}")
|
|
333
|
+
except WebhookVerificationError as e:
|
|
334
|
+
print(f"Invalid: {e}")
|
|
335
|
+
|
|
336
|
+
# Generate test headers
|
|
337
|
+
headers = wh.generate_test_headers('{"test": true}')
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### Ed25519 (`v1a`) endpoints
|
|
341
|
+
|
|
342
|
+
Endpoints created with `signature_scheme="ed25519"` are signed with Ed25519
|
|
343
|
+
(Standard Webhooks `v1a,<sig>`). Hookbase keeps the private key; verify with the
|
|
344
|
+
endpoint's `whpk_` public key. This needs `pip install 'hookbase[ed25519]'`.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
from hookbase.models import CreateEndpointParams
|
|
348
|
+
|
|
349
|
+
ep = client.outbound.endpoints.create(
|
|
350
|
+
"app_id", CreateEndpointParams(url="https://example.com/hook", signature_scheme="ed25519")
|
|
351
|
+
)
|
|
352
|
+
assert ep.public_key is not None # set for Ed25519 endpoints
|
|
353
|
+
wh = Webhook(ep.public_key) # "whpk_..."; same verify() API, tolerance and errors as v1
|
|
354
|
+
payload = wh.verify(request_body, request_headers)
|
|
355
|
+
|
|
356
|
+
# Rotation returns the new public key (no secret). During the grace period
|
|
357
|
+
# deliveries carry `v1a,<old> v1a,<new>`, so either key verifies them.
|
|
358
|
+
rotated = client.outbound.endpoints.rotate_secret(ep.id, grace_period=3600)
|
|
359
|
+
rotated.public_key, rotated.previous_public_key
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The key decides the scheme (`wh.signature_scheme`): a `whsec_` secret checks
|
|
363
|
+
`v1` entries only and a `whpk_` key checks `v1a` entries only, so a header that
|
|
364
|
+
mixes both verifies with either.
|
|
365
|
+
|
|
366
|
+
### Error Handling
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
from hookbase import (
|
|
370
|
+
HookbaseError, # Base error
|
|
371
|
+
APIError, # API returned an error
|
|
372
|
+
AuthenticationError, # 401
|
|
373
|
+
ForbiddenError, # 403
|
|
374
|
+
NotFoundError, # 404
|
|
375
|
+
ValidationError, # 400/422
|
|
376
|
+
ConflictError, # 409
|
|
377
|
+
RateLimitError, # 429 (has retry_after)
|
|
378
|
+
TimeoutError, # Request timed out
|
|
379
|
+
NetworkError, # Connection failed
|
|
380
|
+
WebhookVerificationError,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
client.sources.get("src_id")
|
|
385
|
+
except NotFoundError:
|
|
386
|
+
print("Not found")
|
|
387
|
+
except RateLimitError as e:
|
|
388
|
+
print(f"Retry after {e.retry_after}s")
|
|
389
|
+
except APIError as e:
|
|
390
|
+
print(f"{e.status_code}: {e} (request_id={e.request_id})")
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Every `APIError` carries:
|
|
394
|
+
|
|
395
|
+
- `status_code`: the HTTP status actually returned (a `ValidationError` is 400 or 422).
|
|
396
|
+
- `code`: the `code` from the response body, e.g. `LIMIT_EXCEEDED` or `FEATURE_DISABLED` on a
|
|
397
|
+
`ForbiddenError`. When the body has no `code`, it is the class default (`forbidden`,
|
|
398
|
+
`not_found`, `validation_error`, ...).
|
|
399
|
+
- `details`: the body's `details` object (for validation failures, `{"fieldErrors": ...,
|
|
400
|
+
"formErrors": ...}`), or any extra fields the error sent (such as `current` and `max` on
|
|
401
|
+
`LIMIT_EXCEEDED`); `None` when there are none. `ValidationError.validation_errors` is the
|
|
402
|
+
`fieldErrors` map.
|
|
403
|
+
- `request_id`: the `x-request-id` response header.
|
|
404
|
+
|
|
405
|
+
`TimeoutError` and `NetworkError` (connection failures, and a `base_url` that is not an
|
|
406
|
+
absolute http(s) URL) are raised for requests that got no response.
|
|
407
|
+
|
|
408
|
+
### Retries
|
|
409
|
+
|
|
410
|
+
Up to `max_retries` retries (default 3):
|
|
411
|
+
|
|
412
|
+
- **429** is retried for every method after `Retry-After` (seconds or an HTTP date; 60 s
|
|
413
|
+
when the header is missing or unusable), as long as that wait is at most 60 s. A longer
|
|
414
|
+
`Retry-After` is not waited for: the `RateLimitError` is raised at once with `retry_after`
|
|
415
|
+
set, so you decide how to spend that time.
|
|
416
|
+
- **Any 5xx, read timeouts and dropped connections** are retried with exponential backoff
|
|
417
|
+
(1, 2, 4 ... s, capped at 10 s, plus up to 1 s jitter) for every method except POST. A POST
|
|
418
|
+
is retried only when it carries an idempotency key (`outbound.messages.send` always does).
|
|
419
|
+
- **A connection that was never made** is always retried.
|
|
420
|
+
- **Other 4xx** responses are never retried.
|
|
421
|
+
|
|
422
|
+
### Pydantic Models
|
|
423
|
+
|
|
424
|
+
All responses are typed Pydantic models with camelCase alias support:
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
from hookbase.models import Source, CreateSourceParams
|
|
428
|
+
|
|
429
|
+
# Use Pydantic models for type safety
|
|
430
|
+
params = CreateSourceParams(name="GitHub", slug="github", provider="github")
|
|
431
|
+
source = client.sources.create(params)
|
|
432
|
+
|
|
433
|
+
# Or use plain dicts
|
|
434
|
+
source = client.sources.create({"name": "GitHub", "slug": "github", "provider": "github"})
|
|
435
|
+
|
|
436
|
+
# `provider` is any string (hookbase.models.KNOWN_SOURCE_PROVIDERS lists today's values), so a
|
|
437
|
+
# source with a provider newer than this package still parses.
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Upgrading to 4.2
|
|
441
|
+
|
|
442
|
+
- **Fix:** `destinations.list()` and `get()` raised a `ValidationError` when the organization had
|
|
443
|
+
a destination of a type other than `http`, `s3`, `r2`, `gcs` or `azure_blob` (`sqs`,
|
|
444
|
+
`eventbridge`, `servicebus`, `pubsub`, `oci_queue`, `bridge`). `DestinationType`,
|
|
445
|
+
`TransformType` and `ContentFormat` now accept any string, as `MessageStatus` does, so types
|
|
446
|
+
added later parse too. `"form"`, which the API never accepted, is gone from `ContentFormat`.
|
|
447
|
+
- **Fix:** `events.debug()` returned an empty `curl_command`, `event.headers` and
|
|
448
|
+
`event.payload`; they are filled in now. Its `deliveries` are typed (`EventDebugDelivery`).
|
|
449
|
+
- `destinations.create()` returns a `DestinationWithSecret`, whose `signing_secret` is the only
|
|
450
|
+
time the secret is returned; afterwards use `reveal_secret()`. `rotate_secret()` takes
|
|
451
|
+
`grace_period_seconds`. `Destination.has_signing_secret` says whether deliveries are signed.
|
|
452
|
+
- Bridge destinations: `BridgeConfig` for `config`, `Destination.bridge_config` to read it,
|
|
453
|
+
`destinations.test(id, payload=...)` for a dry run, and the dry-run fields on `TestResult`.
|
|
454
|
+
- `deliveries.replay()` takes `modified_payload`, `payload_patch`, `destination_override`,
|
|
455
|
+
`transform_override`, `headers_override` and `persist_transform`; `ReplayResult` reports what
|
|
456
|
+
changed. Passing both payload edits raises `ValueError`. An argument left as None is not sent,
|
|
457
|
+
so `modified_payload` can be any JSON value but null. The same goes for `payload` on
|
|
458
|
+
`destinations.test()`.
|
|
459
|
+
- Holds: the `held` status on deliveries and outbound messages, with `hold_expires_at`;
|
|
460
|
+
`hold_on_circuit_open`, `held_count` and `held_count_capped` on routes and webhook endpoints;
|
|
461
|
+
`messages_held` on `SendEventResponse` and `held` on `StatsSummary`. `MessageStatus` also
|
|
462
|
+
gains `awaiting_pull` and `fifo_waiting`.
|
|
463
|
+
- `transforms.engines()` lists the transform languages the deployment runs.
|
|
464
|
+
- `catalog_provider` on sources (set on create only).
|
|
465
|
+
- `sources.export()` returns a raw dict, and the API now sends its `ipAllowlist`, `ipDenylist`,
|
|
466
|
+
`encryptFields` and `maskFields` as JSON arrays; they used to be JSON-encoded strings. Code
|
|
467
|
+
that calls `json.loads` on them must stop. The `Source` model already read either form.
|
|
468
|
+
- Secrets and credentials no longer appear in `repr()` or `str()`:
|
|
469
|
+
- The secrets the API returns, on `DestinationWithSecret`, `DestinationSecret`,
|
|
470
|
+
`SourceWithSecret`, `PendingVerification`, `WebhookEndpoint`, `EndpointWithSecret`,
|
|
471
|
+
`RotateSecretResult`, `ApiKeyWithSecret`, `Tunnel`, `TunnelToken` and `PortalToken`.
|
|
472
|
+
- The headers and auth configs the API returns in full to a key with the `write` scope:
|
|
473
|
+
`Destination.headers` and `auth_config`, `WebhookEndpoint.headers` (and each
|
|
474
|
+
`CustomHeader.value`), and `CronJob.headers`.
|
|
475
|
+
- The credentials you pass in: `signing_secret` and `verify_token` on `CreateSourceParams`
|
|
476
|
+
and `UpdateSourceParams`; `headers`, `auth_config` and `config` on `CreateDestinationParams`
|
|
477
|
+
and `UpdateDestinationParams`; `headers` on the endpoint and cron job params; and the keys
|
|
478
|
+
on `S3Config`, `GCSConfig` and `AzureBlobConfig`.
|
|
479
|
+
|
|
480
|
+
A `pydantic.ValidationError` from one of these models, or from a list of them, leaves out the
|
|
481
|
+
input it was given. The values are still sent and still readable from their attributes
|
|
482
|
+
(`dest.signing_secret`).
|
|
483
|
+
- **Typing:** `CreateDestinationParams.config` and `UpdateDestinationParams.config` are now
|
|
484
|
+
`dict[str, Any] | BridgeConfig | None`, so `params.config["bucket"]` no longer type-checks
|
|
485
|
+
until you narrow it with `isinstance(params.config, dict)`. Because `DestinationType`,
|
|
486
|
+
`TransformType` and `ContentFormat` now include `str`, mypy reads `Destination.type`,
|
|
487
|
+
`Transform.transform_type`, `input_format` and `output_format` as plain `str`. An exhaustive
|
|
488
|
+
`match` or `assert_never` over their literals no longer type-checks.
|
|
489
|
+
|
|
490
|
+
## Upgrading to 4.1
|
|
491
|
+
|
|
492
|
+
- `APIError.code` now carries the API's `code` from the response body (for example
|
|
493
|
+
`FEATURE_DISABLED` or `LIMIT_EXCEEDED` on a `ForbiddenError`, `DUPLICATE_NAME` on a
|
|
494
|
+
`ConflictError`) instead of a fixed string such as `"forbidden"` or `"conflict"`. The fixed
|
|
495
|
+
string is still used when the body has no `code`. Check code that compares `.code` to those
|
|
496
|
+
strings; `isinstance` checks on the error classes are unaffected.
|
|
497
|
+
- `ValidationError.status_code` is the status actually returned (400 or 422); it used to be
|
|
498
|
+
always 400.
|
|
499
|
+
- `APIError.details` is now populated (see [Error Handling](#error-handling)).
|
|
500
|
+
- A custom `http_client` no longer needs `base_url`, auth headers or a timeout configured on it,
|
|
501
|
+
and `close()` no longer closes it.
|
|
502
|
+
- `timeout=None` now means no timeout; leave `timeout` out to get the default.
|
|
503
|
+
- Every 5xx (not only 500/502/503/504) is retried for retry-safe requests, and `Retry-After`
|
|
504
|
+
may be an HTTP date. A 429 whose `Retry-After` is longer than 60 s is no longer slept
|
|
505
|
+
through: `RateLimitError` is raised at once, `retry_after` set. An empty 2xx body returns
|
|
506
|
+
`None`; a 2xx body that is not valid JSON raises `APIError` (`code="invalid_response"`).
|
|
507
|
+
- `Webhook("whsec_...")` raises `ValueError` for a secret that is not valid base64, at
|
|
508
|
+
construction rather than on the first `verify()`.
|
|
509
|
+
- `analytics.dashboard()` returns the API's real shape (`overview`, `latency`, `errors`, ...).
|
|
510
|
+
The old top-level fields (`events_received`, `delivery_success_rate`, ...) are now
|
|
511
|
+
read-only properties derived from `overview`; before, they were always 0.
|
|
512
|
+
- `sources.list(search=, provider=, is_active=)`, `destinations.list(search=, is_active=)` and
|
|
513
|
+
`routes.list(source_id=, destination_id=, is_active=)` warn (`DeprecationWarning`) and do
|
|
514
|
+
not send those filters, which the API never applied.
|
|
515
|
+
- `transforms.update()` and `organizations.update()` make a second request to return the
|
|
516
|
+
stored object (they used to raise on the API's `{"success": true}` reply).
|
|
517
|
+
- `SchemaValidationResult.errors` is a list of `SchemaValidationError` (`path`, `message`).
|
|
518
|
+
|
|
519
|
+
## Development
|
|
520
|
+
|
|
521
|
+
```bash
|
|
522
|
+
pip install -e ".[dev]"
|
|
523
|
+
pytest tests/ -v
|
|
524
|
+
mypy src/hookbase
|
|
525
|
+
ruff check src/ tests/
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
## License
|
|
529
|
+
|
|
530
|
+
MIT
|