replylayer 0.26.0__tar.gz → 0.27.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.
- replylayer-0.26.0/README.md → replylayer-0.27.0/PKG-INFO +48 -0
- replylayer-0.26.0/PKG-INFO → replylayer-0.27.0/README.md +28 -20
- {replylayer-0.26.0 → replylayer-0.27.0}/pyproject.toml +1 -1
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/__init__.py +5 -1
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_http.py +1 -1
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/webhooks.py +78 -7
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/types.py +23 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_webhooks.py +149 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/.gitignore +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/__main__.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_client.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/errors.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/py.typed +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/messages.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/policy.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/simulator.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/__init__.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_async.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_attachments.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_client.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_domains.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_drafts.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_governed_email_effect.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_http.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_instruction_trust.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_readme_resource_parity.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_resources.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_threads.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_version.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_ws1_ws6.py +0 -0
- {replylayer-0.26.0 → replylayer-0.27.0}/uv.lock +0 -0
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: replylayer
|
|
3
|
+
Version: 0.27.0
|
|
4
|
+
Summary: Official Python SDK for ReplyLayer — email for AI agents
|
|
5
|
+
Project-URL: Homepage, https://replylayer.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/replylayer/rly
|
|
7
|
+
Project-URL: Issues, https://github.com/replylayer/rly/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: typing-extensions>=4.0
|
|
13
|
+
Provides-Extra: cli
|
|
14
|
+
Requires-Dist: rly>=0.6.3; extra == 'cli'
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
1
21
|
# replylayer
|
|
2
22
|
|
|
3
23
|
Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
|
|
@@ -256,6 +276,34 @@ result = rl.messages.send(
|
|
|
256
276
|
|
|
257
277
|
A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
|
|
258
278
|
|
|
279
|
+
## Webhook custom request headers
|
|
280
|
+
|
|
281
|
+
For a receiver that authenticates by a fixed header rather than by ReplyLayer's HMAC, `webhooks.create` and `webhooks.update` accept `request_headers` — up to 8 static headers added to every delivery attempt for that webhook (first attempt, every retry, `test()`, and a manual `retry_delivery`).
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
wh = rl.webhooks.create(
|
|
285
|
+
url="https://your-server.com/hooks/replylayer",
|
|
286
|
+
enabled_events=["message.received"],
|
|
287
|
+
request_headers={"Authorization": "Bearer your-receiver-token"},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
wh["request_header_names"] # ['authorization'] — names only, lower-cased + sorted
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Values are write-only.** They are encrypted at rest and returned by no read path: every webhook response carries `request_header_names` (`[]` when none) and never a value. Because of that, `update` is three-way and there is no per-header merge:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
rl.webhooks.update(wh["id"], request_headers={"Authorization": "Bearer rotated"}) # replaces the whole map
|
|
297
|
+
rl.webhooks.update(wh["id"], request_headers=None) # clears every header
|
|
298
|
+
rl.webhooks.update(wh["id"], enabled=False) # leaves headers untouched
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`None` means "clear" here, so omitting the argument entirely is the only way to leave the configured headers alone.
|
|
302
|
+
|
|
303
|
+
Names match `^[A-Za-z0-9-]{1,64}$` and are compared case-insensitively; values are 1–1024 printable-ASCII characters. Reserved names — `content-type`, `content-length`, `host`, `user-agent`, `x-replylayer-signature`, `x-webhook-timestamp`, `x-webhook-signature-v2`, `transfer-encoding`, `connection`, `expect`, `te`, `upgrade`, `keep-alive`, `trailer`, and anything starting with `proxy-` — are rejected. The SDK does not pre-validate: rejections arrive as `ReplyLayerError` with `.code` set to `WEBHOOK_HEADER_RESERVED`, `WEBHOOK_HEADER_DUPLICATE`, `WEBHOOK_HEADER_INVALID`, or `VALIDATION_ERROR`.
|
|
304
|
+
|
|
305
|
+
Deliveries for a webhook with custom headers store no response body (`response_preview` is always `None`), so a receiver that echoes the request cannot leak a configured credential back into the delivery history.
|
|
306
|
+
|
|
259
307
|
## Delivery history & manual retry
|
|
260
308
|
|
|
261
309
|
`rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: replylayer
|
|
3
|
-
Version: 0.26.0
|
|
4
|
-
Summary: Official Python SDK for ReplyLayer — email for AI agents
|
|
5
|
-
Project-URL: Homepage, https://replylayer.ai
|
|
6
|
-
Project-URL: Repository, https://github.com/replylayer/rly
|
|
7
|
-
Project-URL: Issues, https://github.com/replylayer/rly/issues
|
|
8
|
-
License-Expression: MIT
|
|
9
|
-
Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
|
|
10
|
-
Requires-Python: >=3.10
|
|
11
|
-
Requires-Dist: httpx>=0.27
|
|
12
|
-
Requires-Dist: typing-extensions>=4.0
|
|
13
|
-
Provides-Extra: cli
|
|
14
|
-
Requires-Dist: rly>=0.6.3; extra == 'cli'
|
|
15
|
-
Provides-Extra: dev
|
|
16
|
-
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
17
|
-
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
18
|
-
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
|
|
21
1
|
# replylayer
|
|
22
2
|
|
|
23
3
|
Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
|
|
@@ -276,6 +256,34 @@ result = rl.messages.send(
|
|
|
276
256
|
|
|
277
257
|
A handle is **consumed once** at send and is single-mailbox-scoped (upload to the same mailbox you send from). Unconsumed handles expire after 24h; delete one early with `rl.attachments.delete_upload(handle["id"])`. Limits: 10 MB/file, 10 attachments and 15 MB total per message. Image attachments require a separate one-time image-risk disclaimer on the mailbox (`OUTBOUND_IMAGE_DISCLAIMER_REQUIRED`). Drafts hold handles and consume them at dispatch; `rl.drafts.update(draft_id, attachment_ids=None)` clears a draft's attachments. Attachment bytes are stored with provider-managed encryption-at-rest and transmitted over TLS — this is not end-to-end / zero-access encryption (the platform scans attachment content).
|
|
278
258
|
|
|
259
|
+
## Webhook custom request headers
|
|
260
|
+
|
|
261
|
+
For a receiver that authenticates by a fixed header rather than by ReplyLayer's HMAC, `webhooks.create` and `webhooks.update` accept `request_headers` — up to 8 static headers added to every delivery attempt for that webhook (first attempt, every retry, `test()`, and a manual `retry_delivery`).
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
wh = rl.webhooks.create(
|
|
265
|
+
url="https://your-server.com/hooks/replylayer",
|
|
266
|
+
enabled_events=["message.received"],
|
|
267
|
+
request_headers={"Authorization": "Bearer your-receiver-token"},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
wh["request_header_names"] # ['authorization'] — names only, lower-cased + sorted
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Values are write-only.** They are encrypted at rest and returned by no read path: every webhook response carries `request_header_names` (`[]` when none) and never a value. Because of that, `update` is three-way and there is no per-header merge:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
rl.webhooks.update(wh["id"], request_headers={"Authorization": "Bearer rotated"}) # replaces the whole map
|
|
277
|
+
rl.webhooks.update(wh["id"], request_headers=None) # clears every header
|
|
278
|
+
rl.webhooks.update(wh["id"], enabled=False) # leaves headers untouched
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`None` means "clear" here, so omitting the argument entirely is the only way to leave the configured headers alone.
|
|
282
|
+
|
|
283
|
+
Names match `^[A-Za-z0-9-]{1,64}$` and are compared case-insensitively; values are 1–1024 printable-ASCII characters. Reserved names — `content-type`, `content-length`, `host`, `user-agent`, `x-replylayer-signature`, `x-webhook-timestamp`, `x-webhook-signature-v2`, `transfer-encoding`, `connection`, `expect`, `te`, `upgrade`, `keep-alive`, `trailer`, and anything starting with `proxy-` — are rejected. The SDK does not pre-validate: rejections arrive as `ReplyLayerError` with `.code` set to `WEBHOOK_HEADER_RESERVED`, `WEBHOOK_HEADER_DUPLICATE`, `WEBHOOK_HEADER_INVALID`, or `VALIDATION_ERROR`.
|
|
284
|
+
|
|
285
|
+
Deliveries for a webhook with custom headers store no response body (`response_preview` is always `None`), so a receiver that echoes the request cannot leak a configured credential back into the delivery history.
|
|
286
|
+
|
|
279
287
|
## Delivery history & manual retry
|
|
280
288
|
|
|
281
289
|
`rl.webhooks.list_deliveries(id, limit=..., before_at=..., before_id=...)` returns the most recent delivery attempts for a webhook with tuple-cursor keyset pagination. `before_at` and `before_id` must be provided together — the SDK omits the cursor entirely if only one is given.
|
|
@@ -29,6 +29,8 @@ from .types import (
|
|
|
29
29
|
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
30
30
|
AsyncSendAck,
|
|
31
31
|
WebhookEventType,
|
|
32
|
+
# Migration 134 — per-webhook custom request headers (write-only values).
|
|
33
|
+
WebhookRequestHeaders,
|
|
32
34
|
WebhookSummary,
|
|
33
35
|
WebhookDeliverySummary,
|
|
34
36
|
WebhookDeliveryStatus,
|
|
@@ -120,7 +122,7 @@ from .types import (
|
|
|
120
122
|
PolicyPreviewResult,
|
|
121
123
|
)
|
|
122
124
|
|
|
123
|
-
__version__ = "0.
|
|
125
|
+
__version__ = "0.27.0"
|
|
124
126
|
|
|
125
127
|
__all__ = [
|
|
126
128
|
# Message delete response (0.20.0).
|
|
@@ -155,6 +157,8 @@ __all__ = [
|
|
|
155
157
|
"EmailEffect",
|
|
156
158
|
"EffectStatus",
|
|
157
159
|
"WebhookEventType",
|
|
160
|
+
# Migration 134 — per-webhook custom request headers (write-only values).
|
|
161
|
+
"WebhookRequestHeaders",
|
|
158
162
|
"WebhookSummary",
|
|
159
163
|
"WebhookDeliverySummary",
|
|
160
164
|
"WebhookDeliveryStatus",
|
|
@@ -9,6 +9,21 @@ from .._http import AsyncHttpClient, SyncHttpClient
|
|
|
9
9
|
from ..errors import WebhookSignatureError
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class _Unset:
|
|
13
|
+
"""Sentinel for "this key was not supplied at all"."""
|
|
14
|
+
|
|
15
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
16
|
+
return "<UNSET>"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Migration 134 — ``request_headers`` on update is THREE-way, exactly like
|
|
20
|
+
# ``description``: a dict REPLACES the whole map, ``None`` CLEARS it, and
|
|
21
|
+
# omitting the argument leaves the configured headers untouched. ``None`` is a
|
|
22
|
+
# meaningful value here, so "absent" needs its own sentinel — the same pattern
|
|
23
|
+
# ``drafts.update``'s ``send_at`` / ``attachment_ids`` already use.
|
|
24
|
+
_UNSET: Any = _Unset()
|
|
25
|
+
|
|
26
|
+
|
|
12
27
|
def verify_webhook_signature(
|
|
13
28
|
payload: str | bytes,
|
|
14
29
|
signature: str,
|
|
@@ -58,7 +73,15 @@ class SyncWebhooks:
|
|
|
58
73
|
def __init__(self, http: SyncHttpClient) -> None:
|
|
59
74
|
self._http = http
|
|
60
75
|
|
|
61
|
-
def create(
|
|
76
|
+
def create(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
url: str,
|
|
80
|
+
enabled_events: list[str],
|
|
81
|
+
description: str | None = None,
|
|
82
|
+
enabled: bool = True,
|
|
83
|
+
request_headers: dict[str, str] | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
62
85
|
"""Create a new webhook subscription.
|
|
63
86
|
|
|
64
87
|
Raises ``ForbiddenError`` (HTTP 403) when the account is at its
|
|
@@ -67,10 +90,25 @@ class SyncWebhooks:
|
|
|
67
90
|
"max_allowed": int}`` so callers can render actionable upgrade
|
|
68
91
|
prompts. Cap matrix: Sandbox 1, Starter 3, Pro 25, Team 100,
|
|
69
92
|
Scale 250, Enterprise unbounded.
|
|
93
|
+
|
|
94
|
+
``request_headers`` (migration 134) is an optional map of at most 8
|
|
95
|
+
static headers added to every delivery attempt for this webhook — for
|
|
96
|
+
a receiver that authenticates by a fixed header rather than by our
|
|
97
|
+
HMAC. It is passed through VERBATIM: the SDK does not pre-validate,
|
|
98
|
+
because the server is the single validator (and re-validates again on
|
|
99
|
+
the delivery path). Rejections arrive as ``ReplyLayerError`` with
|
|
100
|
+
``code`` ``WEBHOOK_HEADER_RESERVED`` / ``WEBHOOK_HEADER_DUPLICATE`` /
|
|
101
|
+
``WEBHOOK_HEADER_INVALID`` / ``VALIDATION_ERROR``.
|
|
102
|
+
|
|
103
|
+
The configured VALUES are write-only — no read path returns one. The
|
|
104
|
+
response carries ``request_header_names`` (lower-cased and sorted)
|
|
105
|
+
instead.
|
|
70
106
|
"""
|
|
71
107
|
payload: dict[str, Any] = {"url": url, "enabled_events": enabled_events, "enabled": enabled}
|
|
72
108
|
if description is not None:
|
|
73
109
|
payload["description"] = description
|
|
110
|
+
if request_headers is not None:
|
|
111
|
+
payload["request_headers"] = request_headers
|
|
74
112
|
return self._http.request("POST", "/v1/webhooks", body=payload)
|
|
75
113
|
|
|
76
114
|
def list(self) -> dict[str, Any]:
|
|
@@ -79,8 +117,21 @@ class SyncWebhooks:
|
|
|
79
117
|
def get(self, id: str) -> dict[str, Any]:
|
|
80
118
|
return self._http.request("GET", f"/v1/webhooks/{id}")
|
|
81
119
|
|
|
82
|
-
def update(self, id: str, **kwargs: Any) -> dict[str, Any]:
|
|
83
|
-
|
|
120
|
+
def update(self, id: str, *, request_headers: Any = _UNSET, **kwargs: Any) -> dict[str, Any]:
|
|
121
|
+
"""Partial update — only the supplied keys are changed.
|
|
122
|
+
|
|
123
|
+
``request_headers`` is three-way (migration 134): a dict REPLACES the
|
|
124
|
+
whole map, ``None`` CLEARS every configured header, and omitting the
|
|
125
|
+
argument leaves them untouched. There is no per-header merge — the
|
|
126
|
+
values are write-only, so a caller cannot see what is already
|
|
127
|
+
configured and a partial update could not be expressed. A rejected map
|
|
128
|
+
fails the whole PATCH and the previously configured headers stay in
|
|
129
|
+
place.
|
|
130
|
+
"""
|
|
131
|
+
body: dict[str, Any] = dict(kwargs)
|
|
132
|
+
if request_headers is not _UNSET:
|
|
133
|
+
body["request_headers"] = request_headers
|
|
134
|
+
return self._http.request("PATCH", f"/v1/webhooks/{id}", body=body)
|
|
84
135
|
|
|
85
136
|
def delete(self, id: str) -> dict[str, Any]:
|
|
86
137
|
return self._http.request("DELETE", f"/v1/webhooks/{id}")
|
|
@@ -124,11 +175,25 @@ class AsyncWebhooks:
|
|
|
124
175
|
def __init__(self, http: AsyncHttpClient) -> None:
|
|
125
176
|
self._http = http
|
|
126
177
|
|
|
127
|
-
async def create(
|
|
128
|
-
|
|
178
|
+
async def create(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
url: str,
|
|
182
|
+
enabled_events: list[str],
|
|
183
|
+
description: str | None = None,
|
|
184
|
+
enabled: bool = True,
|
|
185
|
+
request_headers: dict[str, str] | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Create a new webhook subscription.
|
|
188
|
+
|
|
189
|
+
See ``SyncWebhooks.create`` for tier-cap details and the
|
|
190
|
+
``request_headers`` contract (migration 134).
|
|
191
|
+
"""
|
|
129
192
|
payload: dict[str, Any] = {"url": url, "enabled_events": enabled_events, "enabled": enabled}
|
|
130
193
|
if description is not None:
|
|
131
194
|
payload["description"] = description
|
|
195
|
+
if request_headers is not None:
|
|
196
|
+
payload["request_headers"] = request_headers
|
|
132
197
|
return await self._http.request("POST", "/v1/webhooks", body=payload)
|
|
133
198
|
|
|
134
199
|
async def list(self) -> dict[str, Any]:
|
|
@@ -137,8 +202,14 @@ class AsyncWebhooks:
|
|
|
137
202
|
async def get(self, id: str) -> dict[str, Any]:
|
|
138
203
|
return await self._http.request("GET", f"/v1/webhooks/{id}")
|
|
139
204
|
|
|
140
|
-
async def update(self, id: str, **kwargs: Any) -> dict[str, Any]:
|
|
141
|
-
|
|
205
|
+
async def update(self, id: str, *, request_headers: Any = _UNSET, **kwargs: Any) -> dict[str, Any]:
|
|
206
|
+
"""Partial update. See ``SyncWebhooks.update`` for the three-way
|
|
207
|
+
``request_headers`` semantics (dict replaces / ``None`` clears /
|
|
208
|
+
absent leaves untouched)."""
|
|
209
|
+
body: dict[str, Any] = dict(kwargs)
|
|
210
|
+
if request_headers is not _UNSET:
|
|
211
|
+
body["request_headers"] = request_headers
|
|
212
|
+
return await self._http.request("PATCH", f"/v1/webhooks/{id}", body=body)
|
|
142
213
|
|
|
143
214
|
async def delete(self, id: str) -> dict[str, Any]:
|
|
144
215
|
return await self._http.request("DELETE", f"/v1/webhooks/{id}")
|
|
@@ -1653,6 +1653,23 @@ GetUploadAttachmentResponse = Union[UploadAttachmentResponse, ConsumedAttachment
|
|
|
1653
1653
|
|
|
1654
1654
|
# === Webhooks ===
|
|
1655
1655
|
|
|
1656
|
+
# Migration 134 — static request headers added to every delivery for one
|
|
1657
|
+
# webhook, the shape accepted on create and update.
|
|
1658
|
+
#
|
|
1659
|
+
# At most 8 entries. Names match ``^[A-Za-z0-9-]{1,64}$`` and are compared
|
|
1660
|
+
# case-insensitively; values are 1-1024 printable-ASCII (0x20-0x7E)
|
|
1661
|
+
# characters. Reserved names (``content-type``, ``content-length``, ``host``,
|
|
1662
|
+
# ``user-agent``, ``x-replylayer-signature``, ``x-webhook-timestamp``,
|
|
1663
|
+
# ``x-webhook-signature-v2``, ``transfer-encoding``, ``connection``,
|
|
1664
|
+
# ``expect``, ``te``, ``upgrade``, ``keep-alive``, ``trailer``, and anything
|
|
1665
|
+
# starting with ``proxy-``) are rejected — they carry ReplyLayer's own
|
|
1666
|
+
# signature and framing, or address the connection rather than the receiver.
|
|
1667
|
+
#
|
|
1668
|
+
# The VALUES are write-only: no read path returns one. Responses carry
|
|
1669
|
+
# ``request_header_names`` instead.
|
|
1670
|
+
WebhookRequestHeaders = dict[str, str]
|
|
1671
|
+
|
|
1672
|
+
|
|
1656
1673
|
class CreateWebhookResponse(TypedDict):
|
|
1657
1674
|
id: str
|
|
1658
1675
|
url: str
|
|
@@ -1660,6 +1677,10 @@ class CreateWebhookResponse(TypedDict):
|
|
|
1660
1677
|
enabled: bool
|
|
1661
1678
|
enabled_events: list[WebhookEventType]
|
|
1662
1679
|
signing_secret: str
|
|
1680
|
+
# Configured custom-header NAMES, lower-cased and sorted; ``[]`` when none.
|
|
1681
|
+
# Never a value — those are write-only. Present on every webhook response
|
|
1682
|
+
# (create, list, get, update).
|
|
1683
|
+
request_header_names: list[str]
|
|
1663
1684
|
created_at: str
|
|
1664
1685
|
|
|
1665
1686
|
|
|
@@ -1669,6 +1690,8 @@ class WebhookSummary(TypedDict):
|
|
|
1669
1690
|
description: str | None
|
|
1670
1691
|
enabled: bool
|
|
1671
1692
|
enabled_events: list[WebhookEventType]
|
|
1693
|
+
# See CreateWebhookResponse.request_header_names. Names only, [] when none.
|
|
1694
|
+
request_header_names: list[str]
|
|
1672
1695
|
created_at: str
|
|
1673
1696
|
updated_at: str
|
|
1674
1697
|
# D6: health fields surfaced by the delivery worker
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import hmac
|
|
3
|
+
import json
|
|
3
4
|
import time
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -45,6 +46,154 @@ def test_webhooks_rotate_secret():
|
|
|
45
46
|
assert res["signing_secret"] == "whsec_new"
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
# ── Migration 134: per-webhook custom request headers ──
|
|
50
|
+
#
|
|
51
|
+
# The SDK is a pass-through: no client-side validation of the map (the server
|
|
52
|
+
# is the single validator and re-validates on the delivery path). What these
|
|
53
|
+
# pin is the WIRE — that the map reaches the body verbatim, that ``None`` is
|
|
54
|
+
# serialized as a clear rather than dropped, and that every response surfaces
|
|
55
|
+
# names and never a value. Mirrors packages/sdk/src/__tests__/webhooks.test.ts.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@respx.mock
|
|
59
|
+
def test_webhooks_create_with_request_headers():
|
|
60
|
+
route = respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(201, json={
|
|
61
|
+
"id": "wh3", "url": "https://example.com/wh", "description": None,
|
|
62
|
+
"enabled": True, "enabled_events": ["message.received"],
|
|
63
|
+
"signing_secret": "whsec_h", "request_header_names": ["authorization"],
|
|
64
|
+
"created_at": "2026-09-12T00:00:00Z",
|
|
65
|
+
}))
|
|
66
|
+
res = sdk().webhooks.create(
|
|
67
|
+
url="https://example.com/wh",
|
|
68
|
+
enabled_events=["message.received"],
|
|
69
|
+
request_headers={"Authorization": "Bearer receiver-token"},
|
|
70
|
+
)
|
|
71
|
+
body = json.loads(route.calls.last.request.content)
|
|
72
|
+
assert body["request_headers"] == {"Authorization": "Bearer receiver-token"}
|
|
73
|
+
# Names come back lower-cased and sorted; the VALUE is never returned.
|
|
74
|
+
assert res["request_header_names"] == ["authorization"]
|
|
75
|
+
assert "receiver-token" not in json.dumps(res)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@respx.mock
|
|
79
|
+
def test_webhooks_create_without_request_headers_omits_the_key():
|
|
80
|
+
route = respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(201, json={
|
|
81
|
+
"id": "wh4", "url": "https://example.com/wh", "description": None,
|
|
82
|
+
"enabled": True, "enabled_events": ["message.received"],
|
|
83
|
+
"signing_secret": "whsec_n", "request_header_names": [],
|
|
84
|
+
"created_at": "2026-09-12T00:00:00Z",
|
|
85
|
+
}))
|
|
86
|
+
res = sdk().webhooks.create(url="https://example.com/wh", enabled_events=["message.received"])
|
|
87
|
+
body = json.loads(route.calls.last.request.content)
|
|
88
|
+
assert "request_headers" not in body
|
|
89
|
+
assert res["request_header_names"] == []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _summary(**overrides: object) -> dict:
|
|
93
|
+
row = {
|
|
94
|
+
"id": "wh3", "url": "https://example.com/wh", "description": None,
|
|
95
|
+
"enabled": True, "enabled_events": ["message.received"],
|
|
96
|
+
"request_header_names": [],
|
|
97
|
+
"created_at": "2026-09-12T00:00:00Z", "updated_at": "2026-09-12T00:00:00Z",
|
|
98
|
+
"consecutive_failures": 0, "last_success_at": None, "last_failure_at": None,
|
|
99
|
+
"last_error": None, "disabled_at": None, "disabled_reason": None,
|
|
100
|
+
}
|
|
101
|
+
row.update(overrides)
|
|
102
|
+
return row
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@respx.mock
|
|
106
|
+
def test_webhooks_update_replaces_the_whole_map():
|
|
107
|
+
route = respx.patch(f"{BASE}/v1/webhooks/wh3").mock(
|
|
108
|
+
return_value=httpx.Response(200, json=_summary(request_header_names=["x-api-key"]))
|
|
109
|
+
)
|
|
110
|
+
res = sdk().webhooks.update("wh3", request_headers={"X-Api-Key": "rotated"})
|
|
111
|
+
body = json.loads(route.calls.last.request.content)
|
|
112
|
+
assert body == {"request_headers": {"X-Api-Key": "rotated"}}
|
|
113
|
+
assert res["request_header_names"] == ["x-api-key"]
|
|
114
|
+
assert "rotated" not in json.dumps(res)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@respx.mock
|
|
118
|
+
def test_webhooks_update_none_clears():
|
|
119
|
+
route = respx.patch(f"{BASE}/v1/webhooks/wh3").mock(
|
|
120
|
+
return_value=httpx.Response(200, json=_summary())
|
|
121
|
+
)
|
|
122
|
+
res = sdk().webhooks.update("wh3", request_headers=None)
|
|
123
|
+
body = json.loads(route.calls.last.request.content)
|
|
124
|
+
# None is a CLEAR, not "absent" — the key must survive serialization.
|
|
125
|
+
assert "request_headers" in body
|
|
126
|
+
assert body["request_headers"] is None
|
|
127
|
+
assert res["request_header_names"] == []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@respx.mock
|
|
131
|
+
def test_webhooks_update_absent_leaves_headers_untouched():
|
|
132
|
+
route = respx.patch(f"{BASE}/v1/webhooks/wh3").mock(
|
|
133
|
+
return_value=httpx.Response(200, json=_summary(enabled=False, request_header_names=["authorization"]))
|
|
134
|
+
)
|
|
135
|
+
res = sdk().webhooks.update("wh3", enabled=False)
|
|
136
|
+
body = json.loads(route.calls.last.request.content)
|
|
137
|
+
assert body == {"enabled": False}
|
|
138
|
+
assert "request_headers" not in body
|
|
139
|
+
assert res["request_header_names"] == ["authorization"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@respx.mock
|
|
143
|
+
def test_webhooks_list_and_get_surface_names_only():
|
|
144
|
+
row = _summary(request_header_names=["authorization", "x-api-key"])
|
|
145
|
+
respx.get(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(200, json={"webhooks": [row]}))
|
|
146
|
+
respx.get(f"{BASE}/v1/webhooks/wh3").mock(return_value=httpx.Response(200, json=row))
|
|
147
|
+
rl = sdk()
|
|
148
|
+
assert rl.webhooks.list()["webhooks"][0]["request_header_names"] == ["authorization", "x-api-key"]
|
|
149
|
+
assert rl.webhooks.get("wh3")["request_header_names"] == ["authorization", "x-api-key"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@respx.mock
|
|
153
|
+
def test_webhooks_reserved_header_surfaces_server_code():
|
|
154
|
+
respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(400, json={
|
|
155
|
+
"error": 'Header name "host" is reserved by ReplyLayer and cannot be set on a webhook.',
|
|
156
|
+
"code": "WEBHOOK_HEADER_RESERVED",
|
|
157
|
+
}))
|
|
158
|
+
with pytest.raises(ReplyLayerError) as exc_info:
|
|
159
|
+
sdk().webhooks.create(
|
|
160
|
+
url="https://example.com/wh",
|
|
161
|
+
enabled_events=["message.received"],
|
|
162
|
+
request_headers={"Host": "evil.example.com"},
|
|
163
|
+
)
|
|
164
|
+
assert exc_info.value.code == "WEBHOOK_HEADER_RESERVED"
|
|
165
|
+
assert exc_info.value.status_code == 400
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@respx.mock
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_async_webhooks_create_and_clear_mirror_sync():
|
|
171
|
+
create_route = respx.post(f"{BASE}/v1/webhooks").mock(return_value=httpx.Response(201, json={
|
|
172
|
+
"id": "wh3", "url": "https://example.com/wh", "description": None,
|
|
173
|
+
"enabled": True, "enabled_events": ["message.received"],
|
|
174
|
+
"signing_secret": "whsec_h", "request_header_names": ["authorization"],
|
|
175
|
+
"created_at": "2026-09-12T00:00:00Z",
|
|
176
|
+
}))
|
|
177
|
+
patch_route = respx.patch(f"{BASE}/v1/webhooks/wh3").mock(
|
|
178
|
+
return_value=httpx.Response(200, json=_summary())
|
|
179
|
+
)
|
|
180
|
+
rl = async_sdk()
|
|
181
|
+
created = await rl.webhooks.create(
|
|
182
|
+
url="https://example.com/wh",
|
|
183
|
+
enabled_events=["message.received"],
|
|
184
|
+
request_headers={"Authorization": "Bearer receiver-token"},
|
|
185
|
+
)
|
|
186
|
+
assert json.loads(create_route.calls.last.request.content)["request_headers"] == {
|
|
187
|
+
"Authorization": "Bearer receiver-token"
|
|
188
|
+
}
|
|
189
|
+
assert created["request_header_names"] == ["authorization"]
|
|
190
|
+
|
|
191
|
+
cleared = await rl.webhooks.update("wh3", request_headers=None)
|
|
192
|
+
assert json.loads(patch_route.calls.last.request.content)["request_headers"] is None
|
|
193
|
+
assert cleared["request_header_names"] == []
|
|
194
|
+
await rl.aclose()
|
|
195
|
+
|
|
196
|
+
|
|
48
197
|
def test_verify_signature_valid():
|
|
49
198
|
payload = '{"event":"message.received"}'
|
|
50
199
|
sig = make_signature(payload, SECRET)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|