webhookd-sdk 0.1.0__tar.gz → 0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webhookd-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures.
5
5
  Project-URL: Homepage, https://github.com/NimbusNexus/Webhooks-sdks/tree/develop/python#readme
6
6
  Project-URL: Repository, https://github.com/NimbusNexus/Webhooks-sdks
@@ -28,7 +28,8 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  # webhookd-sdk (Python)
30
30
 
31
- Official Python SDK for **NimbusNexus Webhooks** — publish events, and verify the webhooks you receive.
31
+ Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
32
+ deliveries, and verify the webhooks you receive.
32
33
 
33
34
  ```sh
34
35
  pip install webhookd-sdk
@@ -74,6 +75,47 @@ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
74
75
  Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
75
76
  `Retry-After`); other `4xx` raise `WebhookdAPIError`.
76
77
 
78
+ ## Manage endpoints, keys & deliveries (operators)
79
+
80
+ The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
81
+ dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
82
+ `dict`s (snake_case, exactly as the API sends); list methods return a page —
83
+ `{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
84
+
85
+ ```python
86
+ from webhookd_sdk import Client
87
+
88
+ wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
89
+
90
+ # --- Endpoints ----------------------------------------------------------------
91
+ # Create a receiver — its signing secret is in the response exactly once, so persist it now.
92
+ ep = wh.create_endpoint(
93
+ "https://your-app.example/webhooks",
94
+ subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
95
+ description="orders service",
96
+ )
97
+ endpoint_id, signing_secret = ep["id"], ep["secret"]
98
+
99
+ wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
100
+ wh.get_endpoint(endpoint_id)
101
+
102
+ # PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
103
+ wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
104
+
105
+ wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
106
+ wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
107
+ wh.delete_endpoint(endpoint_id) # -> None (204)
108
+
109
+ # --- API keys -----------------------------------------------------------------
110
+ key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
111
+ print(key["key"]) # shown once
112
+ wh.revoke_api_key(key["id"]) # -> None (204)
113
+
114
+ # --- Deliveries / dead-letter recovery ----------------------------------------
115
+ for d in wh.list_deliveries(status="dead")["items"]:
116
+ wh.redeliver(d["id"])
117
+ ```
118
+
77
119
  ## Develop
78
120
 
79
121
  ```sh
@@ -0,0 +1,96 @@
1
+ # webhookd-sdk (Python)
2
+
3
+ Official Python SDK for **NimbusNexus Webhooks** — publish events, manage your endpoints / keys /
4
+ deliveries, and verify the webhooks you receive.
5
+
6
+ ```sh
7
+ pip install webhookd-sdk
8
+ ```
9
+
10
+ ## Verify an incoming webhook (subscribers)
11
+
12
+ When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
13
+ verify the signature** before trusting the payload — it proves the request really came from webhookd
14
+ and wasn't tampered with or replayed.
15
+
16
+ ```python
17
+ from webhookd_sdk import verify
18
+
19
+ # In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
20
+ ok = verify(
21
+ secret=ENDPOINT_SIGNING_SECRET,
22
+ raw_body=request.body,
23
+ signature=request.headers["X-Webhook-Signature"],
24
+ timestamp=request.headers["X-Webhook-Timestamp"],
25
+ )
26
+ if not ok:
27
+ return Response(status_code=400) # forged, tampered, or outside the 300s replay window
28
+ ```
29
+
30
+ ## Publish an event (producers)
31
+
32
+ ```python
33
+ from webhookd_sdk import Client, WebhookdAPIError
34
+
35
+ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
36
+ try:
37
+ event = wh.publish(
38
+ "order.created",
39
+ {"order_id": "ord_123", "total": 4200},
40
+ idempotency_key="order-123", # makes the publish safe to retry
41
+ )
42
+ print(event.event_uid, event.deliveries_created)
43
+ except WebhookdAPIError as e:
44
+ print(e.status_code, e.code, e.message) # the stable {error:{code,message}} envelope
45
+ ```
46
+
47
+ Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
48
+ `Retry-After`); other `4xx` raise `WebhookdAPIError`.
49
+
50
+ ## Manage endpoints, keys & deliveries (operators)
51
+
52
+ The same `Client` wraps the control-plane API — register receivers, mint keys, and drain the
53
+ dead-letter queue from code (needs an **admin**-scoped key). Management methods return the raw JSON as
54
+ `dict`s (snake_case, exactly as the API sends); list methods return a page —
55
+ `{"items": [...], "next_offset": int | None}`; delete/revoke return `None` (a `204`).
56
+
57
+ ```python
58
+ from webhookd_sdk import Client
59
+
60
+ wh = Client("https://webhooks.example.com", api_key="whsk_admin_…")
61
+
62
+ # --- Endpoints ----------------------------------------------------------------
63
+ # Create a receiver — its signing secret is in the response exactly once, so persist it now.
64
+ ep = wh.create_endpoint(
65
+ "https://your-app.example/webhooks",
66
+ subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
67
+ description="orders service",
68
+ )
69
+ endpoint_id, signing_secret = ep["id"], ep["secret"]
70
+
71
+ wh.list_endpoints(environment="prod") # {"items": [...], "next_offset": ...}
72
+ wh.get_endpoint(endpoint_id)
73
+
74
+ # PATCH — send only the keys you want to change (omitted = unchanged, None = cleared):
75
+ wh.update_endpoint(endpoint_id, {"max_attempts": 10, "status": "disabled"})
76
+
77
+ wh.rotate_endpoint_secret(endpoint_id) # returns the new secret, once
78
+ wh.enable_endpoint(endpoint_id) # recover an auto-disabled endpoint
79
+ wh.delete_endpoint(endpoint_id) # -> None (204)
80
+
81
+ # --- API keys -----------------------------------------------------------------
82
+ key = wh.create_api_key("ci-publisher", scope="publish", expires_in_days=90)
83
+ print(key["key"]) # shown once
84
+ wh.revoke_api_key(key["id"]) # -> None (204)
85
+
86
+ # --- Deliveries / dead-letter recovery ----------------------------------------
87
+ for d in wh.list_deliveries(status="dead")["items"]:
88
+ wh.redeliver(d["id"])
89
+ ```
90
+
91
+ ## Develop
92
+
93
+ ```sh
94
+ pip install -e '.[dev]'
95
+ pytest && ruff check . && mypy webhookd_sdk
96
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webhookd-sdk"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Official Python SDK for NimbusNexus Webhooks — publish events + verify webhook signatures."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,293 @@
1
+ """Publish client — exercised against an httpx MockTransport (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from webhookd_sdk import Client, WebhookdAPIError
11
+
12
+ _OK = {
13
+ "id": "evt_db",
14
+ "event_uid": "u1",
15
+ "event_type": "order.created",
16
+ "application": "default",
17
+ "environment": "prod",
18
+ "deliveries_created": 2,
19
+ "source": None,
20
+ }
21
+
22
+
23
+ def _client(handler, **kw) -> Client:
24
+ return Client("https://wh.example.com", "whsk_x", transport=httpx.MockTransport(handler), **kw)
25
+
26
+
27
+ def test_publish_success_and_request_shape():
28
+ def handler(request: httpx.Request) -> httpx.Response:
29
+ assert request.url.path == "/v1/events"
30
+ assert request.headers["Authorization"] == "Bearer whsk_x"
31
+ body = json.loads(request.content)
32
+ assert body["event_type"] == "order.created"
33
+ assert body["payload"] == {"id": 1} and body["environment"] == "prod"
34
+ return httpx.Response(201, json=_OK)
35
+
36
+ with _client(handler) as c:
37
+ ev = c.publish("order.created", {"id": 1})
38
+ assert ev.event_uid == "u1" and ev.deliveries_created == 2
39
+
40
+
41
+ def test_idempotency_key_header():
42
+ seen: dict[str, str | None] = {}
43
+
44
+ def handler(request: httpx.Request) -> httpx.Response:
45
+ seen["idem"] = request.headers.get("Idempotency-Key")
46
+ return httpx.Response(201, json=_OK)
47
+
48
+ with _client(handler) as c:
49
+ c.publish("order.created", {"id": 1}, idempotency_key="order-123")
50
+ assert seen["idem"] == "order-123"
51
+
52
+
53
+ def test_api_error_carries_envelope():
54
+ def handler(_request: httpx.Request) -> httpx.Response:
55
+ return httpx.Response(422, json={"error": {"code": "validation_error", "message": "bad"}})
56
+
57
+ with _client(handler) as c, pytest.raises(WebhookdAPIError) as ei:
58
+ c.publish("order.created", {})
59
+ assert ei.value.status_code == 422 and ei.value.code == "validation_error"
60
+ assert ei.value.message == "bad"
61
+
62
+
63
+ def test_retries_on_503_then_succeeds():
64
+ calls = {"n": 0}
65
+
66
+ def handler(_request: httpx.Request) -> httpx.Response:
67
+ calls["n"] += 1
68
+ if calls["n"] == 1:
69
+ return httpx.Response(503, json={"error": {"code": "unavailable", "message": "x"}})
70
+ return httpx.Response(201, json=_OK)
71
+
72
+ with _client(handler, max_retries=2) as c:
73
+ ev = c.publish("order.created", {})
74
+ assert calls["n"] == 2 and ev.event_uid == "u1"
75
+
76
+
77
+ def test_does_not_retry_4xx():
78
+ calls = {"n": 0}
79
+
80
+ def handler(_request: httpx.Request) -> httpx.Response:
81
+ calls["n"] += 1
82
+ return httpx.Response(404, json={"error": {"code": "not_found", "message": "no app"}})
83
+
84
+ with _client(handler, max_retries=2) as c, pytest.raises(WebhookdAPIError):
85
+ c.publish("order.created", {})
86
+ assert calls["n"] == 1 # a 404 is terminal — not retried
87
+
88
+
89
+ # -- management: endpoints ----------------------------------------------------
90
+
91
+ _ENDPOINT = {
92
+ "id": "ep_1",
93
+ "url": "https://sub.example.com/hook",
94
+ "environment": "prod",
95
+ "application": "default",
96
+ "status": "enabled",
97
+ "secret": "whsec_shown_once",
98
+ "subscriptions": [{"match_kind": "prefix", "pattern": "order."}],
99
+ }
100
+
101
+
102
+ def test_create_endpoint_returns_secret_and_request_shape():
103
+ def handler(request: httpx.Request) -> httpx.Response:
104
+ assert request.method == "POST"
105
+ assert request.url.path == "/v1/endpoints"
106
+ assert request.headers["Authorization"] == "Bearer whsk_x"
107
+ body = json.loads(request.content)
108
+ # always-sent keys
109
+ assert body["url"] == "https://sub.example.com/hook"
110
+ assert body["environment"] == "prod"
111
+ assert body["application"] == "default"
112
+ assert body["subscriptions"] == [{"match_kind": "prefix", "pattern": "order."}]
113
+ assert body["max_attempts"] == 5
114
+ # unset optional kwargs are dropped
115
+ assert "secret" not in body
116
+ assert "retry_schedule" not in body
117
+ assert "description" not in body
118
+ assert "custom_headers" not in body
119
+ assert "delivery_timeout_ms" not in body
120
+ return httpx.Response(201, json=_ENDPOINT)
121
+
122
+ with _client(handler) as c:
123
+ ep = c.create_endpoint(
124
+ "https://sub.example.com/hook",
125
+ subscriptions=[{"match_kind": "prefix", "pattern": "order."}],
126
+ max_attempts=5,
127
+ )
128
+ # returned as-is (snake_case), secret present exactly as the server sent it
129
+ assert ep["id"] == "ep_1" and ep["secret"] == "whsec_shown_once"
130
+
131
+
132
+ def test_create_endpoint_defaults_subscriptions_to_empty_list():
133
+ def handler(request: httpx.Request) -> httpx.Response:
134
+ body = json.loads(request.content)
135
+ assert body["subscriptions"] == []
136
+ return httpx.Response(201, json=_ENDPOINT)
137
+
138
+ with _client(handler) as c:
139
+ c.create_endpoint("https://sub.example.com/hook")
140
+
141
+
142
+ def test_list_endpoints_passes_environment_and_returns_page():
143
+ page = {"items": [_ENDPOINT], "next_offset": 50}
144
+
145
+ def handler(request: httpx.Request) -> httpx.Response:
146
+ assert request.method == "GET"
147
+ assert request.url.path == "/v1/endpoints"
148
+ assert request.url.params["environment"] == "staging"
149
+ assert request.url.params["offset"] == "0"
150
+ assert request.url.params["limit"] == "50"
151
+ return httpx.Response(200, json=page)
152
+
153
+ with _client(handler) as c:
154
+ result = c.list_endpoints(environment="staging", limit=50)
155
+ assert result["items"][0]["id"] == "ep_1"
156
+ assert result["next_offset"] == 50
157
+
158
+
159
+ def test_get_endpoint_hits_path():
160
+ def handler(request: httpx.Request) -> httpx.Response:
161
+ assert request.method == "GET"
162
+ assert request.url.path == "/v1/endpoints/ep_1"
163
+ return httpx.Response(200, json=_ENDPOINT)
164
+
165
+ with _client(handler) as c:
166
+ ep = c.get_endpoint("ep_1")
167
+ assert ep["id"] == "ep_1"
168
+
169
+
170
+ def test_update_endpoint_sends_only_provided_keys():
171
+ def handler(request: httpx.Request) -> httpx.Response:
172
+ assert request.method == "PATCH"
173
+ assert request.url.path == "/v1/endpoints/ep_1"
174
+ body = json.loads(request.content)
175
+ # explicit None clears; provided key present; omitted key absent
176
+ assert body == {"description": None, "max_attempts": 10}
177
+ assert "url" not in body # omitted -> must not appear in the request body
178
+ return httpx.Response(200, json=_ENDPOINT)
179
+
180
+ with _client(handler) as c:
181
+ c.update_endpoint("ep_1", {"description": None, "max_attempts": 10})
182
+
183
+
184
+ def test_delete_endpoint_handles_204():
185
+ def handler(request: httpx.Request) -> httpx.Response:
186
+ assert request.method == "DELETE"
187
+ assert request.url.path == "/v1/endpoints/ep_1"
188
+ return httpx.Response(204) # no body
189
+
190
+ with _client(handler) as c:
191
+ assert c.delete_endpoint("ep_1") is None
192
+
193
+
194
+ def test_rotate_secret_hits_path_and_returns_new_secret():
195
+ def handler(request: httpx.Request) -> httpx.Response:
196
+ assert request.method == "POST"
197
+ assert request.url.path == "/v1/endpoints/ep_1/rotate-secret"
198
+ assert not request.content # no request body
199
+ return httpx.Response(200, json={**_ENDPOINT, "secret": "whsec_new"})
200
+
201
+ with _client(handler) as c:
202
+ ep = c.rotate_endpoint_secret("ep_1")
203
+ assert ep["secret"] == "whsec_new"
204
+
205
+
206
+ def test_enable_endpoint_hits_path():
207
+ def handler(request: httpx.Request) -> httpx.Response:
208
+ assert request.method == "POST"
209
+ assert request.url.path == "/v1/endpoints/ep_1/enable"
210
+ assert not request.content # no request body
211
+ return httpx.Response(200, json=_ENDPOINT)
212
+
213
+ with _client(handler) as c:
214
+ ep = c.enable_endpoint("ep_1")
215
+ assert ep["status"] == "enabled"
216
+
217
+
218
+ # -- management: api keys -----------------------------------------------------
219
+
220
+
221
+ def test_create_api_key_returns_key_and_sends_scope_and_expiry():
222
+ out = {"id": "key_1", "name": "ci", "scope": "publish", "key": "whsk_secret_once"}
223
+
224
+ def handler(request: httpx.Request) -> httpx.Response:
225
+ assert request.method == "POST"
226
+ assert request.url.path == "/v1/api-keys"
227
+ body = json.loads(request.content)
228
+ assert body == {"name": "ci", "scope": "publish", "expires_in_days": 30}
229
+ return httpx.Response(201, json=out)
230
+
231
+ with _client(handler) as c:
232
+ key = c.create_api_key("ci", "publish", expires_in_days=30)
233
+ assert key["key"] == "whsk_secret_once" and key["scope"] == "publish"
234
+
235
+
236
+ def test_create_api_key_omits_expiry_when_absent():
237
+ def handler(request: httpx.Request) -> httpx.Response:
238
+ body = json.loads(request.content)
239
+ assert body == {"name": "", "scope": "admin"}
240
+ assert "expires_in_days" not in body
241
+ return httpx.Response(201, json={"id": "key_2", "key": "whsk_x", "scope": "admin"})
242
+
243
+ with _client(handler) as c:
244
+ c.create_api_key()
245
+
246
+
247
+ def test_revoke_api_key_handles_204():
248
+ def handler(request: httpx.Request) -> httpx.Response:
249
+ assert request.method == "DELETE"
250
+ assert request.url.path == "/v1/api-keys/key_1"
251
+ return httpx.Response(204)
252
+
253
+ with _client(handler) as c:
254
+ assert c.revoke_api_key("key_1") is None
255
+
256
+
257
+ # -- management: deliveries ---------------------------------------------------
258
+
259
+
260
+ def test_list_deliveries_forwards_only_provided_params():
261
+ page = {"items": [{"id": "dlv_1", "status": "failed"}], "next_offset": None}
262
+
263
+ def handler(request: httpx.Request) -> httpx.Response:
264
+ assert request.method == "GET"
265
+ assert request.url.path == "/v1/deliveries"
266
+ params = request.url.params
267
+ assert params["status"] == "failed"
268
+ assert params["endpoint_id"] == "ep_1"
269
+ assert params["offset"] == "0"
270
+ # unprovided filters must not be forwarded
271
+ assert "event_type" not in params
272
+ assert "since" not in params
273
+ assert "until" not in params
274
+ assert "q" not in params
275
+ assert "limit" not in params
276
+ return httpx.Response(200, json=page)
277
+
278
+ with _client(handler) as c:
279
+ result = c.list_deliveries(status="failed", endpoint_id="ep_1")
280
+ assert result["items"][0]["id"] == "dlv_1"
281
+ assert result["next_offset"] is None
282
+
283
+
284
+ def test_redeliver_hits_path():
285
+ def handler(request: httpx.Request) -> httpx.Response:
286
+ assert request.method == "POST"
287
+ assert request.url.path == "/v1/deliveries/dlv_1/redeliver"
288
+ assert not request.content # no request body
289
+ return httpx.Response(200, json={"id": "dlv_1", "status": "queued"})
290
+
291
+ with _client(handler) as c:
292
+ dlv = c.redeliver("dlv_1")
293
+ assert dlv["status"] == "queued"
@@ -58,6 +58,12 @@ def test_accepts_str_timestamp_header():
58
58
  assert verify(SECRET, BODY, sig, timestamp=str(TS), now=TS)
59
59
 
60
60
 
61
+ def test_rejects_non_numeric_timestamp():
62
+ # A malformed X-Webhook-Timestamp header must be rejected cleanly, not raise (TS parity).
63
+ sig = sign(SECRET, BODY, TS)
64
+ assert not verify(SECRET, BODY, sig, timestamp="not-a-number", now=TS)
65
+
66
+
61
67
  def test_str_body_accepted():
62
68
  sig = sign(SECRET, BODY.decode(), TS)
63
69
  assert verify(SECRET, BODY.decode(), sig, timestamp=TS, now=TS)
@@ -70,3 +76,13 @@ def test_matches_delivery_core_server_signer():
70
76
  assert verify(SECRET, BODY, server_sig, timestamp=TS, now=TS)
71
77
  # ... and our sign matches theirs exactly.
72
78
  assert sign(SECRET, BODY, TS) == server_sig
79
+
80
+
81
+ def test_dual_signature_accepts_any_matching_token():
82
+ # SEC-5(C): during a signing-secret rotation webhookd may send TWO comma-separated tokens
83
+ # (current + previous); a subscriber configured with EITHER secret must verify.
84
+ other = "whsec_previous_secret"
85
+ header = sign(SECRET, BODY, TS) + "," + sign(other, BODY, TS)
86
+ assert verify(SECRET, BODY, header, timestamp=TS, now=TS) # matches the first token
87
+ assert verify(other, BODY, header, timestamp=TS, now=TS) # matches the second token
88
+ assert not verify("whsec_neither", BODY, header, timestamp=TS, now=TS) # matches neither
@@ -10,7 +10,8 @@ from webhookd_sdk.client import Client, Event
10
10
  from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
11
11
  from webhookd_sdk.signature import DEFAULT_TOLERANCE_SECONDS, sign, verify
12
12
 
13
- __version__ = "0.1.0"
13
+ # Keep in lockstep with pyproject.toml + the release tag (see publish.yml's bump checklist).
14
+ __version__ = "0.2.0"
14
15
 
15
16
  __all__ = [
16
17
  "Client",
@@ -0,0 +1,301 @@
1
+ """Typed publish client for the webhookd API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
12
+
13
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
14
+
15
+
16
+ @dataclass
17
+ class Event:
18
+ """The published event, as returned by ``POST /v1/events`` (webhookd's ``EventOut``)."""
19
+
20
+ id: str
21
+ event_uid: str
22
+ event_type: str
23
+ application: str
24
+ environment: str
25
+ deliveries_created: int
26
+ source: str | None = None
27
+
28
+ @classmethod
29
+ def _from_json(cls, d: dict[str, Any]) -> Event:
30
+ return cls(
31
+ id=d["id"],
32
+ event_uid=d["event_uid"],
33
+ event_type=d["event_type"],
34
+ application=d.get("application", "default"),
35
+ environment=d.get("environment", "prod"),
36
+ deliveries_created=d.get("deliveries_created", 0),
37
+ source=d.get("source"),
38
+ )
39
+
40
+
41
+ class Client:
42
+ """Publish events to webhookd.
43
+
44
+ Authenticate with a per-tenant API key (``whsk_…``) or a service token::
45
+
46
+ with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
47
+ event = wh.publish("order.created", {"id": 123}, idempotency_key="order-123")
48
+
49
+ Transient failures (connection errors, 429, 5xx) are retried with exponential backoff; a 429
50
+ honours its ``Retry-After`` header. Other 4xx raise :class:`WebhookdAPIError` carrying the
51
+ server's ``{error: {code, message}}`` envelope.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str,
57
+ api_key: str,
58
+ *,
59
+ timeout: float = 10.0,
60
+ max_retries: int = 2,
61
+ transport: httpx.BaseTransport | None = None,
62
+ ):
63
+ self._base = base_url.rstrip("/")
64
+ self._key = api_key
65
+ self._max_retries = max_retries
66
+ self._client = httpx.Client(timeout=timeout, transport=transport)
67
+
68
+ def publish(
69
+ self,
70
+ event_type: str,
71
+ payload: dict[str, Any],
72
+ *,
73
+ environment: str = "prod",
74
+ application: str = "default",
75
+ source: str | None = None,
76
+ idempotency_key: str | None = None,
77
+ ) -> Event:
78
+ """Publish one event. ``idempotency_key`` makes the publish safe to retry (a replay returns
79
+ the original event without re-fanning-out)."""
80
+ body: dict[str, Any] = {
81
+ "event_type": event_type,
82
+ "payload": payload,
83
+ "environment": environment,
84
+ "application": application,
85
+ }
86
+ if source is not None:
87
+ body["source"] = source
88
+ extra_headers = None
89
+ if idempotency_key is not None:
90
+ extra_headers = {"Idempotency-Key": idempotency_key}
91
+ resp = self._request("POST", "/v1/events", json=body, extra_headers=extra_headers)
92
+ return Event._from_json(resp.json())
93
+
94
+ # -- endpoints -------------------------------------------------------------
95
+ def create_endpoint(
96
+ self,
97
+ url: str,
98
+ *,
99
+ subscriptions: list[dict[str, Any]] | None = None,
100
+ environment: str = "prod",
101
+ application: str = "default",
102
+ secret: str | None = None,
103
+ max_attempts: int | None = None,
104
+ retry_schedule: list[int] | None = None,
105
+ description: str | None = None,
106
+ custom_headers: dict[str, str] | None = None,
107
+ delivery_timeout_ms: int | None = None,
108
+ ) -> dict[str, Any]:
109
+ """Create an endpoint (``POST /v1/endpoints``). The returned ``EndpointOut`` includes the
110
+ signing ``secret`` exactly ONCE — persist it now, it is never shown again.
111
+
112
+ ``subscriptions`` is a list of ``{"match_kind", "pattern"}`` (``match_kind`` in
113
+ ``exact|prefix|suffix|all``); it defaults to ``[]``. Only ``url``/``environment``/
114
+ ``application``/``subscriptions`` are always sent — any other ``None`` kwarg is omitted
115
+ so the server applies its own default.
116
+ """
117
+ body: dict[str, Any] = {
118
+ "url": url,
119
+ "environment": environment,
120
+ "application": application,
121
+ "subscriptions": subscriptions if subscriptions is not None else [],
122
+ }
123
+ optional: dict[str, Any] = {
124
+ "secret": secret,
125
+ "max_attempts": max_attempts,
126
+ "retry_schedule": retry_schedule,
127
+ "description": description,
128
+ "custom_headers": custom_headers,
129
+ "delivery_timeout_ms": delivery_timeout_ms,
130
+ }
131
+ for key, value in optional.items():
132
+ if value is not None:
133
+ body[key] = value
134
+ return self._request("POST", "/v1/endpoints", json=body).json()
135
+
136
+ def list_endpoints(
137
+ self, *, environment: str = "prod", offset: int = 0, limit: int | None = None
138
+ ) -> dict[str, Any]:
139
+ """List endpoints (``GET /v1/endpoints``). Returns a ``Page``:
140
+ ``{"items": [EndpointOut], "next_offset": int | None}``."""
141
+ params: dict[str, Any] = {"environment": environment, "offset": offset}
142
+ if limit is not None:
143
+ params["limit"] = limit
144
+ return self._request("GET", "/v1/endpoints", params=params).json()
145
+
146
+ def get_endpoint(self, endpoint_id: str) -> dict[str, Any]:
147
+ """Fetch one endpoint (``GET /v1/endpoints/{id}``) -> ``EndpointOut``."""
148
+ return self._request("GET", f"/v1/endpoints/{endpoint_id}").json()
149
+
150
+ def update_endpoint(self, endpoint_id: str, patch: dict[str, Any]) -> dict[str, Any]:
151
+ """Patch an endpoint (``PATCH /v1/endpoints/{id}``) -> ``EndpointOut``.
152
+
153
+ The ``patch`` mapping is sent AS-IS with snake_case keys (``url``, ``max_attempts``,
154
+ ``retry_schedule``, ``status``, ``description``, ``custom_headers``,
155
+ ``delivery_timeout_ms``). PATCH semantics: an omitted key is left unchanged, an explicit
156
+ ``None`` clears it. No defaults are injected — send only the keys you want to change.
157
+ """
158
+ return self._request("PATCH", f"/v1/endpoints/{endpoint_id}", json=patch).json()
159
+
160
+ def delete_endpoint(self, endpoint_id: str) -> None:
161
+ """Delete an endpoint (``DELETE /v1/endpoints/{id}``, 204). Returns ``None``."""
162
+ self._request("DELETE", f"/v1/endpoints/{endpoint_id}")
163
+
164
+ def rotate_endpoint_secret(self, endpoint_id: str) -> dict[str, Any]:
165
+ """Rotate an endpoint's signing secret (``POST /v1/endpoints/{id}/rotate-secret``).
166
+
167
+ Returns the ``EndpointOut`` with the NEW ``secret`` shown once.
168
+ """
169
+ return self._request("POST", f"/v1/endpoints/{endpoint_id}/rotate-secret").json()
170
+
171
+ def enable_endpoint(self, endpoint_id: str) -> dict[str, Any]:
172
+ """Re-enable an auto-disabled endpoint (``POST /v1/endpoints/{id}/enable``).
173
+
174
+ Returns ``EndpointOut``.
175
+ """
176
+ return self._request("POST", f"/v1/endpoints/{endpoint_id}/enable").json()
177
+
178
+ # -- api keys --------------------------------------------------------------
179
+ def create_api_key(
180
+ self, name: str = "", scope: str = "admin", *, expires_in_days: int | None = None
181
+ ) -> dict[str, Any]:
182
+ """Create an API key (``POST /v1/api-keys``). The returned ``ApiKeyOut`` includes the
183
+ plaintext ``key`` exactly ONCE. ``scope`` is ``"admin"`` or ``"publish"``."""
184
+ body: dict[str, Any] = {"name": name, "scope": scope}
185
+ if expires_in_days is not None:
186
+ body["expires_in_days"] = expires_in_days
187
+ return self._request("POST", "/v1/api-keys", json=body).json()
188
+
189
+ def revoke_api_key(self, key_id: str) -> None:
190
+ """Revoke an API key (``DELETE /v1/api-keys/{id}``, 204). Returns ``None``."""
191
+ self._request("DELETE", f"/v1/api-keys/{key_id}")
192
+
193
+ # -- deliveries ------------------------------------------------------------
194
+ def list_deliveries(
195
+ self,
196
+ *,
197
+ status: str | None = None,
198
+ endpoint_id: str | None = None,
199
+ event_type: str | None = None,
200
+ since: str | None = None,
201
+ until: str | None = None,
202
+ q: str | None = None,
203
+ offset: int = 0,
204
+ limit: int | None = None,
205
+ ) -> dict[str, Any]:
206
+ """List deliveries (``GET /v1/deliveries``). Returns a ``Page``:
207
+ ``{"items": [DeliveryOut], "next_offset": int | None}``.
208
+
209
+ Every filter is optional and only forwarded when provided. ``status`` is one of
210
+ ``queued|sending|sent|failed|dead``.
211
+ """
212
+ params: dict[str, Any] = {"offset": offset}
213
+ optional: dict[str, Any] = {
214
+ "status": status,
215
+ "endpoint_id": endpoint_id,
216
+ "event_type": event_type,
217
+ "since": since,
218
+ "until": until,
219
+ "q": q,
220
+ "limit": limit,
221
+ }
222
+ for key, value in optional.items():
223
+ if value is not None:
224
+ params[key] = value
225
+ return self._request("GET", "/v1/deliveries", params=params).json()
226
+
227
+ def redeliver(self, delivery_id: str) -> dict[str, Any]:
228
+ """Re-enqueue a delivery (``POST /v1/deliveries/{id}/redeliver``) -> ``DeliveryOut``."""
229
+ return self._request("POST", f"/v1/deliveries/{delivery_id}/redeliver").json()
230
+
231
+ def close(self) -> None:
232
+ self._client.close()
233
+
234
+ def __enter__(self) -> Client:
235
+ return self
236
+
237
+ def __exit__(self, *_exc: object) -> None:
238
+ self.close()
239
+
240
+ # -- internals -------------------------------------------------------------
241
+ def _request(
242
+ self,
243
+ method: str,
244
+ path: str,
245
+ *,
246
+ json: dict[str, Any] | None = None,
247
+ params: dict[str, Any] | None = None,
248
+ extra_headers: dict[str, str] | None = None,
249
+ ) -> httpx.Response:
250
+ """Issue one authenticated request with the shared retry policy.
251
+
252
+ Retries connection errors, 429 (honouring ``Retry-After``) and 5xx with capped
253
+ exponential backoff; any other 4xx/5xx raises :class:`WebhookdAPIError`. ``json`` is
254
+ omitted for bodyless verbs (GET/DELETE, or POSTs with no body). The raw
255
+ :class:`httpx.Response` is returned — the caller decides whether to parse it (a 204 must
256
+ not be parsed).
257
+ """
258
+ url = f"{self._base}{path}"
259
+ headers = {"Authorization": f"Bearer {self._key}"}
260
+ if extra_headers:
261
+ headers.update(extra_headers)
262
+ last_exc: Exception | None = None
263
+ for attempt in range(self._max_retries + 1):
264
+ try:
265
+ resp = self._client.request(method, url, json=json, params=params, headers=headers)
266
+ except httpx.HTTPError as exc:
267
+ last_exc = exc
268
+ if attempt < self._max_retries:
269
+ time.sleep(_backoff(attempt))
270
+ continue
271
+ raise WebhookdError(f"request failed: {exc}") from exc
272
+
273
+ if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries:
274
+ time.sleep(_retry_after(resp) or _backoff(attempt))
275
+ continue
276
+ if resp.status_code >= 400:
277
+ raise _api_error(resp)
278
+ return resp
279
+ raise WebhookdError(f"request failed after retries: {last_exc}")
280
+
281
+
282
+ def _backoff(attempt: int) -> float:
283
+ return min(2.0, 0.2 * (2**attempt))
284
+
285
+
286
+ def _retry_after(resp: httpx.Response) -> float | None:
287
+ raw = resp.headers.get("Retry-After")
288
+ if raw and raw.isdigit():
289
+ return float(raw)
290
+ return None
291
+
292
+
293
+ def _api_error(resp: httpx.Response) -> WebhookdAPIError:
294
+ code, message = "error", resp.text
295
+ try:
296
+ err = resp.json().get("error") or {}
297
+ code = err.get("code", code)
298
+ message = err.get("message", message)
299
+ except Exception: # noqa: BLE001 - a non-JSON error body falls back to the raw text
300
+ pass
301
+ return WebhookdAPIError(resp.status_code, code, message)
@@ -71,11 +71,24 @@ def verify(
71
71
  :param tolerance_seconds: replay window; webhookd's default is 300s.
72
72
  :param now: override the current unix time (for tests).
73
73
  """
74
- ts = int(timestamp) if timestamp is not None else None
74
+ if timestamp is None:
75
+ ts = None
76
+ else:
77
+ try:
78
+ ts = int(timestamp)
79
+ except (ValueError, TypeError):
80
+ return False # a malformed X-Webhook-Timestamp can't be valid — reject, don't raise
75
81
  if ts is not None:
76
82
  current = int(time.time()) if now is None else now
77
83
  if abs(current - ts) > tolerance_seconds:
78
84
  return False
79
85
  expected = sign(secret, raw_body, ts)
80
- candidate = signature if signature.startswith(_PREFIX) else f"{_PREFIX}{signature}"
81
- return hmac.compare_digest(expected, candidate)
86
+ # X-Webhook-Signature carries one token normally, or several comma-separated tokens during a
87
+ # signing-secret rotation (webhookd dual-sign overlap) — accept if ANY token verifies, so a
88
+ # subscriber configured with EITHER the current or the previous secret keeps working.
89
+ for token in signature.split(","):
90
+ token = token.strip()
91
+ candidate = token if token.startswith(_PREFIX) else f"{_PREFIX}{token}"
92
+ if hmac.compare_digest(expected, candidate):
93
+ return True
94
+ return False
@@ -1,54 +0,0 @@
1
- # webhookd-sdk (Python)
2
-
3
- Official Python SDK for **NimbusNexus Webhooks** — publish events, and verify the webhooks you receive.
4
-
5
- ```sh
6
- pip install webhookd-sdk
7
- ```
8
-
9
- ## Verify an incoming webhook (subscribers)
10
-
11
- When webhookd delivers a webhook it signs the body with your endpoint's signing secret. **Always
12
- verify the signature** before trusting the payload — it proves the request really came from webhookd
13
- and wasn't tampered with or replayed.
14
-
15
- ```python
16
- from webhookd_sdk import verify
17
-
18
- # In your webhook handler — pass the RAW request body bytes (do not re-serialize the JSON):
19
- ok = verify(
20
- secret=ENDPOINT_SIGNING_SECRET,
21
- raw_body=request.body,
22
- signature=request.headers["X-Webhook-Signature"],
23
- timestamp=request.headers["X-Webhook-Timestamp"],
24
- )
25
- if not ok:
26
- return Response(status_code=400) # forged, tampered, or outside the 300s replay window
27
- ```
28
-
29
- ## Publish an event (producers)
30
-
31
- ```python
32
- from webhookd_sdk import Client, WebhookdAPIError
33
-
34
- with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
35
- try:
36
- event = wh.publish(
37
- "order.created",
38
- {"order_id": "ord_123", "total": 4200},
39
- idempotency_key="order-123", # makes the publish safe to retry
40
- )
41
- print(event.event_uid, event.deliveries_created)
42
- except WebhookdAPIError as e:
43
- print(e.status_code, e.code, e.message) # the stable {error:{code,message}} envelope
44
- ```
45
-
46
- Transient failures (connection errors, `429`, `5xx`) are retried with backoff (a `429` honours
47
- `Retry-After`); other `4xx` raise `WebhookdAPIError`.
48
-
49
- ## Develop
50
-
51
- ```sh
52
- pip install -e '.[dev]'
53
- pytest && ruff check . && mypy webhookd_sdk
54
- ```
@@ -1,86 +0,0 @@
1
- """Publish client — exercised against an httpx MockTransport (no network)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
-
7
- import httpx
8
- import pytest
9
-
10
- from webhookd_sdk import Client, WebhookdAPIError
11
-
12
- _OK = {
13
- "id": "evt_db",
14
- "event_uid": "u1",
15
- "event_type": "order.created",
16
- "application": "default",
17
- "environment": "prod",
18
- "deliveries_created": 2,
19
- "source": None,
20
- }
21
-
22
-
23
- def _client(handler, **kw) -> Client:
24
- return Client("https://wh.example.com", "whsk_x", transport=httpx.MockTransport(handler), **kw)
25
-
26
-
27
- def test_publish_success_and_request_shape():
28
- def handler(request: httpx.Request) -> httpx.Response:
29
- assert request.url.path == "/v1/events"
30
- assert request.headers["Authorization"] == "Bearer whsk_x"
31
- body = json.loads(request.content)
32
- assert body["event_type"] == "order.created"
33
- assert body["payload"] == {"id": 1} and body["environment"] == "prod"
34
- return httpx.Response(201, json=_OK)
35
-
36
- with _client(handler) as c:
37
- ev = c.publish("order.created", {"id": 1})
38
- assert ev.event_uid == "u1" and ev.deliveries_created == 2
39
-
40
-
41
- def test_idempotency_key_header():
42
- seen: dict[str, str | None] = {}
43
-
44
- def handler(request: httpx.Request) -> httpx.Response:
45
- seen["idem"] = request.headers.get("Idempotency-Key")
46
- return httpx.Response(201, json=_OK)
47
-
48
- with _client(handler) as c:
49
- c.publish("order.created", {"id": 1}, idempotency_key="order-123")
50
- assert seen["idem"] == "order-123"
51
-
52
-
53
- def test_api_error_carries_envelope():
54
- def handler(_request: httpx.Request) -> httpx.Response:
55
- return httpx.Response(422, json={"error": {"code": "validation_error", "message": "bad"}})
56
-
57
- with _client(handler) as c, pytest.raises(WebhookdAPIError) as ei:
58
- c.publish("order.created", {})
59
- assert ei.value.status_code == 422 and ei.value.code == "validation_error"
60
- assert ei.value.message == "bad"
61
-
62
-
63
- def test_retries_on_503_then_succeeds():
64
- calls = {"n": 0}
65
-
66
- def handler(_request: httpx.Request) -> httpx.Response:
67
- calls["n"] += 1
68
- if calls["n"] == 1:
69
- return httpx.Response(503, json={"error": {"code": "unavailable", "message": "x"}})
70
- return httpx.Response(201, json=_OK)
71
-
72
- with _client(handler, max_retries=2) as c:
73
- ev = c.publish("order.created", {})
74
- assert calls["n"] == 2 and ev.event_uid == "u1"
75
-
76
-
77
- def test_does_not_retry_4xx():
78
- calls = {"n": 0}
79
-
80
- def handler(_request: httpx.Request) -> httpx.Response:
81
- calls["n"] += 1
82
- return httpx.Response(404, json={"error": {"code": "not_found", "message": "no app"}})
83
-
84
- with _client(handler, max_retries=2) as c, pytest.raises(WebhookdAPIError):
85
- c.publish("order.created", {})
86
- assert calls["n"] == 1 # a 404 is terminal — not retried
@@ -1,147 +0,0 @@
1
- """Typed publish client for the webhookd API."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from dataclasses import dataclass
7
- from typing import Any
8
-
9
- import httpx
10
-
11
- from webhookd_sdk.errors import WebhookdAPIError, WebhookdError
12
-
13
- _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
14
-
15
-
16
- @dataclass
17
- class Event:
18
- """The published event, as returned by ``POST /v1/events`` (webhookd's ``EventOut``)."""
19
-
20
- id: str
21
- event_uid: str
22
- event_type: str
23
- application: str
24
- environment: str
25
- deliveries_created: int
26
- source: str | None = None
27
-
28
- @classmethod
29
- def _from_json(cls, d: dict[str, Any]) -> Event:
30
- return cls(
31
- id=d["id"],
32
- event_uid=d["event_uid"],
33
- event_type=d["event_type"],
34
- application=d.get("application", "default"),
35
- environment=d.get("environment", "prod"),
36
- deliveries_created=d.get("deliveries_created", 0),
37
- source=d.get("source"),
38
- )
39
-
40
-
41
- class Client:
42
- """Publish events to webhookd.
43
-
44
- Authenticate with a per-tenant API key (``whsk_…``) or a service token::
45
-
46
- with Client("https://webhooks.example.com", api_key="whsk_…") as wh:
47
- event = wh.publish("order.created", {"id": 123}, idempotency_key="order-123")
48
-
49
- Transient failures (connection errors, 429, 5xx) are retried with exponential backoff; a 429
50
- honours its ``Retry-After`` header. Other 4xx raise :class:`WebhookdAPIError` carrying the
51
- server's ``{error: {code, message}}`` envelope.
52
- """
53
-
54
- def __init__(
55
- self,
56
- base_url: str,
57
- api_key: str,
58
- *,
59
- timeout: float = 10.0,
60
- max_retries: int = 2,
61
- transport: httpx.BaseTransport | None = None,
62
- ):
63
- self._base = base_url.rstrip("/")
64
- self._key = api_key
65
- self._max_retries = max_retries
66
- self._client = httpx.Client(timeout=timeout, transport=transport)
67
-
68
- def publish(
69
- self,
70
- event_type: str,
71
- payload: dict[str, Any],
72
- *,
73
- environment: str = "prod",
74
- application: str = "default",
75
- source: str | None = None,
76
- idempotency_key: str | None = None,
77
- ) -> Event:
78
- """Publish one event. ``idempotency_key`` makes the publish safe to retry (a replay returns
79
- the original event without re-fanning-out)."""
80
- body: dict[str, Any] = {
81
- "event_type": event_type,
82
- "payload": payload,
83
- "environment": environment,
84
- "application": application,
85
- }
86
- if source is not None:
87
- body["source"] = source
88
- headers = {"Authorization": f"Bearer {self._key}"}
89
- if idempotency_key is not None:
90
- headers["Idempotency-Key"] = idempotency_key
91
- resp = self._post("/v1/events", body, headers)
92
- return Event._from_json(resp.json())
93
-
94
- def close(self) -> None:
95
- self._client.close()
96
-
97
- def __enter__(self) -> Client:
98
- return self
99
-
100
- def __exit__(self, *_exc: object) -> None:
101
- self.close()
102
-
103
- # -- internals -------------------------------------------------------------
104
- def _post(
105
- self, path: str, json_body: dict[str, Any], headers: dict[str, str]
106
- ) -> httpx.Response:
107
- url = f"{self._base}{path}"
108
- last_exc: Exception | None = None
109
- for attempt in range(self._max_retries + 1):
110
- try:
111
- resp = self._client.post(url, json=json_body, headers=headers)
112
- except httpx.HTTPError as exc:
113
- last_exc = exc
114
- if attempt < self._max_retries:
115
- time.sleep(_backoff(attempt))
116
- continue
117
- raise WebhookdError(f"request failed: {exc}") from exc
118
-
119
- if resp.status_code in _RETRY_STATUSES and attempt < self._max_retries:
120
- time.sleep(_retry_after(resp) or _backoff(attempt))
121
- continue
122
- if resp.status_code >= 400:
123
- raise _api_error(resp)
124
- return resp
125
- raise WebhookdError(f"request failed after retries: {last_exc}")
126
-
127
-
128
- def _backoff(attempt: int) -> float:
129
- return min(2.0, 0.2 * (2**attempt))
130
-
131
-
132
- def _retry_after(resp: httpx.Response) -> float | None:
133
- raw = resp.headers.get("Retry-After")
134
- if raw and raw.isdigit():
135
- return float(raw)
136
- return None
137
-
138
-
139
- def _api_error(resp: httpx.Response) -> WebhookdAPIError:
140
- code, message = "error", resp.text
141
- try:
142
- err = resp.json().get("error") or {}
143
- code = err.get("code", code)
144
- message = err.get("message", message)
145
- except Exception: # noqa: BLE001 - a non-JSON error body falls back to the raw text
146
- pass
147
- return WebhookdAPIError(resp.status_code, code, message)
File without changes
File without changes