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.
Files changed (112) hide show
  1. {hookbase-4.0.0 → hookbase-4.2.0}/.gitignore +3 -0
  2. hookbase-4.2.0/PKG-INFO +530 -0
  3. hookbase-4.2.0/README.md +492 -0
  4. {hookbase-4.0.0 → hookbase-4.2.0}/examples/basic_inbound.py +3 -1
  5. {hookbase-4.0.0 → hookbase-4.2.0}/examples/send_webhooks.py +3 -1
  6. {hookbase-4.0.0 → hookbase-4.2.0}/pyproject.toml +7 -2
  7. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/__init__.py +4 -0
  8. hookbase-4.2.0/src/hookbase/_client.py +397 -0
  9. hookbase-4.2.0/src/hookbase/_constants.py +33 -0
  10. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/_pagination.py +20 -3
  11. hookbase-4.2.0/src/hookbase/_version.py +1 -0
  12. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/client.py +19 -9
  13. hookbase-4.2.0/src/hookbase/errors.py +247 -0
  14. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/__init__.py +126 -3
  15. hookbase-4.2.0/src/hookbase/models/_base.py +20 -0
  16. hookbase-4.2.0/src/hookbase/models/analytics.py +195 -0
  17. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/api_keys.py +5 -3
  18. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/cron_jobs.py +26 -7
  19. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/deliveries.py +22 -2
  20. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/destinations.py +128 -15
  21. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/dlq.py +1 -1
  22. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/endpoints.py +63 -10
  23. hookbase-4.2.0/src/hookbase/models/events.py +239 -0
  24. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/filters.py +3 -2
  25. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/messages.py +26 -0
  26. hookbase-4.2.0/src/hookbase/models/organizations.py +63 -0
  27. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/portal_tokens.py +13 -2
  28. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/routes.py +22 -4
  29. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/schemas.py +10 -1
  30. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/sources.py +27 -9
  31. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/transforms.py +49 -3
  32. hookbase-4.2.0/src/hookbase/models/tunnels.py +71 -0
  33. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/_base.py +19 -4
  34. hookbase-4.2.0/src/hookbase/resources/analytics.py +86 -0
  35. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/api_keys.py +3 -2
  36. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/cron_jobs.py +19 -2
  37. hookbase-4.2.0/src/hookbase/resources/deliveries.py +186 -0
  38. hookbase-4.2.0/src/hookbase/resources/destinations.py +211 -0
  39. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/dlq.py +5 -4
  40. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/filters.py +17 -8
  41. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/message_log.py +3 -2
  42. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/organizations.py +21 -14
  43. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/portal_tokens.py +3 -2
  44. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/routes.py +18 -15
  45. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/schemas.py +32 -1
  46. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/sources.py +16 -15
  47. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/subscriptions.py +7 -2
  48. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/transforms.py +25 -4
  49. hookbase-4.2.0/src/hookbase/resources/tunnels.py +103 -0
  50. hookbase-4.2.0/src/hookbase/webhook.py +337 -0
  51. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_destinations.py +229 -1
  52. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_endpoints.py +73 -1
  53. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_inbound_contract.py +244 -0
  54. hookbase-4.2.0/tests/resources/test_open_types.py +94 -0
  55. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_outbound_contract.py +135 -1
  56. hookbase-4.2.0/tests/resources/test_resource_contracts.py +833 -0
  57. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_source_fields.py +12 -3
  58. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_sources.py +50 -1
  59. hookbase-4.2.0/tests/resources/test_sync_async_parity.py +562 -0
  60. {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_errors.py +119 -0
  61. hookbase-4.2.0/tests/test_http_client.py +327 -0
  62. hookbase-4.2.0/tests/test_models_exports.py +56 -0
  63. {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_pagination.py +50 -0
  64. hookbase-4.2.0/tests/test_retry.py +393 -0
  65. hookbase-4.2.0/tests/test_secret_redaction.py +218 -0
  66. hookbase-4.2.0/tests/test_webhook.py +565 -0
  67. hookbase-4.0.0/PKG-INFO +0 -301
  68. hookbase-4.0.0/README.md +0 -266
  69. hookbase-4.0.0/src/hookbase/_client.py +0 -347
  70. hookbase-4.0.0/src/hookbase/_constants.py +0 -4
  71. hookbase-4.0.0/src/hookbase/_version.py +0 -1
  72. hookbase-4.0.0/src/hookbase/errors.py +0 -173
  73. hookbase-4.0.0/src/hookbase/models/_base.py +0 -14
  74. hookbase-4.0.0/src/hookbase/models/analytics.py +0 -22
  75. hookbase-4.0.0/src/hookbase/models/events.py +0 -116
  76. hookbase-4.0.0/src/hookbase/models/organizations.py +0 -32
  77. hookbase-4.0.0/src/hookbase/models/tunnels.py +0 -19
  78. hookbase-4.0.0/src/hookbase/resources/analytics.py +0 -56
  79. hookbase-4.0.0/src/hookbase/resources/deliveries.py +0 -97
  80. hookbase-4.0.0/src/hookbase/resources/destinations.py +0 -140
  81. hookbase-4.0.0/src/hookbase/resources/tunnels.py +0 -51
  82. hookbase-4.0.0/src/hookbase/webhook.py +0 -182
  83. hookbase-4.0.0/tests/test_webhook.py +0 -211
  84. {hookbase-4.0.0 → hookbase-4.2.0}/.github/workflows/python-publish.yml +0 -0
  85. {hookbase-4.0.0 → hookbase-4.2.0}/LICENSE +0 -0
  86. {hookbase-4.0.0 → hookbase-4.2.0}/examples/async_usage.py +0 -0
  87. {hookbase-4.0.0 → hookbase-4.2.0}/examples/error_handling.py +0 -0
  88. {hookbase-4.0.0 → hookbase-4.2.0}/examples/verify_webhooks.py +0 -0
  89. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/_wire.py +0 -0
  90. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/applications.py +0 -0
  91. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/catalog.py +0 -0
  92. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/common.py +0 -0
  93. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/event_types.py +0 -0
  94. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/models/subscriptions.py +0 -0
  95. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/py.typed +0 -0
  96. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/__init__.py +0 -0
  97. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/applications.py +0 -0
  98. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/endpoints.py +0 -0
  99. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/event_types.py +0 -0
  100. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/events.py +0 -0
  101. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/messages.py +0 -0
  102. {hookbase-4.0.0 → hookbase-4.2.0}/src/hookbase/resources/providers.py +0 -0
  103. {hookbase-4.0.0 → hookbase-4.2.0}/tests/__init__.py +0 -0
  104. {hookbase-4.0.0 → hookbase-4.2.0}/tests/conftest.py +0 -0
  105. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/__init__.py +0 -0
  106. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_applications.py +0 -0
  107. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_cron_jobs.py +0 -0
  108. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_dlq.py +0 -0
  109. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_messages.py +0 -0
  110. {hookbase-4.0.0 → hookbase-4.2.0}/tests/resources/test_source_contract.py +0 -0
  111. {hookbase-4.0.0 → hookbase-4.2.0}/tests/test_client.py +0 -0
  112. {hookbase-4.0.0 → hookbase-4.2.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/
@@ -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