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.
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/PKG-INFO +31 -2
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/README.md +30 -1
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/pyproject.toml +1 -1
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/__init__.py +19 -1
- webhooks_cc-0.3.0/src/webhooks_cc/_agent_tokens.py +126 -0
- webhooks_cc-0.3.0/src/webhooks_cc/agent.py +645 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/client.py +244 -6
- webhooks_cc-0.3.0/src/webhooks_cc/credentials.py +502 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/models.py +47 -1
- webhooks_cc-0.3.0/src/webhooks_cc/sandbox.py +711 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/.gitignore +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/LICENSE +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_crypto.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_js.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_sentinel.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/_sse.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/durations.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/email.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/errors.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/matchers.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/py.typed +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/pytest_plugin.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/templates.py +0 -0
- {webhooks_cc-0.2.0 → webhooks_cc-0.3.0}/src/webhooks_cc/testing.py +0 -0
- {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.
|
|
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`.
|
|
@@ -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.
|
|
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
|