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.
Files changed (48) hide show
  1. replylayer-0.26.0/README.md → replylayer-0.27.0/PKG-INFO +48 -0
  2. replylayer-0.26.0/PKG-INFO → replylayer-0.27.0/README.md +28 -20
  3. {replylayer-0.26.0 → replylayer-0.27.0}/pyproject.toml +1 -1
  4. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/__init__.py +5 -1
  5. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_http.py +1 -1
  6. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/webhooks.py +78 -7
  7. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/types.py +23 -0
  8. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_webhooks.py +149 -0
  9. {replylayer-0.26.0 → replylayer-0.27.0}/.gitignore +0 -0
  10. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/__main__.py +0 -0
  11. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_client.py +0 -0
  12. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/_pagination.py +0 -0
  13. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/errors.py +0 -0
  14. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/py.typed +0 -0
  15. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/__init__.py +0 -0
  16. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/account.py +0 -0
  17. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/api_keys.py +0 -0
  18. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/attachments.py +0 -0
  19. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/domains.py +0 -0
  20. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/drafts.py +0 -0
  21. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/health.py +0 -0
  22. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/inbound_blocklist.py +0 -0
  23. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/legal_holds.py +0 -0
  24. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/mailboxes.py +0 -0
  25. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/messages.py +0 -0
  26. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/policy.py +0 -0
  27. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/recipients.py +0 -0
  28. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/simulator.py +0 -0
  29. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/suppressions.py +0 -0
  30. {replylayer-0.26.0 → replylayer-0.27.0}/replylayer/resources/threads.py +0 -0
  31. {replylayer-0.26.0 → replylayer-0.27.0}/tests/__init__.py +0 -0
  32. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_async.py +0 -0
  33. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_attachments.py +0 -0
  34. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_client.py +0 -0
  35. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_domains.py +0 -0
  36. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_drafts.py +0 -0
  37. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_governed_email_effect.py +0 -0
  38. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_hitl_review_types.py +0 -0
  39. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_http.py +0 -0
  40. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_instruction_trust.py +0 -0
  41. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_messages_idempotency.py +0 -0
  42. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_readme_resource_parity.py +0 -0
  43. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_resources.py +0 -0
  44. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_threads.py +0 -0
  45. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_version.py +0 -0
  46. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_web_risk_types.py +0 -0
  47. {replylayer-0.26.0 → replylayer-0.27.0}/tests/test_ws1_ws6.py +0 -0
  48. {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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.26.0"
7
+ version = "0.27.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.26.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,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.26.0"
12
+ _VERSION = "0.27.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -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(self, *, url: str, enabled_events: list[str], description: str | None = None, enabled: bool = True) -> dict[str, Any]:
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
- return self._http.request("PATCH", f"/v1/webhooks/{id}", body=kwargs)
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(self, *, url: str, enabled_events: list[str], description: str | None = None, enabled: bool = True) -> dict[str, Any]:
128
- """Create a new webhook subscription. See SyncWebhooks.create for tier-cap details."""
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
- return await self._http.request("PATCH", f"/v1/webhooks/{id}", body=kwargs)
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