webhook-platform 2.2.1__tar.gz → 2.6.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 (24) hide show
  1. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/PKG-INFO +106 -14
  2. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/README.md +105 -13
  3. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/__init__.py +1 -1
  4. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/client.py +18 -5
  5. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/errors.py +7 -2
  6. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/types.py +89 -12
  7. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/hookflow/webhooks.py +34 -6
  8. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/pyproject.toml +1 -1
  9. webhook_platform-2.6.0/scripts/live_api_smoke.py +493 -0
  10. webhook_platform-2.6.0/tests/contract/__init__.py +0 -0
  11. webhook_platform-2.6.0/tests/contract/conftest.py +18 -0
  12. webhook_platform-2.6.0/tests/contract/support.py +84 -0
  13. webhook_platform-2.6.0/tests/contract/test_client_contract.py +99 -0
  14. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_client.py +21 -6
  15. webhook_platform-2.6.0/tests/test_package_identity.py +21 -0
  16. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_webhooks.py +43 -0
  17. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/PKG-INFO +106 -14
  18. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/SOURCES.txt +6 -0
  19. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/top_level.txt +1 -0
  20. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/setup.cfg +0 -0
  21. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/__init__.py +0 -0
  22. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/tests/test_incoming.py +0 -0
  23. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/dependency_links.txt +0 -0
  24. {webhook_platform-2.2.1 → webhook_platform-2.6.0}/webhook_platform.egg-info/requires.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhook-platform
3
- Version: 2.2.1
3
+ Version: 2.6.0
4
4
  Summary: Official Python SDK for Hookflow — reliable webhook infrastructure
5
5
  Author-email: Vadym Kykalo <vadymkykalo@gmail.com>
6
6
  License: MIT
@@ -31,6 +31,17 @@ Requires-Dist: types-requests>=2.28.0; extra == "dev"
31
31
 
32
32
  Official Python SDK for [Hookflow](https://github.com/vadymkykalo/webhook-platform).
33
33
 
34
+ > The PyPI distribution is `webhook-platform`; the module you import is
35
+ > `hookflow`. `pip install webhook-platform`, then `from hookflow import ...`.
36
+
37
+ **Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
38
+ Incoming Sources, Incoming Events, and webhook signature verification —
39
+ 7 of the platform's 35 API controllers. It does not wrap
40
+ Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
41
+ Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
42
+ use the [Generic Requests](#generic-requests) helpers for those until the
43
+ SDK grows to cover them.
44
+
34
45
  ## Installation
35
46
 
36
47
  ```bash
@@ -40,10 +51,12 @@ pip install webhook-platform
40
51
  ## Quick Start
41
52
 
42
53
  ```python
54
+ import os
55
+
43
56
  from hookflow import Hookflow, Event
44
57
 
45
58
  client = Hookflow(
46
- api_key="wh_live_your_api_key",
59
+ api_key=os.environ["HOOKFLOW_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
47
60
  base_url="http://localhost:8080", # optional
48
61
  )
49
62
 
@@ -92,8 +105,11 @@ endpoint = client.endpoints.create(
92
105
  ),
93
106
  )
94
107
 
95
- # List endpoints
96
- endpoints = client.endpoints.list(project_id)
108
+ # List endpoints — the API paginates this one, so the endpoints are in .content
109
+ # (iterating the page yields them directly)
110
+ page = client.endpoints.list(project_id, page=0, size=20)
111
+ for endpoint in page:
112
+ print(endpoint.url)
97
113
 
98
114
  # Update endpoint
99
115
  client.endpoints.update(
@@ -112,6 +128,7 @@ print(f"New secret: {updated.secret}")
112
128
  # Test endpoint connectivity
113
129
  result = client.endpoints.test(project_id, endpoint_id)
114
130
  print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
131
+ print(f"{result.http_status_code} — {result.message}")
115
132
  ```
116
133
 
117
134
  ### Subscriptions
@@ -129,7 +146,7 @@ subscription = client.subscriptions.create(
129
146
  ),
130
147
  )
131
148
 
132
- # List subscriptions
149
+ # List subscriptions — a bare list; unlike endpoints, this one is not paginated
133
150
  subscriptions = client.subscriptions.list(project_id)
134
151
 
135
152
  # Update subscription
@@ -159,7 +176,7 @@ print(f"Total failed: {deliveries.total_elements}")
159
176
  # Get delivery attempts
160
177
  attempts = client.deliveries.get_attempts(delivery_id)
161
178
  for attempt in attempts:
162
- print(f"Attempt {attempt.attempt_number}: {attempt.http_status} ({attempt.latency_ms}ms)")
179
+ print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
163
180
 
164
181
  # Replay failed delivery
165
182
  client.deliveries.replay(delivery_id)
@@ -274,11 +291,12 @@ def handle_webhook():
274
291
  # Option 2: Verify and parse
275
292
  event = construct_event(payload, headers, secret)
276
293
 
277
- print(f"Received {event.type}: {event.data}")
278
-
279
- # Handle the event
280
- if event.type == "order.completed":
281
- handle_order_completed(event.data)
294
+ # event.data is the parsed body; event.event_id / event.delivery_id /
295
+ # event.timestamp come from the X-Event-Id / X-Delivery-Id /
296
+ # X-Timestamp headers. See "What lands on your endpoint" below for
297
+ # event.type.
298
+ print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
299
+ handle_order_completed(event.data)
282
300
 
283
301
  return "OK", 200
284
302
 
@@ -287,6 +305,40 @@ def handle_webhook():
287
305
  return "Invalid signature", 400
288
306
  ```
289
307
 
308
+ ### What lands on your endpoint
309
+
310
+ Hookflow PUTs the event's **payload** on the wire, not an envelope. This:
311
+
312
+ ```python
313
+ client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
314
+ ```
315
+
316
+ arrives at your endpoint as the ``data`` object alone —
317
+
318
+ ```http
319
+ POST /webhooks HTTP/1.1
320
+ Content-Type: application/json
321
+ X-Signature: t=1738000000000,v1=<hex hmac-sha256>
322
+ X-Timestamp: 1738000000000
323
+ X-Event-Id: 6f0e…
324
+ X-Delivery-Id: 91ab…
325
+ X-Sequence-Number: 0
326
+ Idempotency-Key: 6f0e…-<endpoint-id>
327
+
328
+ {"order_id":"ord_1"}
329
+ ```
330
+
331
+ So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
332
+ headers and `data` from the body, but **`type` is empty**: the event type is
333
+ not on the wire for a default subscription. Route on the payload, on the
334
+ endpoint you registered, or set the subscription's `payload_template` to wrap
335
+ the event so `type` becomes part of the body.
336
+
337
+ The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
338
+ the endpoint secret, and the server rejects timestamps more than **300
339
+ seconds** old — verify against the *raw* body bytes, before any JSON parse and
340
+ re-serialize.
341
+
290
342
  ### FastAPI Example
291
343
 
292
344
  ```python
@@ -327,7 +379,8 @@ from hookflow import (
327
379
  try:
328
380
  client.events.send(Event(type="test", data={}))
329
381
  except RateLimitError as e:
330
- # Wait and retry
382
+ # retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
383
+ # X-RateLimit-Reset header, which the API sends in Unix *seconds*.
331
384
  print(f"Rate limited. Retry after {e.retry_after_ms}ms")
332
385
  time.sleep(e.retry_after_ms / 1000)
333
386
  except AuthenticationError:
@@ -400,12 +453,35 @@ All generic methods use the same authentication, error handling, and rate-limit
400
453
 
401
454
  ```python
402
455
  client = Hookflow(
403
- api_key="wh_live_xxx", # Required: Your API key
404
- base_url="https://api.example.com", # Optional: API base URL
456
+ api_key=os.environ["HOOKFLOW_API_KEY"], # Required: Your project API key
457
+ base_url="https://api.example.com", # Optional (default: http://localhost:8080)
405
458
  timeout=30, # Optional: Request timeout in seconds (default: 30)
406
459
  )
407
460
  ```
408
461
 
462
+ ### Timeouts and retries
463
+
464
+ `timeout` is passed straight to `requests`; hitting it raises `HookflowError`
465
+ with `code="timeout"` and `status=0`. A connection-level failure raises the
466
+ same class with `code="network_error"`.
467
+
468
+ **The client does not retry.** One SDK call is exactly one HTTP request — no
469
+ backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
470
+ That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
471
+ policy belongs to the caller who knows whether reissuing the request is safe.
472
+ What *is* retried is the delivery itself, by the platform, on the
473
+ subscription's `retry_delays` ladder.
474
+
475
+ ## Authentication
476
+
477
+ Every request the client makes carries `X-API-Key: <your key>` — the project
478
+ API key, created in the dashboard or via
479
+ `POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
480
+ token and has no login surface: JWT-authenticated endpoints (auth, projects,
481
+ organizations, members, API keys) are not part of it. Bootstrapping a project
482
+ and a key is a one-time step you do with the dashboard, the CLI, or plain
483
+ HTTP.
484
+
409
485
  ## Type Hints
410
486
 
411
487
  This SDK includes full type hints for better IDE support:
@@ -435,6 +511,22 @@ pytest
435
511
  docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
436
512
  ```
437
513
 
514
+ ### Live-API smoke check
515
+
516
+ `pytest` stubs the transport, so it cannot see a renamed field. To drive the
517
+ SDK against a real instance:
518
+
519
+ ```bash
520
+ make up # from the repo root
521
+ python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
522
+ ```
523
+
524
+ It registers a throwaway org, walks endpoint → subscription → event →
525
+ deliveries → attempts → incoming, checks each error envelope, and verifies a
526
+ signature the running server itself produced. It is not collected by `pytest`
527
+ (`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
528
+ passes with no backend.
529
+
438
530
  ## License
439
531
 
440
532
  MIT
@@ -2,6 +2,17 @@
2
2
 
3
3
  Official Python SDK for [Hookflow](https://github.com/vadymkykalo/webhook-platform).
4
4
 
5
+ > The PyPI distribution is `webhook-platform`; the module you import is
6
+ > `hookflow`. `pip install webhook-platform`, then `from hookflow import ...`.
7
+
8
+ **Scope.** This SDK covers Events, Endpoints, Subscriptions, Deliveries,
9
+ Incoming Sources, Incoming Events, and webhook signature verification —
10
+ 7 of the platform's 35 API controllers. It does not wrap
11
+ Transformations, Rules, Workflows, Schemas, DLQ, Analytics, Usage, Alerts,
12
+ Incidents, PII rules, Audit Log, Tunnels, API keys, Members, or Projects —
13
+ use the [Generic Requests](#generic-requests) helpers for those until the
14
+ SDK grows to cover them.
15
+
5
16
  ## Installation
6
17
 
7
18
  ```bash
@@ -11,10 +22,12 @@ pip install webhook-platform
11
22
  ## Quick Start
12
23
 
13
24
  ```python
25
+ import os
26
+
14
27
  from hookflow import Hookflow, Event
15
28
 
16
29
  client = Hookflow(
17
- api_key="wh_live_your_api_key",
30
+ api_key=os.environ["HOOKFLOW_API_KEY"], # e.g. "Kz1uAIM8VeJUQN7yGSYCst64WxNLabBHfOYbrPlJ1yk"
18
31
  base_url="http://localhost:8080", # optional
19
32
  )
20
33
 
@@ -63,8 +76,11 @@ endpoint = client.endpoints.create(
63
76
  ),
64
77
  )
65
78
 
66
- # List endpoints
67
- endpoints = client.endpoints.list(project_id)
79
+ # List endpoints — the API paginates this one, so the endpoints are in .content
80
+ # (iterating the page yields them directly)
81
+ page = client.endpoints.list(project_id, page=0, size=20)
82
+ for endpoint in page:
83
+ print(endpoint.url)
68
84
 
69
85
  # Update endpoint
70
86
  client.endpoints.update(
@@ -83,6 +99,7 @@ print(f"New secret: {updated.secret}")
83
99
  # Test endpoint connectivity
84
100
  result = client.endpoints.test(project_id, endpoint_id)
85
101
  print(f"Test {'passed' if result.success else 'failed'}: {result.latency_ms}ms")
102
+ print(f"{result.http_status_code} — {result.message}")
86
103
  ```
87
104
 
88
105
  ### Subscriptions
@@ -100,7 +117,7 @@ subscription = client.subscriptions.create(
100
117
  ),
101
118
  )
102
119
 
103
- # List subscriptions
120
+ # List subscriptions — a bare list; unlike endpoints, this one is not paginated
104
121
  subscriptions = client.subscriptions.list(project_id)
105
122
 
106
123
  # Update subscription
@@ -130,7 +147,7 @@ print(f"Total failed: {deliveries.total_elements}")
130
147
  # Get delivery attempts
131
148
  attempts = client.deliveries.get_attempts(delivery_id)
132
149
  for attempt in attempts:
133
- print(f"Attempt {attempt.attempt_number}: {attempt.http_status} ({attempt.latency_ms}ms)")
150
+ print(f"Attempt {attempt.attempt_number}: {attempt.http_status_code} ({attempt.duration_ms}ms)")
134
151
 
135
152
  # Replay failed delivery
136
153
  client.deliveries.replay(delivery_id)
@@ -245,11 +262,12 @@ def handle_webhook():
245
262
  # Option 2: Verify and parse
246
263
  event = construct_event(payload, headers, secret)
247
264
 
248
- print(f"Received {event.type}: {event.data}")
249
-
250
- # Handle the event
251
- if event.type == "order.completed":
252
- handle_order_completed(event.data)
265
+ # event.data is the parsed body; event.event_id / event.delivery_id /
266
+ # event.timestamp come from the X-Event-Id / X-Delivery-Id /
267
+ # X-Timestamp headers. See "What lands on your endpoint" below for
268
+ # event.type.
269
+ print(f"Delivery {event.delivery_id} of event {event.event_id}: {event.data}")
270
+ handle_order_completed(event.data)
253
271
 
254
272
  return "OK", 200
255
273
 
@@ -258,6 +276,40 @@ def handle_webhook():
258
276
  return "Invalid signature", 400
259
277
  ```
260
278
 
279
+ ### What lands on your endpoint
280
+
281
+ Hookflow PUTs the event's **payload** on the wire, not an envelope. This:
282
+
283
+ ```python
284
+ client.events.send(Event(type="order.completed", data={"order_id": "ord_1"}))
285
+ ```
286
+
287
+ arrives at your endpoint as the ``data`` object alone —
288
+
289
+ ```http
290
+ POST /webhooks HTTP/1.1
291
+ Content-Type: application/json
292
+ X-Signature: t=1738000000000,v1=<hex hmac-sha256>
293
+ X-Timestamp: 1738000000000
294
+ X-Event-Id: 6f0e…
295
+ X-Delivery-Id: 91ab…
296
+ X-Sequence-Number: 0
297
+ Idempotency-Key: 6f0e…-<endpoint-id>
298
+
299
+ {"order_id":"ord_1"}
300
+ ```
301
+
302
+ So `construct_event` fills `event_id`, `delivery_id` and `timestamp` from the
303
+ headers and `data` from the body, but **`type` is empty**: the event type is
304
+ not on the wire for a default subscription. Route on the payload, on the
305
+ endpoint you registered, or set the subscription's `payload_template` to wrap
306
+ the event so `type` becomes part of the body.
307
+
308
+ The signature is computed over `f"{timestamp}.{raw_body}"` with HMAC-SHA256 and
309
+ the endpoint secret, and the server rejects timestamps more than **300
310
+ seconds** old — verify against the *raw* body bytes, before any JSON parse and
311
+ re-serialize.
312
+
261
313
  ### FastAPI Example
262
314
 
263
315
  ```python
@@ -298,7 +350,8 @@ from hookflow import (
298
350
  try:
299
351
  client.events.send(Event(type="test", data={}))
300
352
  except RateLimitError as e:
301
- # Wait and retry
353
+ # retry_after_ms is milliseconds. e.rate_limit_info.reset is the raw
354
+ # X-RateLimit-Reset header, which the API sends in Unix *seconds*.
302
355
  print(f"Rate limited. Retry after {e.retry_after_ms}ms")
303
356
  time.sleep(e.retry_after_ms / 1000)
304
357
  except AuthenticationError:
@@ -371,12 +424,35 @@ All generic methods use the same authentication, error handling, and rate-limit
371
424
 
372
425
  ```python
373
426
  client = Hookflow(
374
- api_key="wh_live_xxx", # Required: Your API key
375
- base_url="https://api.example.com", # Optional: API base URL
427
+ api_key=os.environ["HOOKFLOW_API_KEY"], # Required: Your project API key
428
+ base_url="https://api.example.com", # Optional (default: http://localhost:8080)
376
429
  timeout=30, # Optional: Request timeout in seconds (default: 30)
377
430
  )
378
431
  ```
379
432
 
433
+ ### Timeouts and retries
434
+
435
+ `timeout` is passed straight to `requests`; hitting it raises `HookflowError`
436
+ with `code="timeout"` and `status=0`. A connection-level failure raises the
437
+ same class with `code="network_error"`.
438
+
439
+ **The client does not retry.** One SDK call is exactly one HTTP request — no
440
+ backoff, no idempotent replay, and no `urllib3` `Retry` adapter is installed.
441
+ That is deliberate: `events.send` accepts an `idempotency_key`, so a retry
442
+ policy belongs to the caller who knows whether reissuing the request is safe.
443
+ What *is* retried is the delivery itself, by the platform, on the
444
+ subscription's `retry_delays` ladder.
445
+
446
+ ## Authentication
447
+
448
+ Every request the client makes carries `X-API-Key: <your key>` — the project
449
+ API key, created in the dashboard or via
450
+ `POST /api/v1/projects/{project_id}/api-keys`. The SDK never sends a bearer
451
+ token and has no login surface: JWT-authenticated endpoints (auth, projects,
452
+ organizations, members, API keys) are not part of it. Bootstrapping a project
453
+ and a key is a one-time step you do with the dashboard, the CLI, or plain
454
+ HTTP.
455
+
380
456
  ## Type Hints
381
457
 
382
458
  This SDK includes full type hints for better IDE support:
@@ -406,6 +482,22 @@ pytest
406
482
  docker run --rm -v $(pwd):/app -w /app python:3.11-slim sh -c "pip install -e '.[dev]' && pytest"
407
483
  ```
408
484
 
485
+ ### Live-API smoke check
486
+
487
+ `pytest` stubs the transport, so it cannot see a renamed field. To drive the
488
+ SDK against a real instance:
489
+
490
+ ```bash
491
+ make up # from the repo root
492
+ python scripts/live_api_smoke.py # SMOKE_API_BASE_URL overrides the target
493
+ ```
494
+
495
+ It registers a throwaway org, walks endpoint → subscription → event →
496
+ deliveries → attempts → incoming, checks each error envelope, and verifies a
497
+ signature the running server itself produced. It is not collected by `pytest`
498
+ (`testpaths = tests`, `python_files = test_*.py`), so the unit suite still
499
+ passes with no backend.
500
+
409
501
  ## License
410
502
 
411
503
  MIT
@@ -36,7 +36,7 @@ from .types import (
36
36
  ReplayEventResponse,
37
37
  )
38
38
 
39
- __version__ = "2.2.1"
39
+ __version__ = "2.6.0"
40
40
 
41
41
  # Backward-compatible aliases
42
42
  WebhookPlatform = Hookflow
@@ -37,7 +37,7 @@ from .errors import (
37
37
 
38
38
  DEFAULT_BASE_URL = "http://localhost:8080"
39
39
  DEFAULT_TIMEOUT = 30
40
- SDK_VERSION = "2.2.1"
40
+ SDK_VERSION = "2.6.0"
41
41
 
42
42
 
43
43
  class Hookflow:
@@ -165,8 +165,10 @@ class Hookflow:
165
165
  return NotFoundError(message)
166
166
  elif status == 429:
167
167
  import time
168
+ # `reset` is a Unix timestamp in seconds (see RateLimitInfo), so the
169
+ # fallback has to be in seconds too.
168
170
  info = rate_limit_info or RateLimitInfo(
169
- limit=0, remaining=0, reset=int(time.time() * 1000) + 60000
171
+ limit=0, remaining=0, reset=int(time.time()) + 60
170
172
  )
171
173
  return RateLimitError(message, info)
172
174
  elif status == 400:
@@ -217,13 +219,21 @@ class Endpoints:
217
219
  )
218
220
  return Endpoint.from_dict(data)
219
221
 
220
- def list(self, project_id: str) -> List[Endpoint]:
221
- """List all endpoints for a project."""
222
+ def list(
223
+ self, project_id: str, page: int = 0, size: int = 20
224
+ ) -> PaginatedResponse:
225
+ """List a project's endpoints.
226
+
227
+ The API returns a Spring page envelope here, not a bare array — the
228
+ endpoints themselves are in ``.content`` (iterating the page yields
229
+ them directly).
230
+ """
222
231
  data = self._client._request(
223
232
  "GET",
224
233
  f"/api/v1/projects/{project_id}/endpoints",
234
+ params={"page": page, "size": size},
225
235
  )
226
- return [Endpoint.from_dict(e) for e in data]
236
+ return PaginatedResponse.from_dict(data, Endpoint)
227
237
 
228
238
  def update(
229
239
  self, project_id: str, endpoint_id: str, params: EndpointUpdateParams
@@ -305,6 +315,7 @@ class Subscriptions:
305
315
  retry_delays: Optional[str] = None,
306
316
  payload_template: Optional[str] = None,
307
317
  custom_headers: Optional[str] = None,
318
+ transformation_id: Optional[str] = None,
308
319
  ) -> Subscription:
309
320
  """Update subscription."""
310
321
  body: Dict[str, Any] = {}
@@ -324,6 +335,8 @@ class Subscriptions:
324
335
  body["payloadTemplate"] = payload_template
325
336
  if custom_headers is not None:
326
337
  body["customHeaders"] = custom_headers
338
+ if transformation_id is not None:
339
+ body["transformationId"] = transformation_id
327
340
 
328
341
  data = self._client._request(
329
342
  "PUT",
@@ -35,10 +35,15 @@ class RateLimitError(HookflowError):
35
35
 
36
36
  @property
37
37
  def retry_after_ms(self) -> int:
38
- """Milliseconds to wait before retrying."""
38
+ """Milliseconds to wait before retrying.
39
+
40
+ ``rate_limit_info.reset`` is the raw ``X-RateLimit-Reset`` header, which
41
+ the API sends as a Unix timestamp in **seconds**; subtracting a
42
+ millisecond clock from it directly always yields 0.
43
+ """
39
44
  import time
40
45
  now_ms = int(time.time() * 1000)
41
- return max(0, self.rate_limit_info.reset - now_ms)
46
+ return max(0, self.rate_limit_info.reset * 1000 - now_ms)
42
47
 
43
48
 
44
49
  class ValidationError(HookflowError):