webhooks-cc 0.2.0__tar.gz → 0.3.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 (25) hide show
  1. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/PKG-INFO +31 -2
  2. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/README.md +30 -1
  3. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/pyproject.toml +1 -1
  4. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/__init__.py +19 -1
  5. webhooks_cc-0.3.0/src/webhooks_cc/_agent_tokens.py +126 -0
  6. webhooks_cc-0.3.0/src/webhooks_cc/agent.py +645 -0
  7. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/client.py +244 -6
  8. webhooks_cc-0.3.0/src/webhooks_cc/credentials.py +502 -0
  9. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/models.py +47 -1
  10. webhooks_cc-0.3.0/src/webhooks_cc/sandbox.py +711 -0
  11. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/.gitignore +0 -0
  12. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/LICENSE +0 -0
  13. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_crypto.py +0 -0
  14. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_js.py +0 -0
  15. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_sentinel.py +0 -0
  16. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_sse.py +0 -0
  17. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/durations.py +0 -0
  18. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/email.py +0 -0
  19. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/errors.py +0 -0
  20. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/matchers.py +0 -0
  21. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/py.typed +0 -0
  22. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/pytest_plugin.py +0 -0
  23. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/templates.py +0 -0
  24. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/testing.py +0 -0
  25. {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webhooks-cc
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for webhooks.cc: capture, wait for, sign and verify webhooks and test emails
5
5
  Project-URL: Homepage, https://webhooks.cc
6
6
  Project-URL: Documentation, https://github.com/kroqdotdev/webhooks-cc/tree/main/packages/sdk-python#readme
@@ -31,7 +31,7 @@ pip install webhooks-cc
31
31
  pip install "webhooks-cc[crypto]" # also verify Discord (Ed25519) and PayPal (RSA) signatures
32
32
  ```
33
33
 
34
- Python 3.10 or later. Get an API key at [webhooks.cc/account/api-keys](https://webhooks.cc/account/api-keys) and set `WHK_API_KEY`, or pass `api_key=`.
34
+ Python 3.10 or later. Get an API key at [webhooks.cc/account/api-keys](https://webhooks.cc/account/api-keys) and set `WHK_API_KEY`, or pass `api_key=`. An AI agent without an account can start in [the agent sandbox](#no-account-the-agent-sandbox).
35
35
 
36
36
  ## Wait for a webhook
37
37
 
@@ -209,6 +209,35 @@ for request in client.requests.subscribe(endpoint.slug, timeout="10m", reconnect
209
209
  print(request.method, request.path)
210
210
  ```
211
211
 
212
+ ## No account: the agent sandbox
213
+
214
+ An AI agent can capture webhooks before anyone signs up. `WebhooksCC.sandbox()` registers through [auth.md](https://webhooks.cc/auth.md), solves a short proof of work (a few seconds of CPU) and returns a sandbox: up to 3 endpoints, 25 captured requests each and 100 in all, for 24 hours. Sandbox endpoints only capture: no mock responses, forwarding or email.
215
+
216
+ ```python
217
+ from webhooks_cc import WebhooksCC
218
+
219
+ with WebhooksCC.sandbox(client_name="my-agent") as sandbox:
220
+ endpoint = sandbox.endpoints.create()
221
+ trigger_checkout(webhook_url=endpoint.url)
222
+ request = sandbox.requests.wait_for(endpoint.slug, timeout="60s")
223
+
224
+ # To keep the endpoints, a human connects the agent to their account.
225
+ claim = sandbox.claim("dev@example.com")
226
+ print(f"Open {claim.verification_uri} and enter {claim.user_code}")
227
+ client = sandbox.wait_for_claim() # a WebhooksCC for that account
228
+ ```
229
+
230
+ The registration is saved in the agent credential file the MCP server, the other SDKs and the `whk` CLI share: `~/.config/whk/agent.json` on Linux (in `$XDG_CONFIG_HOME` when that is set), `~/Library/Application Support/whk/agent.json` on macOS, `%APPDATA%\whk\agent.json` on Windows, or the path in `WHK_AGENT_FILE`. The next `WebhooksCC.sandbox()` reuses a live sandbox instead of registering again, and `credentials_file=False` leaves the file alone. The claim token is never saved, so only the process that registered moves the sandbox endpoints into the account; a sandbox reused from the file starts a new code for the email instead (`claim.moves_endpoints` is false), and its endpoints stay in the sandbox until they expire.
231
+
232
+ A connection lasts 90 days, and any process picks it up:
233
+
234
+ ```python
235
+ client = WebhooksCC.connected() # None when no agent is connected
236
+ client = WebhooksCC() # without WHK_API_KEY, acts for the connected account
237
+ ```
238
+
239
+ `WebhooksCC.connect(email)` connects an agent without a sandbox: show the human `pending.verification_uri` and `pending.user_code`, then `pending.wait()` returns the client. An API key always wins over the file. A refused sandbox call raises `SandboxError` with the server's `code`; the protocol steps themselves are in `webhooks_cc.agent`.
240
+
212
241
  ## Errors
213
242
 
214
243
  API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`. They repeat GET, PUT and DELETE calls after a network error, a timeout or a status in `retry_on` (429, 500, 502, 503 and 504 by default), and any call after a 429; a POST or PATCH that failed any other way may have gone through, so it is not repeated, and neither is `requests.clear()` without `before`.
@@ -7,7 +7,7 @@ pip install webhooks-cc
7
7
  pip install "webhooks-cc[crypto]" # also verify Discord (Ed25519) and PayPal (RSA) signatures
8
8
  ```
9
9
 
10
- Python 3.10 or later. Get an API key at [webhooks.cc/account/api-keys](https://webhooks.cc/account/api-keys) and set `WHK_API_KEY`, or pass `api_key=`.
10
+ Python 3.10 or later. Get an API key at [webhooks.cc/account/api-keys](https://webhooks.cc/account/api-keys) and set `WHK_API_KEY`, or pass `api_key=`. An AI agent without an account can start in [the agent sandbox](#no-account-the-agent-sandbox).
11
11
 
12
12
  ## Wait for a webhook
13
13
 
@@ -185,6 +185,35 @@ for request in client.requests.subscribe(endpoint.slug, timeout="10m", reconnect
185
185
  print(request.method, request.path)
186
186
  ```
187
187
 
188
+ ## No account: the agent sandbox
189
+
190
+ An AI agent can capture webhooks before anyone signs up. `WebhooksCC.sandbox()` registers through [auth.md](https://webhooks.cc/auth.md), solves a short proof of work (a few seconds of CPU) and returns a sandbox: up to 3 endpoints, 25 captured requests each and 100 in all, for 24 hours. Sandbox endpoints only capture: no mock responses, forwarding or email.
191
+
192
+ ```python
193
+ from webhooks_cc import WebhooksCC
194
+
195
+ with WebhooksCC.sandbox(client_name="my-agent") as sandbox:
196
+ endpoint = sandbox.endpoints.create()
197
+ trigger_checkout(webhook_url=endpoint.url)
198
+ request = sandbox.requests.wait_for(endpoint.slug, timeout="60s")
199
+
200
+ # To keep the endpoints, a human connects the agent to their account.
201
+ claim = sandbox.claim("dev@example.com")
202
+ print(f"Open {claim.verification_uri} and enter {claim.user_code}")
203
+ client = sandbox.wait_for_claim() # a WebhooksCC for that account
204
+ ```
205
+
206
+ The registration is saved in the agent credential file the MCP server, the other SDKs and the `whk` CLI share: `~/.config/whk/agent.json` on Linux (in `$XDG_CONFIG_HOME` when that is set), `~/Library/Application Support/whk/agent.json` on macOS, `%APPDATA%\whk\agent.json` on Windows, or the path in `WHK_AGENT_FILE`. The next `WebhooksCC.sandbox()` reuses a live sandbox instead of registering again, and `credentials_file=False` leaves the file alone. The claim token is never saved, so only the process that registered moves the sandbox endpoints into the account; a sandbox reused from the file starts a new code for the email instead (`claim.moves_endpoints` is false), and its endpoints stay in the sandbox until they expire.
207
+
208
+ A connection lasts 90 days, and any process picks it up:
209
+
210
+ ```python
211
+ client = WebhooksCC.connected() # None when no agent is connected
212
+ client = WebhooksCC() # without WHK_API_KEY, acts for the connected account
213
+ ```
214
+
215
+ `WebhooksCC.connect(email)` connects an agent without a sandbox: show the human `pending.verification_uri` and `pending.user_code`, then `pending.wait()` returns the client. An API key always wins over the file. A refused sandbox call raises `SandboxError` with the server's `code`; the protocol steps themselves are in `webhooks_cc.agent`.
216
+
188
217
  ## Errors
189
218
 
190
219
  API errors raise `WebhooksCCError` with a `status_code`, or one of its subclasses: `UnauthorizedError` (401), `NotFoundError` (404), `RateLimitError` (429, with `retry_after`) and `TimeoutError`, which is also Python's built-in `TimeoutError`. Network failures raise httpx's exceptions. Retries are off unless you pass `retry=RetryOptions(max_attempts=3)`. They repeat GET, PUT and DELETE calls after a network error, a timeout or a status in `retry_on` (429, 500, 502, 503 and 504 by default), and any call after a 429; a POST or PATCH that failed any other way may have gone through, so it is not repeated, and neither is `requests.clear()` without `before`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webhooks-cc"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Python SDK for webhooks.cc: capture, wait for, sign and verify webhooks and test emails"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -13,11 +13,16 @@ your own handlers, and verify provider and forwarding signatures.
13
13
  endpoint.slug, timeout="30s", match=match_body_path("type", "invoice.paid")
14
14
  )
15
15
 
16
+ Without an API key, ``WebhooksCC.sandbox()`` gives an AI agent a sandbox for 24
17
+ hours, and ``WebhooksCC.connect(email)`` connects it to a human's account; see
18
+ :mod:`webhooks_cc.sandbox` and :mod:`webhooks_cc.agent`.
19
+
16
20
  Every signature check for each provider is in :mod:`webhooks_cc.verify`.
17
21
  """
18
22
 
19
- __version__ = "0.2.0"
23
+ __version__ = "0.3.0"
20
24
 
25
+ from .agent import AgentAuthError, ClaimAttempt
21
26
  from .client import (
22
27
  Destinations,
23
28
  Emails,
@@ -27,6 +32,7 @@ from .client import (
27
32
  RetryOptions,
28
33
  WebhooksCC,
29
34
  )
35
+ from .credentials import agent_credentials_path
30
36
  from .durations import Duration, parse_duration
31
37
  from .email import (
32
38
  DEFAULT_EMAIL_DOMAIN,
@@ -87,11 +93,14 @@ from .models import (
87
93
  Request,
88
94
  RequestPage,
89
95
  ResponsePreview,
96
+ SandboxEndpoint,
97
+ SandboxLimits,
90
98
  SenderTime,
91
99
  SendTestEmailResult,
92
100
  TeamShare,
93
101
  UsageInfo,
94
102
  )
103
+ from .sandbox import PendingConnection, SandboxClaim, SandboxClient, SandboxError
95
104
  from .templates import (
96
105
  TEMPLATE_METADATA,
97
106
  TEMPLATE_PROVIDERS,
@@ -115,7 +124,9 @@ __all__ = [
115
124
  "TEMPLATE_METADATA",
116
125
  "TEMPLATE_PROVIDERS",
117
126
  "VERIFY_PROVIDERS",
127
+ "AgentAuthError",
118
128
  "BuiltRequest",
129
+ "ClaimAttempt",
119
130
  "DeliveryCounts",
120
131
  "Destination",
121
132
  "DestinationFilter",
@@ -139,6 +150,7 @@ __all__ = [
139
150
  "Forwarding",
140
151
  "Matcher",
141
152
  "NotFoundError",
153
+ "PendingConnection",
142
154
  "PreviewRequest",
143
155
  "PreviewRule",
144
156
  "PreviewedDelivery",
@@ -151,6 +163,11 @@ __all__ = [
151
163
  "Requests",
152
164
  "ResponsePreview",
153
165
  "RetryOptions",
166
+ "SandboxClaim",
167
+ "SandboxClient",
168
+ "SandboxEndpoint",
169
+ "SandboxError",
170
+ "SandboxLimits",
154
171
  "SendTestEmailResult",
155
172
  "SenderTime",
156
173
  "TeamShare",
@@ -162,6 +179,7 @@ __all__ = [
162
179
  "WebhooksCC",
163
180
  "WebhooksCCError",
164
181
  "__version__",
182
+ "agent_credentials_path",
165
183
  "build_template_request",
166
184
  "capture_during",
167
185
  "capture_email_during",
@@ -0,0 +1,126 @@
1
+ """Access tokens kept fresh from an agent's identity assertion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+
10
+ import httpx
11
+
12
+ from .agent import AccessToken, AgentAuthError, exchange
13
+ from .credentials import AgentCredentialStore, AgentSlot, SlotName, parse_timestamp
14
+
15
+ # Exchange again this long before a token expires.
16
+ _REFRESH_MARGIN_S = 60
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SlotBinding:
21
+ """The credential-file slot a registration lives in, for writing tokens back."""
22
+
23
+ store: AgentCredentialStore
24
+ base_url: str
25
+ name: SlotName
26
+ registration_id: str
27
+
28
+ def save_token(self, token: AccessToken | None) -> None:
29
+ self.store.save_token(
30
+ self.base_url,
31
+ self.name,
32
+ self.registration_id,
33
+ token.access_token if token else None,
34
+ token.expires_at if token else None,
35
+ )
36
+
37
+ def remove(self) -> None:
38
+ self.store.remove(self.base_url, self.name, registration_id=self.registration_id)
39
+
40
+
41
+ def cached_token(slot: AgentSlot) -> AccessToken | None:
42
+ """The slot's cached access token, when it is still usable."""
43
+ token = slot.usable_token()
44
+ millis = parse_timestamp(slot.access_token_expires)
45
+ if token is None or millis is None:
46
+ return None
47
+ return AccessToken(
48
+ access_token=token,
49
+ scope=slot.scope or "",
50
+ expires_at=datetime.fromtimestamp(millis / 1000, tz=timezone.utc),
51
+ )
52
+
53
+
54
+ class AgentTokens:
55
+ """Exchanges the assertion on first use, again shortly before the token expires,
56
+ and on demand after a 401. With a ``binding``, each new token is cached in the
57
+ credential file, and ``invalid_grant`` removes the slot before it is raised.
58
+ ``revoked`` is set once the server refused the assertion that way."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ base_url: str,
64
+ assertion: str,
65
+ http: httpx.Client,
66
+ timeout_ms: float,
67
+ token: AccessToken | None = None,
68
+ binding: SlotBinding | None = None,
69
+ ) -> None:
70
+ self._base_url = base_url
71
+ self._assertion = assertion
72
+ self._http = http
73
+ self._timeout_ms = timeout_ms
74
+ self._token = token
75
+ self._binding = binding
76
+ self._lock = threading.Lock()
77
+ self.revoked = False
78
+
79
+ def get(self, force_refresh: bool = False) -> str:
80
+ with self._lock:
81
+ current = self._token
82
+ if (
83
+ not force_refresh
84
+ and current is not None
85
+ and current.expires_at.timestamp() - time.time() > _REFRESH_MARGIN_S
86
+ ):
87
+ return current.access_token
88
+ try:
89
+ token = exchange(
90
+ self._assertion,
91
+ base_url=self._base_url,
92
+ timeout=self._timeout_ms,
93
+ http_client=self._http,
94
+ )
95
+ except AgentAuthError as error:
96
+ if error.code == "invalid_grant":
97
+ self._token = None
98
+ self.revoked = True
99
+ self._forget_slot()
100
+ raise
101
+ self._token = token
102
+ if self._binding is not None:
103
+ self._binding.save_token(token)
104
+ return token.access_token
105
+
106
+ def peek(self) -> str | None:
107
+ """The current token, without exchanging."""
108
+ return self._token.access_token if self._token is not None else None
109
+
110
+ def drop(self) -> None:
111
+ """Forget the current token (it was revoked), here and in the file."""
112
+ with self._lock:
113
+ self._token = None
114
+ if self._binding is not None:
115
+ self._binding.save_token(None)
116
+
117
+ def forget_slot(self) -> None:
118
+ """Remove the slot from the credential file (the registration is over), unless
119
+ another process has put another registration there since."""
120
+ with self._lock:
121
+ self._forget_slot()
122
+
123
+ def _forget_slot(self) -> None:
124
+ if self._binding is not None:
125
+ self._binding.remove()
126
+ self._binding = None