hookbridge 1.2.0__tar.gz → 1.4.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(mypy hookbridge:*)",
5
+ "Bash(ruff check:*)"
6
+ ]
7
+ }
8
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hookbridge
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Official HookBridge SDK for Python
5
5
  Project-URL: Homepage, https://hookbridge.io
6
6
  Project-URL: Documentation, https://hookbridge.io/docs/sdks/python
@@ -188,6 +188,73 @@ print("Save this key:", new_key.key) # Only shown once!
188
188
  client.delete_api_key("key_abc123")
189
189
  ```
190
190
 
191
+ ### Inbound Webhooks
192
+
193
+ ```python
194
+ # Create an inbound endpoint
195
+ inbound = client.create_inbound_endpoint(
196
+ url="https://myapp.com/webhooks/inbound",
197
+ name="Stripe inbound",
198
+ description="Receives Stripe events through HookBridge",
199
+ verify_static_token=True,
200
+ token_header_name="X-Webhook-Token",
201
+ token_value="my-shared-secret",
202
+ signing_enabled=True,
203
+ idempotency_header_names=["X-Idempotency-Key"],
204
+ ingest_response_code=202,
205
+ )
206
+
207
+ print("Inbound endpoint ID:", inbound.id)
208
+ print("Send webhooks here:", inbound.ingest_url) # Save this
209
+ print("Secret token:", inbound.secret_token) # Only shown once
210
+
211
+ # Inspect and manage the inbound endpoint
212
+ details = client.get_inbound_endpoint(inbound.id)
213
+ client.pause_inbound_endpoint(inbound.id)
214
+ client.resume_inbound_endpoint(inbound.id)
215
+
216
+ # Update verification settings later if needed
217
+ client.update_inbound_endpoint(
218
+ inbound.id,
219
+ verify_hmac=True,
220
+ hmac_header_name="X-Signature",
221
+ hmac_secret="whsec_inbound_secret",
222
+ )
223
+ ```
224
+
225
+ ### Inbound Observability
226
+
227
+ ```python
228
+ # List inbound endpoints
229
+ inbound_endpoints = client.list_inbound_endpoints(limit=50)
230
+
231
+ # Query inbound delivery logs
232
+ logs = client.get_inbound_logs(
233
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
234
+ limit=50,
235
+ )
236
+ for entry in logs.entries:
237
+ print(entry.message_id, entry.status, entry.total_delivery_ms)
238
+
239
+ # Get inbound metrics and time series
240
+ metrics = client.get_inbound_metrics(
241
+ "24h",
242
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
243
+ )
244
+ timeseries = client.get_inbound_timeseries_metrics(
245
+ "24h",
246
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
247
+ )
248
+
249
+ # Review rejected inbound requests
250
+ rejections = client.list_inbound_rejections(
251
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
252
+ limit=25,
253
+ )
254
+ for rejection in rejections.entries:
255
+ print(rejection.reason_code, rejection.reason_detail)
256
+ ```
257
+
191
258
  ## Async Usage
192
259
 
193
260
  ```python
@@ -11,20 +11,31 @@
11
11
  - Create account at https://pypi.org/account/register/
12
12
  - Generate API token at https://pypi.org/manage/account/token/
13
13
 
14
- 3. Configure credentials in `~/.pypirc`:
14
+ 3. Recommended authentication: configure the API token in `~/.pypirc`:
15
15
  ```ini
16
16
  [pypi]
17
17
  username = __token__
18
18
  password = pypi-xxxxxxxxxxxxx
19
19
  ```
20
+ 4. Work from a clean local checkout. You do not need to create a GitHub release before uploading to PyPI, but you should avoid publishing uncommitted local changes by accident.
20
21
 
21
22
  ## Publishing Steps
22
23
 
23
24
  ### 1. Update Version
24
25
 
25
- Update version in both files:
26
- - `hookbridge/__init__.py` - `__version__ = "X.Y.Z"`
27
- - `pyproject.toml` - `version = "X.Y.Z"`
26
+ Update the version in both files to the exact same value:
27
+ - `hookbridge/__init__.py`
28
+ - `pyproject.toml`
29
+
30
+ Example change:
31
+
32
+ ```python
33
+ __version__ = "1.3.1"
34
+ ```
35
+
36
+ ```toml
37
+ version = "1.3.1"
38
+ ```
28
39
 
29
40
  ### 2. Run Tests
30
41
 
@@ -67,6 +78,18 @@ pip install --index-url https://test.pypi.org/simple/ hookbridge
67
78
  twine upload dist/*
68
79
  ```
69
80
 
81
+ If you use multiple PyPI accounts on the same machine, verify the active credentials in `~/.pypirc` before uploading.
82
+
83
+ Typical release order:
84
+
85
+ 1. bump the version in `hookbridge/__init__.py` and `pyproject.toml`
86
+ 2. run tests and checks
87
+ 3. build the distributions
88
+ 4. optionally upload to TestPyPI first
89
+ 5. run `twine upload dist/*`
90
+ 6. verify install from PyPI
91
+ 7. commit/tag/push if you have not already
92
+
70
93
  ### 6. Verify
71
94
 
72
95
  ```bash
@@ -154,6 +154,73 @@ print("Save this key:", new_key.key) # Only shown once!
154
154
  client.delete_api_key("key_abc123")
155
155
  ```
156
156
 
157
+ ### Inbound Webhooks
158
+
159
+ ```python
160
+ # Create an inbound endpoint
161
+ inbound = client.create_inbound_endpoint(
162
+ url="https://myapp.com/webhooks/inbound",
163
+ name="Stripe inbound",
164
+ description="Receives Stripe events through HookBridge",
165
+ verify_static_token=True,
166
+ token_header_name="X-Webhook-Token",
167
+ token_value="my-shared-secret",
168
+ signing_enabled=True,
169
+ idempotency_header_names=["X-Idempotency-Key"],
170
+ ingest_response_code=202,
171
+ )
172
+
173
+ print("Inbound endpoint ID:", inbound.id)
174
+ print("Send webhooks here:", inbound.ingest_url) # Save this
175
+ print("Secret token:", inbound.secret_token) # Only shown once
176
+
177
+ # Inspect and manage the inbound endpoint
178
+ details = client.get_inbound_endpoint(inbound.id)
179
+ client.pause_inbound_endpoint(inbound.id)
180
+ client.resume_inbound_endpoint(inbound.id)
181
+
182
+ # Update verification settings later if needed
183
+ client.update_inbound_endpoint(
184
+ inbound.id,
185
+ verify_hmac=True,
186
+ hmac_header_name="X-Signature",
187
+ hmac_secret="whsec_inbound_secret",
188
+ )
189
+ ```
190
+
191
+ ### Inbound Observability
192
+
193
+ ```python
194
+ # List inbound endpoints
195
+ inbound_endpoints = client.list_inbound_endpoints(limit=50)
196
+
197
+ # Query inbound delivery logs
198
+ logs = client.get_inbound_logs(
199
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
200
+ limit=50,
201
+ )
202
+ for entry in logs.entries:
203
+ print(entry.message_id, entry.status, entry.total_delivery_ms)
204
+
205
+ # Get inbound metrics and time series
206
+ metrics = client.get_inbound_metrics(
207
+ "24h",
208
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
209
+ )
210
+ timeseries = client.get_inbound_timeseries_metrics(
211
+ "24h",
212
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
213
+ )
214
+
215
+ # Review rejected inbound requests
216
+ rejections = client.list_inbound_rejections(
217
+ inbound_endpoint_id="01935abc-def0-7123-4567-890abcdef012",
218
+ limit=25,
219
+ )
220
+ for rejection in rejections.entries:
221
+ print(rejection.reason_code, rejection.reason_detail)
222
+ ```
223
+
157
224
  ## Async Usage
158
225
 
159
226
  ```python
@@ -44,16 +44,14 @@ from hookbridge.types import (
44
44
  MessageSummary,
45
45
  Metrics,
46
46
  MetricsWindow,
47
- NotificationPreferences,
48
47
  PauseState,
49
- Personalization,
50
48
  PortalSession,
51
49
  Project,
50
+ ReplayableMessageStatus,
52
51
  ReplayAllMessagesResponse,
53
52
  ReplayBatchMessagesResponse,
54
53
  ReplayBatchResult,
55
54
  ReplayMessageResponse,
56
- ReplayableMessageStatus,
57
55
  RotateSecretResponse,
58
56
  SendWebhookResponse,
59
57
  SigningKey,
@@ -67,7 +65,7 @@ from hookbridge.types import (
67
65
  UsageHistoryRow,
68
66
  )
69
67
 
70
- __version__ = "1.2.0"
68
+ __version__ = "1.4.0"
71
69
 
72
70
  __all__ = [
73
71
  "HookBridge",
@@ -101,9 +99,7 @@ __all__ = [
101
99
  "MessageSummary",
102
100
  "Metrics",
103
101
  "MetricsWindow",
104
- "NotificationPreferences",
105
102
  "PauseState",
106
- "Personalization",
107
103
  "PortalSession",
108
104
  "Project",
109
105
  "ReplayAllMessagesResponse",
@@ -51,16 +51,14 @@ from hookbridge.types import (
51
51
  MessageSummary,
52
52
  Metrics,
53
53
  MetricsWindow,
54
- NotificationPreferences,
55
54
  PauseState,
56
- Personalization,
57
55
  PortalSession,
58
56
  Project,
57
+ ReplayableMessageStatus,
59
58
  ReplayAllMessagesResponse,
60
59
  ReplayBatchMessagesResponse,
61
60
  ReplayBatchResult,
62
61
  ReplayMessageResponse,
63
- ReplayableMessageStatus,
64
62
  RotateSecretResponse,
65
63
  SendWebhookResponse,
66
64
  SigningKey,
@@ -95,9 +93,7 @@ def _query_string(params: dict[str, Any]) -> str:
95
93
  for key, value in params.items():
96
94
  if value is None:
97
95
  continue
98
- if isinstance(value, datetime):
99
- encoded[key] = value.isoformat()
100
- elif isinstance(value, date):
96
+ if isinstance(value, (datetime, date)):
101
97
  encoded[key] = value.isoformat()
102
98
  else:
103
99
  encoded[key] = str(value)
@@ -111,6 +107,14 @@ def _with_query(path: str, params: dict[str, Any]) -> str:
111
107
  return f"{path}?{query}"
112
108
 
113
109
 
110
+ def _json_value(value: Any) -> Any:
111
+ if isinstance(value, datetime):
112
+ return value.isoformat()
113
+ if isinstance(value, date):
114
+ return value.isoformat()
115
+ return value
116
+
117
+
114
118
  def _parse_message(data: dict[str, Any]) -> Message:
115
119
  return Message(
116
120
  id=data["id"],
@@ -224,6 +228,7 @@ def _parse_endpoint(data: dict[str, Any]) -> Endpoint:
224
228
  url=data["url"],
225
229
  created_at=_parse_datetime(data["created_at"]),
226
230
  updated_at=_parse_datetime(data["updated_at"]),
231
+ paused=data["paused"],
227
232
  description=data.get("description"),
228
233
  rate_limit_rps=data.get("rate_limit_rps"),
229
234
  burst=data.get("burst"),
@@ -236,6 +241,7 @@ def _parse_endpoint_summary(data: dict[str, Any]) -> EndpointSummary:
236
241
  id=data["id"],
237
242
  url=data["url"],
238
243
  created_at=_parse_datetime(data["created_at"]),
244
+ paused=data["paused"],
239
245
  description=data.get("description"),
240
246
  )
241
247
 
@@ -294,31 +300,6 @@ def _parse_rotate_secret(data: dict[str, Any]) -> RotateSecretResponse:
294
300
  )
295
301
 
296
302
 
297
- def _parse_personalization(data: dict[str, Any]) -> Personalization:
298
- return Personalization(
299
- id=data["id"],
300
- user_id=data["user_id"],
301
- timezone=data["timezone"],
302
- date_format=data["date_format"],
303
- locale=data["locale"],
304
- created_at=_parse_datetime(data["created_at"]),
305
- updated_at=_parse_datetime(data["updated_at"]),
306
- )
307
-
308
-
309
- def _parse_notification_preferences(data: dict[str, Any]) -> NotificationPreferences:
310
- return NotificationPreferences(
311
- id=data["id"],
312
- user_id=data["user_id"],
313
- enabled_alert_types=data["enabled_alert_types"],
314
- delivery_channels=data["delivery_channels"],
315
- failure_rate_threshold=data["failure_rate_threshold"],
316
- pending_backlog_threshold=data["pending_backlog_threshold"],
317
- created_at=_parse_datetime(data["created_at"]),
318
- updated_at=_parse_datetime(data["updated_at"]),
319
- )
320
-
321
-
322
303
  def _parse_usage_history_row(data: dict[str, Any]) -> UsageHistoryRow:
323
304
  return UsageHistoryRow(
324
305
  period_start=_parse_date(data["period_start"]),
@@ -558,7 +539,9 @@ class HookBridge:
558
539
  return _parse_replay_all_messages(self._request("POST", path)["data"])
559
540
 
560
541
  def replay_batch_messages(self, message_ids: list[str]) -> ReplayBatchMessagesResponse:
561
- response = self._request("POST", "/v1/messages/replay-batch", json={"message_ids": message_ids})
542
+ response = self._request(
543
+ "POST", "/v1/messages/replay-batch", json={"message_ids": message_ids}
544
+ )
562
545
  return _parse_replay_batch_messages(response["data"])
563
546
 
564
547
  def get_logs(
@@ -605,7 +588,8 @@ class HookBridge:
605
588
  def get_dlq_messages(
606
589
  self, *, limit: Optional[int] = None, cursor: Optional[str] = None
607
590
  ) -> DLQMessagesResponse:
608
- response = self._request("GET", _with_query("/v1/dlq/messages", {"limit": limit, "cursor": cursor}))
591
+ path = _with_query("/v1/dlq/messages", {"limit": limit, "cursor": cursor})
592
+ response = self._request("GET", path)
609
593
  data = response["data"]
610
594
  return DLQMessagesResponse(
611
595
  messages=[_parse_message_summary(item) for item in data.get("messages", [])],
@@ -650,7 +634,11 @@ class HookBridge:
650
634
  return _parse_project(self._request("GET", f"/v1/projects/{project_id}")["data"])
651
635
 
652
636
  def update_project(
653
- self, project_id: str, *, name: Optional[str] = None, rate_limit_default: Optional[int] = None
637
+ self,
638
+ project_id: str,
639
+ *,
640
+ name: Optional[str] = None,
641
+ rate_limit_default: Optional[int] = None,
654
642
  ) -> Project:
655
643
  body: dict[str, Any] = {}
656
644
  if name is not None:
@@ -697,7 +685,8 @@ class HookBridge:
697
685
  def list_endpoints(
698
686
  self, *, limit: Optional[int] = None, cursor: Optional[str] = None
699
687
  ) -> ListEndpointsResponse:
700
- response = self._request("GET", _with_query("/v1/endpoints", {"limit": limit, "cursor": cursor}))
688
+ path = _with_query("/v1/endpoints", {"limit": limit, "cursor": cursor})
689
+ response = self._request("GET", path)
701
690
  meta = response.get("meta", {})
702
691
  next_cursor = meta.get("next_cursor")
703
692
  return ListEndpointsResponse(
@@ -732,6 +721,18 @@ class HookBridge:
732
721
  def delete_endpoint(self, endpoint_id: str) -> None:
733
722
  self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
734
723
 
724
+ def pause_endpoint(self, endpoint_id: str) -> PauseState:
725
+ data = self._request("POST", f"/v1/endpoints/{endpoint_id}/pause")["data"]
726
+ return PauseState(id=data["id"], paused=data["paused"])
727
+
728
+ def resume_endpoint(self, endpoint_id: str) -> PauseState:
729
+ data = self._request("POST", f"/v1/endpoints/{endpoint_id}/resume")["data"]
730
+ return PauseState(
731
+ id=data["id"],
732
+ paused=data["paused"],
733
+ messages_requeued=data.get("messages_requeued"),
734
+ )
735
+
735
736
  def create_endpoint_signing_key(self, endpoint_id: str) -> RotateSecretResponse:
736
737
  data = self._request("POST", f"/v1/endpoints/{endpoint_id}/signing-keys")["data"]
737
738
  return _parse_rotate_secret(data)
@@ -746,55 +747,6 @@ class HookBridge:
746
747
  def rotate_endpoint_secret(self, endpoint_id: str) -> RotateSecretResponse:
747
748
  return self.create_endpoint_signing_key(endpoint_id)
748
749
 
749
- def get_personalization(self) -> Personalization:
750
- return _parse_personalization(
751
- self._request("GET", "/v1/settings/personalization")["data"]
752
- )
753
-
754
- def update_personalization(
755
- self,
756
- *,
757
- timezone: Optional[str] = None,
758
- date_format: Optional[str] = None,
759
- locale: Optional[str] = None,
760
- ) -> Personalization:
761
- body: dict[str, Any] = {}
762
- if timezone is not None:
763
- body["timezone"] = timezone
764
- if date_format is not None:
765
- body["date_format"] = date_format
766
- if locale is not None:
767
- body["locale"] = locale
768
- return _parse_personalization(
769
- self._request("PUT", "/v1/settings/personalization", json=body)["data"]
770
- )
771
-
772
- def get_notification_preferences(self) -> NotificationPreferences:
773
- return _parse_notification_preferences(
774
- self._request("GET", "/v1/settings/notifications")["data"]
775
- )
776
-
777
- def update_notification_preferences(
778
- self,
779
- *,
780
- enabled_alert_types: Optional[list[str]] = None,
781
- delivery_channels: Optional[list[str]] = None,
782
- failure_rate_threshold: Optional[int] = None,
783
- pending_backlog_threshold: Optional[int] = None,
784
- ) -> NotificationPreferences:
785
- body: dict[str, Any] = {}
786
- if enabled_alert_types is not None:
787
- body["enabled_alert_types"] = enabled_alert_types
788
- if delivery_channels is not None:
789
- body["delivery_channels"] = delivery_channels
790
- if failure_rate_threshold is not None:
791
- body["failure_rate_threshold"] = failure_rate_threshold
792
- if pending_backlog_threshold is not None:
793
- body["pending_backlog_threshold"] = pending_backlog_threshold
794
- return _parse_notification_preferences(
795
- self._request("PUT", "/v1/settings/notifications", json=body)["data"]
796
- )
797
-
798
750
  def create_checkout(self, *, plan: str, interval: str) -> CheckoutSession:
799
751
  data = self._request(
800
752
  "POST", "/v1/billing/checkout", json={"plan": plan, "interval": interval}
@@ -1038,7 +990,10 @@ class HookBridge:
1038
990
  status: Optional[MessageStatus] = None,
1039
991
  endpoint_id: Optional[str] = None,
1040
992
  ) -> ExportRecord:
1041
- body: dict[str, Any] = {"start_time": start_time, "end_time": end_time}
993
+ body: dict[str, Any] = {
994
+ "start_time": _json_value(start_time),
995
+ "end_time": _json_value(end_time),
996
+ }
1042
997
  if status is not None:
1043
998
  body["status"] = status
1044
999
  if endpoint_id is not None:
@@ -1243,7 +1198,8 @@ class AsyncHookBridge:
1243
1198
  await self._request("POST", f"/v1/dlq/replay/{message_id}")
1244
1199
 
1245
1200
  async def list_api_keys(self) -> list[APIKeyInfo]:
1246
- return [_parse_api_key_info(item) for item in (await self._request("GET", "/v1/api-keys"))["data"]]
1201
+ response = await self._request("GET", "/v1/api-keys")
1202
+ return [_parse_api_key_info(item) for item in response["data"]]
1247
1203
 
1248
1204
  async def create_api_key(
1249
1205
  self, mode: APIKeyMode, *, label: Optional[str] = None
@@ -1264,9 +1220,12 @@ class AsyncHookBridge:
1264
1220
  await self._request("DELETE", f"/v1/api-keys/{key_id}")
1265
1221
 
1266
1222
  async def list_projects(self) -> list[Project]:
1267
- return [_parse_project(item) for item in (await self._request("GET", "/v1/projects"))["data"]]
1223
+ response = await self._request("GET", "/v1/projects")
1224
+ return [_parse_project(item) for item in response["data"]]
1268
1225
 
1269
- async def create_project(self, name: str, *, rate_limit_default: Optional[int] = None) -> Project:
1226
+ async def create_project(
1227
+ self, name: str, *, rate_limit_default: Optional[int] = None
1228
+ ) -> Project:
1270
1229
  body: dict[str, Any] = {"name": name}
1271
1230
  if rate_limit_default is not None:
1272
1231
  body["rate_limit_default"] = rate_limit_default
@@ -1276,14 +1235,19 @@ class AsyncHookBridge:
1276
1235
  return _parse_project((await self._request("GET", f"/v1/projects/{project_id}"))["data"])
1277
1236
 
1278
1237
  async def update_project(
1279
- self, project_id: str, *, name: Optional[str] = None, rate_limit_default: Optional[int] = None
1238
+ self,
1239
+ project_id: str,
1240
+ *,
1241
+ name: Optional[str] = None,
1242
+ rate_limit_default: Optional[int] = None,
1280
1243
  ) -> Project:
1281
1244
  body: dict[str, Any] = {}
1282
1245
  if name is not None:
1283
1246
  body["name"] = name
1284
1247
  if rate_limit_default is not None:
1285
1248
  body["rate_limit_default"] = rate_limit_default
1286
- return _parse_project((await self._request("PUT", f"/v1/projects/{project_id}", json=body))["data"])
1249
+ response = await self._request("PUT", f"/v1/projects/{project_id}", json=body)
1250
+ return _parse_project(response["data"])
1287
1251
 
1288
1252
  async def delete_project(self, project_id: str) -> None:
1289
1253
  await self._request("DELETE", f"/v1/projects/{project_id}")
@@ -1360,6 +1324,18 @@ class AsyncHookBridge:
1360
1324
  async def delete_endpoint(self, endpoint_id: str) -> None:
1361
1325
  await self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
1362
1326
 
1327
+ async def pause_endpoint(self, endpoint_id: str) -> PauseState:
1328
+ data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/pause"))["data"]
1329
+ return PauseState(id=data["id"], paused=data["paused"])
1330
+
1331
+ async def resume_endpoint(self, endpoint_id: str) -> PauseState:
1332
+ data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/resume"))["data"]
1333
+ return PauseState(
1334
+ id=data["id"],
1335
+ paused=data["paused"],
1336
+ messages_requeued=data.get("messages_requeued"),
1337
+ )
1338
+
1363
1339
  async def create_endpoint_signing_key(self, endpoint_id: str) -> RotateSecretResponse:
1364
1340
  data = (await self._request("POST", f"/v1/endpoints/{endpoint_id}/signing-keys"))["data"]
1365
1341
  return _parse_rotate_secret(data)
@@ -1374,55 +1350,6 @@ class AsyncHookBridge:
1374
1350
  async def rotate_endpoint_secret(self, endpoint_id: str) -> RotateSecretResponse:
1375
1351
  return await self.create_endpoint_signing_key(endpoint_id)
1376
1352
 
1377
- async def get_personalization(self) -> Personalization:
1378
- return _parse_personalization(
1379
- (await self._request("GET", "/v1/settings/personalization"))["data"]
1380
- )
1381
-
1382
- async def update_personalization(
1383
- self,
1384
- *,
1385
- timezone: Optional[str] = None,
1386
- date_format: Optional[str] = None,
1387
- locale: Optional[str] = None,
1388
- ) -> Personalization:
1389
- body: dict[str, Any] = {}
1390
- if timezone is not None:
1391
- body["timezone"] = timezone
1392
- if date_format is not None:
1393
- body["date_format"] = date_format
1394
- if locale is not None:
1395
- body["locale"] = locale
1396
- return _parse_personalization(
1397
- (await self._request("PUT", "/v1/settings/personalization", json=body))["data"]
1398
- )
1399
-
1400
- async def get_notification_preferences(self) -> NotificationPreferences:
1401
- return _parse_notification_preferences(
1402
- (await self._request("GET", "/v1/settings/notifications"))["data"]
1403
- )
1404
-
1405
- async def update_notification_preferences(
1406
- self,
1407
- *,
1408
- enabled_alert_types: Optional[list[str]] = None,
1409
- delivery_channels: Optional[list[str]] = None,
1410
- failure_rate_threshold: Optional[int] = None,
1411
- pending_backlog_threshold: Optional[int] = None,
1412
- ) -> NotificationPreferences:
1413
- body: dict[str, Any] = {}
1414
- if enabled_alert_types is not None:
1415
- body["enabled_alert_types"] = enabled_alert_types
1416
- if delivery_channels is not None:
1417
- body["delivery_channels"] = delivery_channels
1418
- if failure_rate_threshold is not None:
1419
- body["failure_rate_threshold"] = failure_rate_threshold
1420
- if pending_backlog_threshold is not None:
1421
- body["pending_backlog_threshold"] = pending_backlog_threshold
1422
- return _parse_notification_preferences(
1423
- (await self._request("PUT", "/v1/settings/notifications", json=body))["data"]
1424
- )
1425
-
1426
1353
  async def create_checkout(self, *, plan: str, interval: str) -> CheckoutSession:
1427
1354
  data = (
1428
1355
  await self._request(
@@ -1674,7 +1601,10 @@ class AsyncHookBridge:
1674
1601
  status: Optional[MessageStatus] = None,
1675
1602
  endpoint_id: Optional[str] = None,
1676
1603
  ) -> ExportRecord:
1677
- body: dict[str, Any] = {"start_time": start_time, "end_time": end_time}
1604
+ body: dict[str, Any] = {
1605
+ "start_time": _json_value(start_time),
1606
+ "end_time": _json_value(end_time),
1607
+ }
1678
1608
  if status is not None:
1679
1609
  body["status"] = status
1680
1610
  if endpoint_id is not None:
@@ -1682,10 +1612,12 @@ class AsyncHookBridge:
1682
1612
  return _parse_export_record((await self._request("POST", "/v1/exports", json=body))["data"])
1683
1613
 
1684
1614
  async def list_exports(self) -> list[ExportRecord]:
1685
- return [_parse_export_record(item) for item in (await self._request("GET", "/v1/exports"))["data"]]
1615
+ response = await self._request("GET", "/v1/exports")
1616
+ return [_parse_export_record(item) for item in response["data"]]
1686
1617
 
1687
1618
  async def get_export(self, export_id: str) -> ExportRecord:
1688
- return _parse_export_record((await self._request("GET", f"/v1/exports/{export_id}"))["data"])
1619
+ response = await self._request("GET", f"/v1/exports/{export_id}")
1620
+ return _parse_export_record(response["data"])
1689
1621
 
1690
1622
  async def download_export(self, export_id: str) -> str:
1691
1623
  response = await self._request(
@@ -126,6 +126,7 @@ class Endpoint:
126
126
  url: str
127
127
  created_at: datetime
128
128
  updated_at: datetime
129
+ paused: bool
129
130
  description: Optional[str] = None
130
131
  rate_limit_rps: Optional[int] = None
131
132
  burst: Optional[int] = None
@@ -137,6 +138,7 @@ class EndpointSummary:
137
138
  id: str
138
139
  url: str
139
140
  created_at: datetime
141
+ paused: bool
140
142
  description: Optional[str] = None
141
143
 
142
144
 
@@ -243,29 +245,6 @@ class Project:
243
245
  created_at: datetime
244
246
 
245
247
 
246
- @dataclass(frozen=True)
247
- class Personalization:
248
- id: str
249
- user_id: str
250
- timezone: str
251
- date_format: str
252
- locale: str
253
- created_at: datetime
254
- updated_at: datetime
255
-
256
-
257
- @dataclass(frozen=True)
258
- class NotificationPreferences:
259
- id: str
260
- user_id: str
261
- enabled_alert_types: list[str]
262
- delivery_channels: list[str]
263
- failure_rate_threshold: int
264
- pending_backlog_threshold: int
265
- created_at: datetime
266
- updated_at: datetime
267
-
268
-
269
248
  @dataclass(frozen=True)
270
249
  class CheckoutSession:
271
250
  session_id: str
@@ -384,6 +363,7 @@ class DeleteResult:
384
363
  class PauseState:
385
364
  id: str
386
365
  paused: bool
366
+ messages_requeued: Optional[int] = None
387
367
 
388
368
 
389
369
  @dataclass(frozen=True)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hookbridge"
7
- version = "1.2.0"
7
+ version = "1.4.0"
8
8
  description = "Official HookBridge SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -186,6 +186,7 @@ MOCK_RESPONSES = {
186
186
  "url": "https://example.com/webhook",
187
187
  "created_at": "2025-01-01T00:00:00Z",
188
188
  "updated_at": "2025-01-01T00:00:00Z",
189
+ "paused": False,
189
190
  "description": "Test endpoint",
190
191
  "rate_limit_rps": 10,
191
192
  "burst": 20,
@@ -198,6 +199,7 @@ MOCK_RESPONSES = {
198
199
  "id": "ep_550e8400e29b41d4a716446655440000",
199
200
  "url": "https://example.com/webhook",
200
201
  "created_at": "2025-01-01T00:00:00Z",
202
+ "paused": False,
201
203
  "description": "Test endpoint",
202
204
  }
203
205
  ],
@@ -2,6 +2,9 @@
2
2
  Tests for the asynchronous HookBridge client.
3
3
  """
4
4
 
5
+ import json
6
+ from datetime import UTC, datetime
7
+
5
8
  import pytest
6
9
  from pytest_httpx import HTTPXMock
7
10
 
@@ -303,8 +306,45 @@ class TestAsyncEndpoints:
303
306
  result = await async_client.get_endpoint(endpoint_id)
304
307
 
305
308
  assert result.id == endpoint_id
309
+ assert result.paused is False
306
310
  assert result.rate_limit_rps == 10
307
311
 
312
+
313
+ class TestAsyncExports:
314
+ """Tests for async export operations."""
315
+
316
+ @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
317
+ async def test_create_export_serializes_datetimes(
318
+ self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
319
+ ) -> None:
320
+ httpx_mock.add_response(
321
+ method="POST",
322
+ url=f"{async_client._base_url}/v1/exports",
323
+ json={
324
+ "data": {
325
+ "id": "01935abc-def0-7123-4567-890abcdef077",
326
+ "project_id": "proj_abc123",
327
+ "status": "pending",
328
+ "filter_start_time": "2025-12-01T00:00:00+00:00",
329
+ "filter_end_time": "2025-12-06T23:59:59+00:00",
330
+ "created_at": "2025-12-06T12:00:00Z",
331
+ },
332
+ "meta": {"request_id": "req-12345"},
333
+ },
334
+ )
335
+
336
+ start = datetime(2025, 12, 1, 0, 0, 0, tzinfo=UTC)
337
+ end = datetime(2025, 12, 6, 23, 59, 59, tzinfo=UTC)
338
+ await async_client.create_export(start_time=start, end_time=end, endpoint_id="ep_123")
339
+
340
+ request = httpx_mock.get_request()
341
+ assert request is not None
342
+ assert json.loads(request.content) == {
343
+ "start_time": "2025-12-01T00:00:00+00:00",
344
+ "end_time": "2025-12-06T23:59:59+00:00",
345
+ "endpoint_id": "ep_123",
346
+ }
347
+
308
348
  @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
309
349
  async def test_list_endpoints(
310
350
  self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
@@ -319,8 +359,36 @@ class TestAsyncEndpoints:
319
359
  result = await async_client.list_endpoints()
320
360
 
321
361
  assert len(result.endpoints) == 1
362
+ assert result.endpoints[0].paused is False
322
363
  assert result.has_more is False
323
364
 
365
+ @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
366
+ async def test_pause_and_resume_endpoint(
367
+ self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
368
+ ) -> None:
369
+ """Pause and resume an outbound endpoint."""
370
+ endpoint_id = "ep_550e8400e29b41d4a716446655440000"
371
+ httpx_mock.add_response(
372
+ method="POST",
373
+ url=f"{async_client._base_url}/v1/endpoints/{endpoint_id}/pause",
374
+ json={"data": {"id": endpoint_id, "paused": True}, "meta": {"request_id": "req-12345"}},
375
+ )
376
+ httpx_mock.add_response(
377
+ method="POST",
378
+ url=f"{async_client._base_url}/v1/endpoints/{endpoint_id}/resume",
379
+ json={
380
+ "data": {"id": endpoint_id, "paused": False, "messages_requeued": 2},
381
+ "meta": {"request_id": "req-12345"},
382
+ },
383
+ )
384
+
385
+ paused = await async_client.pause_endpoint(endpoint_id)
386
+ resumed = await async_client.resume_endpoint(endpoint_id)
387
+
388
+ assert paused.paused is True
389
+ assert resumed.paused is False
390
+ assert resumed.messages_requeued == 2
391
+
324
392
  @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
325
393
  async def test_rotate_endpoint_secret(
326
394
  self, async_client: AsyncHookBridge, httpx_mock: HTTPXMock
@@ -398,6 +398,7 @@ class TestEndpoints:
398
398
 
399
399
  assert result.id == endpoint_id
400
400
  assert result.url == "https://example.com/webhook"
401
+ assert result.paused is False
401
402
  assert result.rate_limit_rps == 10
402
403
 
403
404
  @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
@@ -413,8 +414,34 @@ class TestEndpoints:
413
414
 
414
415
  assert len(result.endpoints) == 1
415
416
  assert result.endpoints[0].id == "ep_550e8400e29b41d4a716446655440000"
417
+ assert result.endpoints[0].paused is False
416
418
  assert result.has_more is False
417
419
 
420
+ @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
421
+ def test_pause_and_resume_endpoint(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
422
+ """Pause and resume an outbound endpoint."""
423
+ endpoint_id = "ep_550e8400e29b41d4a716446655440000"
424
+ httpx_mock.add_response(
425
+ method="POST",
426
+ url=f"{client._base_url}/v1/endpoints/{endpoint_id}/pause",
427
+ json={"data": {"id": endpoint_id, "paused": True}, "meta": {"request_id": "req-12345"}},
428
+ )
429
+ httpx_mock.add_response(
430
+ method="POST",
431
+ url=f"{client._base_url}/v1/endpoints/{endpoint_id}/resume",
432
+ json={
433
+ "data": {"id": endpoint_id, "paused": False, "messages_requeued": 2},
434
+ "meta": {"request_id": "req-12345"},
435
+ },
436
+ )
437
+
438
+ paused = client.pause_endpoint(endpoint_id)
439
+ resumed = client.resume_endpoint(endpoint_id)
440
+
441
+ assert paused.paused is True
442
+ assert resumed.paused is False
443
+ assert resumed.messages_requeued == 2
444
+
418
445
  @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
419
446
  def test_update_endpoint(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
420
447
  """Update an endpoint."""
@@ -3,6 +3,7 @@ Spec parity tests for the synchronous HookBridge client.
3
3
  """
4
4
 
5
5
  import json
6
+ from datetime import UTC, datetime
6
7
 
7
8
  import pytest
8
9
  from pytest_httpx import HTTPXMock
@@ -11,7 +12,6 @@ from hookbridge import HookBridge
11
12
 
12
13
  from .conftest import LIVE_MODE
13
14
 
14
-
15
15
  PROJECT_ID = "01935abc-def0-7123-4567-890abcdef012"
16
16
  INBOUND_ENDPOINT_ID = "01935abc-def0-7123-4567-890abcdef099"
17
17
  MESSAGE_ID = "01935abc-def0-7123-4567-890abcdef013"
@@ -236,69 +236,68 @@ class TestSigningKeyParity:
236
236
  client.delete_endpoint_signing_key(ENDPOINT_ID, SIGNING_KEY_ID)
237
237
 
238
238
 
239
- class TestSettingsParity:
239
+ class TestEndpointPauseParity:
240
240
  @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
241
- def test_personalization_settings(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
242
- payload = {
243
- "data": {
244
- "id": "550e8400-e29b-41d4-a716-446655440000",
245
- "user_id": "user_abc123",
246
- "timezone": "America/New_York",
247
- "date_format": "MM/DD/YYYY",
248
- "locale": "en-US",
249
- "created_at": "2025-12-01T10:00:00Z",
250
- "updated_at": "2025-12-06T12:00:00Z",
251
- },
252
- "meta": {"request_id": "req-12345"},
253
- }
241
+ def test_get_list_pause_resume_endpoint(
242
+ self, client: HookBridge, httpx_mock: HTTPXMock
243
+ ) -> None:
254
244
  httpx_mock.add_response(
255
245
  method="GET",
256
- url=f"{client._base_url}/v1/settings/personalization",
257
- json=payload,
246
+ url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}",
247
+ json={
248
+ "data": {
249
+ "id": ENDPOINT_ID,
250
+ "url": "https://customer.app/webhooks",
251
+ "description": "Main production webhook",
252
+ "paused": False,
253
+ "rate_limit_rps": 10,
254
+ "burst": 20,
255
+ "created_at": "2025-12-01T10:00:00Z",
256
+ "updated_at": "2025-12-06T12:00:00Z",
257
+ },
258
+ "meta": {"request_id": "req-12345"},
259
+ },
258
260
  )
259
261
  httpx_mock.add_response(
260
- method="PUT",
261
- url=f"{client._base_url}/v1/settings/personalization",
262
- json=payload,
263
- )
264
-
265
- current = client.get_personalization()
266
- updated = client.update_personalization(timezone="America/New_York")
267
-
268
- assert current.locale == "en-US"
269
- assert updated.timezone == "America/New_York"
270
-
271
- @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
272
- def test_notification_settings(self, client: HookBridge, httpx_mock: HTTPXMock) -> None:
273
- payload = {
274
- "data": {
275
- "id": "550e8400-e29b-41d4-a716-446655440001",
276
- "user_id": "user_abc123",
277
- "enabled_alert_types": ["high_failure_rate"],
278
- "delivery_channels": ["email"],
279
- "failure_rate_threshold": 10,
280
- "pending_backlog_threshold": 100,
281
- "created_at": "2025-12-01T10:00:00Z",
282
- "updated_at": "2025-12-06T12:00:00Z",
262
+ method="GET",
263
+ url=f"{client._base_url}/v1/endpoints",
264
+ json={
265
+ "data": [
266
+ {
267
+ "id": ENDPOINT_ID,
268
+ "url": "https://customer.app/webhooks",
269
+ "description": "Main production webhook",
270
+ "paused": False,
271
+ "created_at": "2025-12-01T10:00:00Z",
272
+ }
273
+ ],
274
+ "meta": {"request_id": "req-12345", "next_cursor": None},
283
275
  },
284
- "meta": {"request_id": "req-12345"},
285
- }
276
+ )
286
277
  httpx_mock.add_response(
287
- method="GET",
288
- url=f"{client._base_url}/v1/settings/notifications",
289
- json=payload,
278
+ method="POST",
279
+ url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}/pause",
280
+ json={"data": {"id": ENDPOINT_ID, "paused": True}, "meta": {"request_id": "req-12345"}},
290
281
  )
291
282
  httpx_mock.add_response(
292
- method="PUT",
293
- url=f"{client._base_url}/v1/settings/notifications",
294
- json=payload,
283
+ method="POST",
284
+ url=f"{client._base_url}/v1/endpoints/{ENDPOINT_ID}/resume",
285
+ json={
286
+ "data": {"id": ENDPOINT_ID, "paused": False, "messages_requeued": 4},
287
+ "meta": {"request_id": "req-12345"},
288
+ },
295
289
  )
296
290
 
297
- current = client.get_notification_preferences()
298
- updated = client.update_notification_preferences(delivery_channels=["email"])
291
+ fetched = client.get_endpoint(ENDPOINT_ID)
292
+ listed = client.list_endpoints()
293
+ paused = client.pause_endpoint(ENDPOINT_ID)
294
+ resumed = client.resume_endpoint(ENDPOINT_ID)
299
295
 
300
- assert current.enabled_alert_types == ["high_failure_rate"]
301
- assert updated.pending_backlog_threshold == 100
296
+ assert fetched.paused is False
297
+ assert listed.endpoints[0].paused is False
298
+ assert paused.paused is True
299
+ assert resumed.paused is False
300
+ assert resumed.messages_requeued == 4
302
301
 
303
302
 
304
303
  class TestBillingParity:
@@ -700,3 +699,35 @@ class TestExportParity:
700
699
  assert listed[0].row_count == 5000
701
700
  assert fetched.status == "completed"
702
701
  assert download_url == "https://downloads.example.com/export.csv"
702
+
703
+ @pytest.mark.skipif(LIVE_MODE, reason="Mock test only")
704
+ def test_create_export_serializes_datetimes(
705
+ self, client: HookBridge, httpx_mock: HTTPXMock
706
+ ) -> None:
707
+ httpx_mock.add_response(
708
+ method="POST",
709
+ url=f"{client._base_url}/v1/exports",
710
+ json={
711
+ "data": {
712
+ "id": EXPORT_ID,
713
+ "project_id": "proj_abc123",
714
+ "status": "pending",
715
+ "filter_start_time": "2025-12-01T00:00:00+00:00",
716
+ "filter_end_time": "2025-12-06T23:59:59+00:00",
717
+ "created_at": "2025-12-06T12:00:00Z",
718
+ },
719
+ "meta": {"request_id": "req-12345"},
720
+ },
721
+ )
722
+
723
+ start = datetime(2025, 12, 1, 0, 0, 0, tzinfo=UTC)
724
+ end = datetime(2025, 12, 6, 23, 59, 59, tzinfo=UTC)
725
+ client.create_export(start_time=start, end_time=end, endpoint_id=ENDPOINT_ID)
726
+
727
+ request = httpx_mock.get_request()
728
+ assert request is not None
729
+ assert json.loads(request.content) == {
730
+ "start_time": "2025-12-01T00:00:00+00:00",
731
+ "end_time": "2025-12-06T23:59:59+00:00",
732
+ "endpoint_id": ENDPOINT_ID,
733
+ }
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(mypy hookbridge:*)"
5
- ]
6
- }
7
- }
File without changes
File without changes
File without changes