hookbase 4.1.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 (99) hide show
  1. {hookbase-4.1.0 → hookbase-4.2.0}/PKG-INFO +107 -5
  2. {hookbase-4.1.0 → hookbase-4.2.0}/README.md +106 -4
  3. {hookbase-4.1.0 → hookbase-4.2.0}/examples/basic_inbound.py +3 -1
  4. {hookbase-4.1.0 → hookbase-4.2.0}/examples/send_webhooks.py +3 -1
  5. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/_pagination.py +6 -2
  6. hookbase-4.2.0/src/hookbase/_version.py +1 -0
  7. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/__init__.py +22 -0
  8. hookbase-4.2.0/src/hookbase/models/_base.py +20 -0
  9. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/api_keys.py +5 -3
  10. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/cron_jobs.py +13 -5
  11. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/deliveries.py +22 -2
  12. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/destinations.py +128 -15
  13. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/endpoints.py +37 -9
  14. hookbase-4.2.0/src/hookbase/models/events.py +239 -0
  15. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/messages.py +26 -0
  16. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/portal_tokens.py +7 -2
  17. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/routes.py +18 -0
  18. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/sources.py +27 -9
  19. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/transforms.py +49 -3
  20. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/tunnels.py +10 -6
  21. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/_base.py +3 -4
  22. hookbase-4.2.0/src/hookbase/resources/deliveries.py +186 -0
  23. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/destinations.py +82 -8
  24. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/transforms.py +15 -0
  25. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_destinations.py +229 -1
  26. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_endpoints.py +73 -1
  27. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_inbound_contract.py +244 -0
  28. hookbase-4.2.0/tests/resources/test_open_types.py +94 -0
  29. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_outbound_contract.py +89 -1
  30. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_resource_contracts.py +141 -0
  31. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_source_fields.py +12 -3
  32. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_sources.py +50 -1
  33. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_sync_async_parity.py +145 -1
  34. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_models_exports.py +12 -0
  35. hookbase-4.2.0/tests/test_secret_redaction.py +218 -0
  36. hookbase-4.1.0/src/hookbase/_version.py +0 -1
  37. hookbase-4.1.0/src/hookbase/models/_base.py +0 -14
  38. hookbase-4.1.0/src/hookbase/models/events.py +0 -116
  39. hookbase-4.1.0/src/hookbase/resources/deliveries.py +0 -99
  40. {hookbase-4.1.0 → hookbase-4.2.0}/.github/workflows/python-publish.yml +0 -0
  41. {hookbase-4.1.0 → hookbase-4.2.0}/.gitignore +0 -0
  42. {hookbase-4.1.0 → hookbase-4.2.0}/LICENSE +0 -0
  43. {hookbase-4.1.0 → hookbase-4.2.0}/examples/async_usage.py +0 -0
  44. {hookbase-4.1.0 → hookbase-4.2.0}/examples/error_handling.py +0 -0
  45. {hookbase-4.1.0 → hookbase-4.2.0}/examples/verify_webhooks.py +0 -0
  46. {hookbase-4.1.0 → hookbase-4.2.0}/pyproject.toml +0 -0
  47. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/__init__.py +0 -0
  48. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/_client.py +0 -0
  49. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/_constants.py +0 -0
  50. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/client.py +0 -0
  51. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/errors.py +0 -0
  52. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/_wire.py +0 -0
  53. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/analytics.py +0 -0
  54. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/applications.py +0 -0
  55. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/catalog.py +0 -0
  56. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/common.py +0 -0
  57. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/dlq.py +0 -0
  58. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/event_types.py +0 -0
  59. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/filters.py +0 -0
  60. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/organizations.py +0 -0
  61. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/schemas.py +0 -0
  62. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/models/subscriptions.py +0 -0
  63. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/py.typed +0 -0
  64. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/__init__.py +0 -0
  65. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/analytics.py +0 -0
  66. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/api_keys.py +0 -0
  67. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/applications.py +0 -0
  68. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/cron_jobs.py +0 -0
  69. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/dlq.py +0 -0
  70. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/endpoints.py +0 -0
  71. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/event_types.py +0 -0
  72. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/events.py +0 -0
  73. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/filters.py +0 -0
  74. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/message_log.py +0 -0
  75. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/messages.py +0 -0
  76. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/organizations.py +0 -0
  77. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/portal_tokens.py +0 -0
  78. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/providers.py +0 -0
  79. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/routes.py +0 -0
  80. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/schemas.py +0 -0
  81. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/sources.py +0 -0
  82. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/subscriptions.py +0 -0
  83. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/resources/tunnels.py +0 -0
  84. {hookbase-4.1.0 → hookbase-4.2.0}/src/hookbase/webhook.py +0 -0
  85. {hookbase-4.1.0 → hookbase-4.2.0}/tests/__init__.py +0 -0
  86. {hookbase-4.1.0 → hookbase-4.2.0}/tests/conftest.py +0 -0
  87. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/__init__.py +0 -0
  88. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_applications.py +0 -0
  89. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_cron_jobs.py +0 -0
  90. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_dlq.py +0 -0
  91. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_messages.py +0 -0
  92. {hookbase-4.1.0 → hookbase-4.2.0}/tests/resources/test_source_contract.py +0 -0
  93. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_client.py +0 -0
  94. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_errors.py +0 -0
  95. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_http_client.py +0 -0
  96. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_pagination.py +0 -0
  97. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_retry.py +0 -0
  98. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_slug_contract.py +0 -0
  99. {hookbase-4.1.0 → hookbase-4.2.0}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 4.1.0
3
+ Version: 4.2.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
@@ -145,23 +145,67 @@ for p in client.providers.list(category="payments").providers:
145
145
  print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
146
146
  client.providers.get("stripe").signature_verification
147
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
+
148
153
  # Destinations
149
154
  client.destinations.list()
150
- client.destinations.create({"name": "Backend", "url": "https://..."})
155
+ dest = client.destinations.create({"name": "Backend", "url": "https://..."})
151
156
  client.destinations.test("dst_id")
152
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
+
153
179
  # Routes
154
180
  client.routes.create({"name": "Route", "sourceId": "src_id", "destinationId": "dst_id"})
155
181
  client.routes.get_circuit_status("route_id")
156
182
  client.routes.reset_circuit("route_id")
157
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
+
158
190
  # Events & Deliveries
159
191
  client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
160
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
161
194
  client.deliveries.replay("del_id")
162
195
  client.deliveries.bulk_replay(["del_1", "del_2"])
163
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
+
164
207
  # Transforms & Filters
208
+ client.transforms.engines() # languages this deployment runs, and which are available
165
209
  client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
166
210
  client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
167
211
  client.filters.test(
@@ -189,18 +233,26 @@ client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
189
233
  # this". Name the setting in `clear` to send an explicit null for it:
190
234
  client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
191
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
+
192
241
  # Event Types & Subscriptions
193
242
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
194
243
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
195
244
  client.outbound.subscriptions.bulk_create("ep_id", ["et_1", "et_2"])
196
245
 
197
246
  # Send Events
198
- client.outbound.messages.send("app_id", event_type="order.created", payload={"orderId": "ord_1"})
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)
199
250
 
200
251
  # Message Log
201
- client.outbound.message_log.list(application_id="app_id")
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
202
254
  client.outbound.message_log.list_attempts("msg_id")
203
- client.outbound.message_log.stats()
255
+ client.outbound.message_log.stats() # .held among the counts
204
256
 
205
257
  # Dead Letter Queue
206
258
  client.outbound.dlq.list()
@@ -385,6 +437,56 @@ source = client.sources.create({"name": "GitHub", "slug": "github", "provider":
385
437
  # source with a provider newer than this package still parses.
386
438
  ```
387
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
+
388
490
  ## Upgrading to 4.1
389
491
 
390
492
  - `APIError.code` now carries the API's `code` from the response body (for example
@@ -107,23 +107,67 @@ for p in client.providers.list(category="payments").providers:
107
107
  print(p.hookbase_provider, p.signature_verification and p.signature_verification.secret_source)
108
108
  client.providers.get("stripe").signature_verification
109
109
 
110
+ # Record the catalog entry a source is made from (create only). Entries with no verifier of
111
+ # their own, such as Salesforce, save as Custom HMAC:
112
+ client.sources.create({"name": "Salesforce", "slug": "salesforce", "provider": "custom",
113
+ "catalogProvider": "salesforce"})
114
+
110
115
  # Destinations
111
116
  client.destinations.list()
112
- client.destinations.create({"name": "Backend", "url": "https://..."})
117
+ dest = client.destinations.create({"name": "Backend", "url": "https://..."})
113
118
  client.destinations.test("dst_id")
114
119
 
120
+ # HTTP destinations sign every delivery (Standard Webhooks headers), so the receiver can check
121
+ # it with Webhook(secret).verify(). Create returns the secret once: put it in your secret store.
122
+ # repr() and str() leave it out, so logging the destination doesn't leak it.
123
+ signing_secret = dest.signing_secret # whsec_...; dest.has_signing_secret is True
124
+ client.destinations.reveal_secret("dst_id").signing_secret
125
+ rotated = client.destinations.rotate_secret("dst_id", grace_period_seconds=3600)
126
+ rotated.previous_secret_expires_at # until then the old secret signs too
127
+
128
+ # Bridge destinations publish each delivery they receive as an outbound event
129
+ from hookbase.models import BridgeConfig, CreateDestinationParams
130
+
131
+ bridge = client.destinations.create(CreateDestinationParams(
132
+ name="Orders to customers",
133
+ type="bridge",
134
+ config=BridgeConfig(event_type_source="field", event_type_field="type",
135
+ event_type_prefix="order."),
136
+ ))
137
+ # A bridge's test is a dry run: nothing is published
138
+ result = client.destinations.test(bridge.id, payload={"type": "created"})
139
+ result.event_type, result.event_type_exists, result.matching_endpoints
140
+
115
141
  # Routes
116
142
  client.routes.create({"name": "Route", "sourceId": "src_id", "destinationId": "dst_id"})
117
143
  client.routes.get_circuit_status("route_id")
118
144
  client.routes.reset_circuit("route_id")
119
145
 
146
+ # While a route's circuit is open its deliveries are held (status "held", hold_expires_at set)
147
+ # and go out when it closes. hold_on_circuit_open=False dead-letters them as soon as the circuit
148
+ # opens instead.
149
+ client.routes.update("route_id", {"holdOnCircuitOpen": False})
150
+ client.routes.get("route_id").held_count # get only; held_count_capped past 10,000
151
+
120
152
  # Events & Deliveries
121
153
  client.events.list(source_id="src_id", from_date="2024-01-01") # id or slug; unknown -> 404
122
154
  client.events.get("evt_id") # Includes payload and deliveries
155
+ client.events.debug("evt_id").curl_command # plus typed deliveries, pattern hints, circuit state
123
156
  client.deliveries.replay("del_id")
124
157
  client.deliveries.bulk_replay(["del_1", "del_2"])
125
158
 
159
+ # Replay with edits. payload_patch is an RFC 7386 merge patch on the stored payload (None
160
+ # removes a key), so it works on sources that mask fields; modified_payload replaces it. An
161
+ # argument left as None is not sent.
162
+ client.deliveries.replay(
163
+ "del_id",
164
+ payload_patch={"amount": 10, "coupon": None},
165
+ destination_override="backup-destination", # id or slug
166
+ headers_override={"X-Replay": "1"},
167
+ )
168
+
126
169
  # Transforms & Filters
170
+ client.transforms.engines() # languages this deployment runs, and which are available
127
171
  client.transforms.create({"name": "Extract", "transformType": "jsonata", "code": "$.data"})
128
172
  client.transforms.test(code="$.data", payload={"data": {"key": "value"}}) # .result
129
173
  client.filters.test(
@@ -151,18 +195,26 @@ client.outbound.endpoints.rotate_secret("ep_id", grace_period=3600)
151
195
  # this". Name the setting in `clear` to send an explicit null for it:
152
196
  client.outbound.endpoints.update("ep_id", {"clear": ["retryDelays"]})
153
197
 
198
+ # While an endpoint's circuit is open its messages are held (status "held") and go out when it
199
+ # recovers; hold_on_circuit_open=False dead-letters them instead. FIFO endpoints never hold.
200
+ client.outbound.endpoints.update("ep_id", {"holdOnCircuitOpen": False})
201
+ client.outbound.endpoints.get("ep_id").held_count
202
+
154
203
  # Event Types & Subscriptions
155
204
  client.outbound.event_types.create({"name": "order.created", "category": "orders"})
156
205
  client.outbound.subscriptions.create({"endpointId": "ep_id", "eventTypeId": "et_id"})
157
206
  client.outbound.subscriptions.bulk_create("ep_id", ["et_1", "et_2"])
158
207
 
159
208
  # Send Events
160
- client.outbound.messages.send("app_id", event_type="order.created", payload={"orderId": "ord_1"})
209
+ sent = client.outbound.messages.send("app_id", event_type="order.created",
210
+ payload={"orderId": "ord_1"})
211
+ sent.messages_held # of messages_queued, those held behind an open circuit (None if none)
161
212
 
162
213
  # Message Log
163
- client.outbound.message_log.list(application_id="app_id")
214
+ client.outbound.message_log.list(application_id="app_id", status="held") # .hold_expires_at
215
+ client.outbound.message_log.get("msg_id").bridge # set when a bridge destination published it
164
216
  client.outbound.message_log.list_attempts("msg_id")
165
- client.outbound.message_log.stats()
217
+ client.outbound.message_log.stats() # .held among the counts
166
218
 
167
219
  # Dead Letter Queue
168
220
  client.outbound.dlq.list()
@@ -347,6 +399,56 @@ source = client.sources.create({"name": "GitHub", "slug": "github", "provider":
347
399
  # source with a provider newer than this package still parses.
348
400
  ```
349
401
 
402
+ ## Upgrading to 4.2
403
+
404
+ - **Fix:** `destinations.list()` and `get()` raised a `ValidationError` when the organization had
405
+ a destination of a type other than `http`, `s3`, `r2`, `gcs` or `azure_blob` (`sqs`,
406
+ `eventbridge`, `servicebus`, `pubsub`, `oci_queue`, `bridge`). `DestinationType`,
407
+ `TransformType` and `ContentFormat` now accept any string, as `MessageStatus` does, so types
408
+ added later parse too. `"form"`, which the API never accepted, is gone from `ContentFormat`.
409
+ - **Fix:** `events.debug()` returned an empty `curl_command`, `event.headers` and
410
+ `event.payload`; they are filled in now. Its `deliveries` are typed (`EventDebugDelivery`).
411
+ - `destinations.create()` returns a `DestinationWithSecret`, whose `signing_secret` is the only
412
+ time the secret is returned; afterwards use `reveal_secret()`. `rotate_secret()` takes
413
+ `grace_period_seconds`. `Destination.has_signing_secret` says whether deliveries are signed.
414
+ - Bridge destinations: `BridgeConfig` for `config`, `Destination.bridge_config` to read it,
415
+ `destinations.test(id, payload=...)` for a dry run, and the dry-run fields on `TestResult`.
416
+ - `deliveries.replay()` takes `modified_payload`, `payload_patch`, `destination_override`,
417
+ `transform_override`, `headers_override` and `persist_transform`; `ReplayResult` reports what
418
+ changed. Passing both payload edits raises `ValueError`. An argument left as None is not sent,
419
+ so `modified_payload` can be any JSON value but null. The same goes for `payload` on
420
+ `destinations.test()`.
421
+ - Holds: the `held` status on deliveries and outbound messages, with `hold_expires_at`;
422
+ `hold_on_circuit_open`, `held_count` and `held_count_capped` on routes and webhook endpoints;
423
+ `messages_held` on `SendEventResponse` and `held` on `StatsSummary`. `MessageStatus` also
424
+ gains `awaiting_pull` and `fifo_waiting`.
425
+ - `transforms.engines()` lists the transform languages the deployment runs.
426
+ - `catalog_provider` on sources (set on create only).
427
+ - `sources.export()` returns a raw dict, and the API now sends its `ipAllowlist`, `ipDenylist`,
428
+ `encryptFields` and `maskFields` as JSON arrays; they used to be JSON-encoded strings. Code
429
+ that calls `json.loads` on them must stop. The `Source` model already read either form.
430
+ - Secrets and credentials no longer appear in `repr()` or `str()`:
431
+ - The secrets the API returns, on `DestinationWithSecret`, `DestinationSecret`,
432
+ `SourceWithSecret`, `PendingVerification`, `WebhookEndpoint`, `EndpointWithSecret`,
433
+ `RotateSecretResult`, `ApiKeyWithSecret`, `Tunnel`, `TunnelToken` and `PortalToken`.
434
+ - The headers and auth configs the API returns in full to a key with the `write` scope:
435
+ `Destination.headers` and `auth_config`, `WebhookEndpoint.headers` (and each
436
+ `CustomHeader.value`), and `CronJob.headers`.
437
+ - The credentials you pass in: `signing_secret` and `verify_token` on `CreateSourceParams`
438
+ and `UpdateSourceParams`; `headers`, `auth_config` and `config` on `CreateDestinationParams`
439
+ and `UpdateDestinationParams`; `headers` on the endpoint and cron job params; and the keys
440
+ on `S3Config`, `GCSConfig` and `AzureBlobConfig`.
441
+
442
+ A `pydantic.ValidationError` from one of these models, or from a list of them, leaves out the
443
+ input it was given. The values are still sent and still readable from their attributes
444
+ (`dest.signing_secret`).
445
+ - **Typing:** `CreateDestinationParams.config` and `UpdateDestinationParams.config` are now
446
+ `dict[str, Any] | BridgeConfig | None`, so `params.config["bucket"]` no longer type-checks
447
+ until you narrow it with `isinstance(params.config, dict)`. Because `DestinationType`,
448
+ `TransformType` and `ContentFormat` now include `str`, mypy reads `Destination.type`,
449
+ `Transform.transform_type`, `input_format` and `output_format` as plain `str`. An exhaustive
450
+ `match` or `assert_never` over their literals no longer type-checks.
451
+
350
452
  ## Upgrading to 4.1
351
453
 
352
454
  - `APIError.code` now carries the API's `code` from the response body (for example
@@ -13,7 +13,9 @@ source = client.sources.create({
13
13
  })
14
14
  print(f"Source created: {source.id}")
15
15
  print(f"Ingest URL: {source.ingest_url}")
16
- print(f"Signing secret: {source.signing_secret}")
16
+ # The signing secret is returned this once. Put it in your secret store (and GitHub's webhook
17
+ # settings) instead of printing or logging it; client.sources.reveal_secret() shows it again.
18
+ github_signing_secret = source.signing_secret
17
19
 
18
20
  # Create a destination to forward webhooks to
19
21
  dest = client.destinations.create({
@@ -18,7 +18,9 @@ endpoint = client.outbound.endpoints.create(app.id, {
18
18
  "description": "Production webhook endpoint",
19
19
  })
20
20
  print(f"Endpoint: {endpoint.id}")
21
- print(f"Endpoint secret: {endpoint.secret}")
21
+ # The endpoint's signing secret is returned this once. Put it in your secret store, and hand it to
22
+ # the customer to verify deliveries with, instead of printing or logging it.
23
+ endpoint_secret = endpoint.secret
22
24
 
23
25
  # Create event types
24
26
  order_type = client.outbound.event_types.create({
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from collections.abc import AsyncIterator, Iterator
4
4
  from typing import Any, Generic, TypeVar
5
5
 
6
- from pydantic import TypeAdapter
6
+ from pydantic import ConfigDict, TypeAdapter
7
7
 
8
8
  from ._client import AsyncTransport, SyncTransport
9
9
 
@@ -360,7 +360,11 @@ class AsyncLimitOffsetPage(Generic[T]):
360
360
 
361
361
 
362
362
  def _parse_list(raw: Any, model: type[T]) -> list[T]:
363
- adapter = TypeAdapter(list[model]) # type: ignore[valid-type]
363
+ # pydantic takes hide_input_in_errors from the outermost type, so a list of a model that
364
+ # carries a secret has to ask for it too (models/_base.py SECRET_MODEL_CONFIG).
365
+ hides_input = getattr(model, "model_config", {}).get("hide_input_in_errors", False)
366
+ config = ConfigDict(hide_input_in_errors=True) if hides_input else None
367
+ adapter = TypeAdapter(list[model], config=config) # type: ignore[valid-type]
364
368
  return adapter.validate_python(raw)
365
369
 
366
370
 
@@ -0,0 +1 @@
1
+ __version__ = "4.2.0"
@@ -63,9 +63,13 @@ from .deliveries import (
63
63
  from .destinations import (
64
64
  AuthType,
65
65
  AzureBlobConfig,
66
+ BridgeConfig,
67
+ BridgeEventTypeSource,
66
68
  CreateDestinationParams,
67
69
  Destination,
70
+ DestinationSecret,
68
71
  DestinationType,
72
+ DestinationWithSecret,
69
73
  FieldMapping,
70
74
  FieldMappingType,
71
75
  FileFormat,
@@ -109,8 +113,12 @@ from .event_types import CreateEventTypeParams, EventType, UpdateEventTypeParams
109
113
  from .events import (
110
114
  DeliveryStats,
111
115
  Event,
116
+ EventDebugDelivery,
117
+ EventDebugDestination,
112
118
  EventDebugEvent,
113
119
  EventDebugInfo,
120
+ EventDebugPatternHint,
121
+ EventDebugRouteCircuit,
114
122
  EventDelivery,
115
123
  EventDetail,
116
124
  InboundEventStatus,
@@ -127,6 +135,7 @@ from .messages import (
127
135
  MessageStatus,
128
136
  OutboundAttempt,
129
137
  OutboundMessage,
138
+ OutboundMessageBridge,
130
139
  SendEventEndpoint,
131
140
  SendEventParams,
132
141
  SendEventResponse,
@@ -176,6 +185,8 @@ from .transforms import (
176
185
  ContentFormat,
177
186
  CreateTransformParams,
178
187
  Transform,
188
+ TransformEngine,
189
+ TransformEnginesResponse,
179
190
  TransformTestResult,
180
191
  TransformType,
181
192
  UpdateTransformParams,
@@ -249,9 +260,13 @@ __all__ = [
249
260
  # Destinations
250
261
  "AuthType",
251
262
  "AzureBlobConfig",
263
+ "BridgeConfig",
264
+ "BridgeEventTypeSource",
252
265
  "CreateDestinationParams",
253
266
  "Destination",
267
+ "DestinationSecret",
254
268
  "DestinationType",
269
+ "DestinationWithSecret",
255
270
  "FieldMapping",
256
271
  "GCSConfig",
257
272
  "HttpMethod",
@@ -299,6 +314,10 @@ __all__ = [
299
314
  "EventDetail",
300
315
  "InboundEventStatus",
301
316
  "EventDebugEvent",
317
+ "EventDebugDelivery",
318
+ "EventDebugDestination",
319
+ "EventDebugPatternHint",
320
+ "EventDebugRouteCircuit",
302
321
  "EventDelivery",
303
322
  # Filters
304
323
  "CreateFilterParams",
@@ -311,6 +330,7 @@ __all__ = [
311
330
  "MessageStatus",
312
331
  "OutboundAttempt",
313
332
  "OutboundMessage",
333
+ "OutboundMessageBridge",
314
334
  "OutboundReplayResult",
315
335
  "SendEventParams",
316
336
  "SendEventResponse",
@@ -360,6 +380,8 @@ __all__ = [
360
380
  "ContentFormat",
361
381
  "CreateTransformParams",
362
382
  "Transform",
383
+ "TransformEngine",
384
+ "TransformEnginesResponse",
363
385
  "TransformTestResult",
364
386
  "TransformType",
365
387
  "UpdateTransformParams",
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+ from pydantic.alias_generators import to_camel
5
+
6
+ #: `model_config` for a model that carries a secret or credential; pydantic merges it over
7
+ #: HookbaseModel's. A validation error then leaves out its input, which pydantic otherwise prints
8
+ #: truncated and whose tail can be the secret; `_pagination._parse_list` does the same for a list
9
+ #: of one. Give the secret field `Field(repr=False)` too, for repr() and str().
10
+ SECRET_MODEL_CONFIG = ConfigDict(hide_input_in_errors=True)
11
+
12
+
13
+ class HookbaseModel(BaseModel):
14
+ """Base model with camelCase alias support for API compatibility."""
15
+
16
+ model_config = ConfigDict(
17
+ alias_generator=to_camel,
18
+ populate_by_name=True,
19
+ extra="ignore",
20
+ )
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Any
5
5
 
6
- from pydantic import field_validator
6
+ from pydantic import Field, field_validator
7
7
 
8
- from ._base import HookbaseModel
8
+ from ._base import SECRET_MODEL_CONFIG, HookbaseModel
9
9
 
10
10
 
11
11
  class ApiKey(HookbaseModel):
@@ -27,7 +27,9 @@ class ApiKey(HookbaseModel):
27
27
 
28
28
 
29
29
  class ApiKeyWithSecret(ApiKey):
30
- key: str = ""
30
+ model_config = SECRET_MODEL_CONFIG
31
+
32
+ key: str = Field(default="", repr=False)
31
33
 
32
34
 
33
35
  class CreateApiKeyParams(HookbaseModel):
@@ -3,21 +3,25 @@ from __future__ import annotations
3
3
  import json
4
4
  from typing import Literal
5
5
 
6
- from pydantic import field_validator
6
+ from pydantic import Field, field_validator
7
7
 
8
- from ._base import HookbaseModel
8
+ from ._base import SECRET_MODEL_CONFIG, HookbaseModel
9
9
 
10
10
  CronJobExecutionStatus = Literal["success", "failed", "running"]
11
11
 
12
12
 
13
13
  class CronJob(HookbaseModel):
14
+ # The API returns headers, which hold the job's credentials, in full to a key with the write
15
+ # scope.
16
+ model_config = SECRET_MODEL_CONFIG
17
+
14
18
  id: str
15
19
  organization_id: str | None = None
16
20
  name: str
17
21
  description: str | None = None
18
22
  url: str = ""
19
23
  method: str = "GET"
20
- headers: dict[str, str] | None = None
24
+ headers: dict[str, str] | None = Field(default=None, repr=False)
21
25
  payload: str | None = None
22
26
  cron_expression: str = ""
23
27
  timezone: str = "UTC"
@@ -66,11 +70,13 @@ class CronJobExecution(HookbaseModel):
66
70
 
67
71
 
68
72
  class CreateCronJobParams(HookbaseModel):
73
+ model_config = SECRET_MODEL_CONFIG
74
+
69
75
  name: str
70
76
  description: str | None = None
71
77
  url: str
72
78
  method: str | None = None
73
- headers: dict[str, str] | None = None
79
+ headers: dict[str, str] | None = Field(default=None, repr=False)
74
80
  payload: str | None = None
75
81
  cron_expression: str
76
82
  timezone: str | None = None
@@ -84,11 +90,13 @@ class CreateCronJobParams(HookbaseModel):
84
90
 
85
91
 
86
92
  class UpdateCronJobParams(HookbaseModel):
93
+ model_config = SECRET_MODEL_CONFIG
94
+
87
95
  name: str | None = None
88
96
  description: str | None = None
89
97
  url: str | None = None
90
98
  method: str | None = None
91
- headers: dict[str, str] | None = None
99
+ headers: dict[str, str] | None = Field(default=None, repr=False)
92
100
  payload: str | None = None
93
101
  cron_expression: str | None = None
94
102
  timezone: str | None = None
@@ -8,8 +8,9 @@ from pydantic.alias_generators import to_camel
8
8
 
9
9
  from ._base import HookbaseModel
10
10
 
11
- # API may return: pending, success, failed, retrying, delivered,
12
- # failed_over, schema_failed, exhausted, etc.
11
+ # The API stores: pending, delivered, failed, failed_over, schema_failed, throttled,
12
+ # awaiting_retry (a retry scheduled for later; see `next_retry_at`) and held (parked while the
13
+ # route's circuit is open; see `hold_expires_at`). Any string, so a status added later still parses.
13
14
  DeliveryStatus = str
14
15
 
15
16
 
@@ -23,6 +24,11 @@ class Delivery(HookbaseModel):
23
24
  attempt_count: int = 0
24
25
  max_attempts: int = 5
25
26
  next_retry_at: str | None = None
27
+ """When the next retry is due. None for a held delivery: see `hold_expires_at`."""
28
+ hold_expires_at: str | None = None
29
+ """For a `held` delivery (parked because the route's circuit is open and the route has
30
+ `hold_on_circuit_open`): when the hold ends and the delivery fails if the circuit has not
31
+ closed. None for every other status."""
26
32
  response_status: int | None = None
27
33
  response_key: str | None = None
28
34
  response_headers: dict[str, Any] | None = None
@@ -121,8 +127,22 @@ class DeliveryDetail(Delivery):
121
127
 
122
128
 
123
129
  class ReplayResult(HookbaseModel):
130
+ """The result of `deliveries.replay`. The replay is a new delivery; the original keeps its
131
+ status."""
132
+
124
133
  delivery_id: str
134
+ """The new delivery's id."""
125
135
  message: str = ""
136
+ modified: bool = False
137
+ """Whether the replay sent an edited payload (`modified_payload` or `payload_patch`)."""
138
+ destination_overridden: bool = False
139
+ transform_overridden: bool = False
140
+ transform_persisted: bool = False
141
+ """Whether the transform override was also saved as the route's transform."""
142
+ headers_overridden: bool = False
143
+ target_destination_id: str | None = None
144
+ """The destination the replay goes to: the override when one was given, otherwise the
145
+ original's."""
126
146
 
127
147
 
128
148
  class BulkReplayResult(HookbaseModel):