Skip to content

Commit 1f86bf7

Browse files
committed
feat: add webhook subscription and event client helpers
Implement webhook subscription lifecycle and event/replay helpers in the Python SDK with tests and quickstart examples for Track C parity. Made-with: Cursor
1 parent b0454f5 commit 1f86bf7

3 files changed

Lines changed: 179 additions & 0 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ with AxmeClient(config) as client:
4040
idempotency_key="reply-001",
4141
)
4242
print(replied)
43+
subscription = client.upsert_webhook_subscription(
44+
{
45+
"callback_url": "https://integrator.example/webhooks/axme",
46+
"event_types": ["inbox.thread_created"],
47+
"active": True,
48+
}
49+
)
50+
print(subscription)
51+
events = client.publish_webhook_event(
52+
{"event_type": "inbox.thread_created", "source": "sdk-example", "payload": {"thread_id": "t-1"}},
53+
owner_agent="agent://example/receiver",
54+
)
55+
print(events["event_id"])
4356
```
4457

4558
## Development

axme_sdk/client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,46 @@ def reply_inbox_thread(
121121
)
122122
return self._parse_json_response(response)
123123

124+
def upsert_webhook_subscription(
125+
self,
126+
payload: dict[str, Any],
127+
*,
128+
idempotency_key: str | None = None,
129+
) -> dict[str, Any]:
130+
headers: dict[str, str] | None = None
131+
if idempotency_key is not None:
132+
headers = {"Idempotency-Key": idempotency_key}
133+
response = self._http.post("/v1/webhooks/subscriptions", json=payload, headers=headers)
134+
return self._parse_json_response(response)
135+
136+
def list_webhook_subscriptions(self, *, owner_agent: str | None = None) -> dict[str, Any]:
137+
params: dict[str, str] | None = None
138+
if owner_agent is not None:
139+
params = {"owner_agent": owner_agent}
140+
response = self._http.get("/v1/webhooks/subscriptions", params=params)
141+
return self._parse_json_response(response)
142+
143+
def delete_webhook_subscription(self, subscription_id: str, *, owner_agent: str | None = None) -> dict[str, Any]:
144+
params: dict[str, str] | None = None
145+
if owner_agent is not None:
146+
params = {"owner_agent": owner_agent}
147+
response = self._http.delete(f"/v1/webhooks/subscriptions/{subscription_id}", params=params)
148+
return self._parse_json_response(response)
149+
150+
def publish_webhook_event(self, payload: dict[str, Any], *, owner_agent: str | None = None) -> dict[str, Any]:
151+
params: dict[str, str] | None = None
152+
if owner_agent is not None:
153+
params = {"owner_agent": owner_agent}
154+
response = self._http.post("/v1/webhooks/events", params=params, json=payload)
155+
return self._parse_json_response(response)
156+
157+
def replay_webhook_event(self, event_id: str, *, owner_agent: str | None = None) -> dict[str, Any]:
158+
params: dict[str, str] | None = None
159+
if owner_agent is not None:
160+
params = {"owner_agent": owner_agent}
161+
response = self._http.post(f"/v1/webhooks/events/{event_id}/replay", params=params)
162+
return self._parse_json_response(response)
163+
124164
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
125165
if response.status_code >= 400:
126166
self._raise_http_error(response)

tests/test_client.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ def _thread_payload() -> dict[str, object]:
4848
}
4949

5050

51+
def _webhook_subscription_payload() -> dict[str, object]:
52+
return {
53+
"subscription_id": "44444444-4444-4444-8444-444444444444",
54+
"owner_agent": "agent://owner",
55+
"callback_url": "https://integrator.example/webhooks/axme",
56+
"event_types": ["inbox.thread_created"],
57+
"active": True,
58+
"description": "sdk-test",
59+
"created_at": "2026-02-28T00:00:00Z",
60+
"updated_at": "2026-02-28T00:00:01Z",
61+
"revoked_at": None,
62+
"secret_hint": "****hint",
63+
}
64+
65+
5166
def test_health_success() -> None:
5267
def handler(request: httpx.Request) -> httpx.Response:
5368
assert request.method == "GET"
@@ -220,3 +235,114 @@ def handler(request: httpx.Request) -> httpx.Response:
220235
assert exc_info.value.retry_after == 30
221236
assert isinstance(exc_info.value.body, dict)
222237
assert exc_info.value.body["message"] == "too many"
238+
239+
240+
def test_upsert_webhook_subscription_success() -> None:
241+
subscription = _webhook_subscription_payload()
242+
request_payload = {
243+
"callback_url": "https://integrator.example/webhooks/axme",
244+
"event_types": ["inbox.thread_created"],
245+
"active": True,
246+
}
247+
248+
def handler(request: httpx.Request) -> httpx.Response:
249+
assert request.method == "POST"
250+
assert request.url.path == "/v1/webhooks/subscriptions"
251+
assert request.headers["idempotency-key"] == "wh-1"
252+
assert request.read() == b'{"callback_url":"https://integrator.example/webhooks/axme","event_types":["inbox.thread_created"],"active":true}'
253+
return httpx.Response(200, json={"ok": True, "subscription": subscription})
254+
255+
client = _client(handler)
256+
assert client.upsert_webhook_subscription(request_payload, idempotency_key="wh-1") == {
257+
"ok": True,
258+
"subscription": subscription,
259+
}
260+
261+
262+
def test_list_webhook_subscriptions_success() -> None:
263+
subscription = _webhook_subscription_payload()
264+
265+
def handler(request: httpx.Request) -> httpx.Response:
266+
assert request.method == "GET"
267+
assert request.url.path == "/v1/webhooks/subscriptions"
268+
assert request.url.params.get("owner_agent") == "agent://owner"
269+
return httpx.Response(200, json={"ok": True, "subscriptions": [subscription]})
270+
271+
client = _client(handler)
272+
assert client.list_webhook_subscriptions(owner_agent="agent://owner") == {
273+
"ok": True,
274+
"subscriptions": [subscription],
275+
}
276+
277+
278+
def test_delete_webhook_subscription_success() -> None:
279+
subscription_id = "44444444-4444-4444-8444-444444444444"
280+
281+
def handler(request: httpx.Request) -> httpx.Response:
282+
assert request.method == "DELETE"
283+
assert request.url.path == f"/v1/webhooks/subscriptions/{subscription_id}"
284+
assert request.url.params.get("owner_agent") == "agent://owner"
285+
return httpx.Response(200, json={"ok": True, "subscription_id": subscription_id, "revoked_at": "2026-02-28T00:00:03Z"})
286+
287+
client = _client(handler)
288+
assert client.delete_webhook_subscription(subscription_id, owner_agent="agent://owner") == {
289+
"ok": True,
290+
"subscription_id": subscription_id,
291+
"revoked_at": "2026-02-28T00:00:03Z",
292+
}
293+
294+
295+
def test_publish_webhook_event_success() -> None:
296+
event_id = "33333333-3333-4333-8333-333333333333"
297+
request_payload = {"event_type": "inbox.thread_created", "source": "sdk-test", "payload": {"thread_id": "t-1"}}
298+
299+
def handler(request: httpx.Request) -> httpx.Response:
300+
assert request.method == "POST"
301+
assert request.url.path == "/v1/webhooks/events"
302+
assert request.url.params.get("owner_agent") == "agent://owner"
303+
return httpx.Response(
304+
200,
305+
json={
306+
"ok": True,
307+
"accepted_at": "2026-02-28T00:00:01Z",
308+
"event_type": "inbox.thread_created",
309+
"source": "sdk-test",
310+
"owner_agent": "agent://owner",
311+
"event_id": event_id,
312+
"queued_deliveries": 1,
313+
"processed_deliveries": 1,
314+
"delivered": 1,
315+
"pending": 0,
316+
"dead_lettered": 0,
317+
},
318+
)
319+
320+
client = _client(handler)
321+
assert client.publish_webhook_event(request_payload, owner_agent="agent://owner")["event_id"] == event_id
322+
323+
324+
def test_replay_webhook_event_success() -> None:
325+
event_id = "33333333-3333-4333-8333-333333333333"
326+
327+
def handler(request: httpx.Request) -> httpx.Response:
328+
assert request.method == "POST"
329+
assert request.url.path == f"/v1/webhooks/events/{event_id}/replay"
330+
assert request.url.params.get("owner_agent") == "agent://owner"
331+
return httpx.Response(
332+
200,
333+
json={
334+
"ok": True,
335+
"event_id": event_id,
336+
"owner_agent": "agent://owner",
337+
"event_type": "inbox.thread_created",
338+
"queued_deliveries": 1,
339+
"processed_deliveries": 1,
340+
"delivered": 1,
341+
"pending": 0,
342+
"dead_lettered": 0,
343+
"replayed_at": "2026-02-28T00:00:02Z",
344+
},
345+
)
346+
347+
client = _client(handler)
348+
assert client.replay_webhook_event(event_id, owner_agent="agent://owner")["event_id"] == event_id

0 commit comments

Comments
 (0)